diff --git a/dashboard/backend/api/routers/admin_users.py b/dashboard/backend/api/routers/admin_users.py
index 6847f662..a53ac897 100644
--- a/dashboard/backend/api/routers/admin_users.py
+++ b/dashboard/backend/api/routers/admin_users.py
@@ -228,6 +228,61 @@ def get_user(user_id: int, _admin: dict = Depends(require_admin)):
return {"user": payload}
+@admin_router.get("/users/{user_id}/agent-activity")
+def get_user_agent_activity(
+ user_id: int,
+ limit: int = Query(default=50, ge=1, le=100),
+ offset: int = Query(default=0, ge=0),
+ _admin: dict = Depends(require_admin),
+):
+ """Return the admin projection from a user to traceable agent runs."""
+ if users_module.user_store.get_user_admin(user_id) is None:
+ raise HTTPException(status_code=404, detail="User not found")
+
+ # Trace persistence is durable in production while protocol runs are
+ # diskless. Read the durable projection first, then use local runs only to
+ # enrich matching rows that still exist after a deploy.
+ from dashboard.backend.domain.agents.repository import agent_store
+ from dashboard.backend.domain.runs.repository import run_store
+ from dashboard.backend.domain.traces.repository import trace_store
+
+ agents = agent_store.list_agents(owner_user_id=user_id)
+ agent_ids = [agent["agent_id"] for agent in agents]
+ traces = trace_store.list_traces(agent_ids=agent_ids, limit=limit, offset=offset)
+ local_runs = {
+ run["run_id"]: run
+ for agent_id in agent_ids
+ for run in run_store.list_runs(agent_id)
+ }
+ activity = []
+ traces_by_agent = {agent_id: [] for agent_id in agent_ids}
+ for trace in traces["items"]:
+ run = local_runs.get(trace.get("run_id"), {})
+ traces_by_agent.setdefault(trace.get("agent_id"), []).append({
+ "run_id": trace.get("run_id"),
+ "status": run.get("status") or trace.get("status"),
+ "created_at": run.get("created_at") or trace.get("created_at"),
+ "trace_id": trace.get("trace_id"),
+ "trace_status": trace.get("status"),
+ })
+ for agent in agents:
+ activity.append({
+ "agent_id": agent["agent_id"],
+ "name": agent.get("name") or agent["agent_id"],
+ "agent_type": agent.get("agent_type") or "external",
+ "created_at": agent.get("created_at"),
+ "runs": traces_by_agent.get(agent["agent_id"], []),
+ })
+ return {
+ "user_id": user_id,
+ "agents": activity,
+ "limit": limit,
+ "offset": offset,
+ "has_more": traces["has_more"],
+ "next_cursor": traces["next_cursor"],
+ }
+
+
@admin_router.patch("/users/{user_id}")
def patch_user(
user_id: int,
diff --git a/dashboard/backend/domain/traces/repository.py b/dashboard/backend/domain/traces/repository.py
index 9e4c2665..778aaad4 100644
--- a/dashboard/backend/domain/traces/repository.py
+++ b/dashboard/backend/domain/traces/repository.py
@@ -144,6 +144,7 @@ def list_traces(
self,
*,
agent_id: Optional[str] = None,
+ agent_ids: Optional[Sequence[str]] = None,
run_id: Optional[str] = None,
status: Optional[str] = None,
limit: int = 50,
@@ -157,6 +158,13 @@ def list_traces(
if value is not None:
clauses.append(f"{column} = ?")
params.append(value)
+ if agent_ids is not None:
+ ids = [str(value) for value in agent_ids]
+ if not ids:
+ return {"items": [], "has_more": False, "next_cursor": None}
+ placeholders = ", ".join("?" for _ in ids)
+ clauses.append(f"agent_id IN ({placeholders})")
+ params.extend(ids)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with self._get_connection() as conn:
rows = conn.execute(
diff --git a/dashboard/backend/domain/traces/repository_postgres.py b/dashboard/backend/domain/traces/repository_postgres.py
index a6ac21f1..1675a336 100644
--- a/dashboard/backend/domain/traces/repository_postgres.py
+++ b/dashboard/backend/domain/traces/repository_postgres.py
@@ -136,6 +136,7 @@ def list_traces(
self,
*,
agent_id: Optional[str] = None,
+ agent_ids: Optional[Sequence[str]] = None,
run_id: Optional[str] = None,
status: Optional[str] = None,
limit: int = 50,
@@ -149,6 +150,12 @@ def list_traces(
if value is not None:
clauses.append(f"{column} = %s")
params.append(value)
+ if agent_ids is not None:
+ ids = [str(value) for value in agent_ids]
+ if not ids:
+ return {"items": [], "has_more": False, "next_cursor": None}
+ clauses.append("agent_id = ANY(%s)")
+ params.append(ids)
where = f"WHERE {' AND '.join(clauses)}" if clauses else ""
with self._get_connection() as conn:
with conn.cursor() as cur:
diff --git a/dashboard/backend/tests/test_admin_agent_activity_api.py b/dashboard/backend/tests/test_admin_agent_activity_api.py
new file mode 100644
index 00000000..d671e9cd
--- /dev/null
+++ b/dashboard/backend/tests/test_admin_agent_activity_api.py
@@ -0,0 +1,70 @@
+from fastapi.testclient import TestClient
+
+from dashboard.backend.api.auth import require_admin
+from dashboard.backend.app import app
+
+
+def test_admin_user_agent_activity_joins_agents_runs_and_traces(monkeypatch):
+ import dashboard.backend.api.routers.admin_users as module
+ import dashboard.backend.domain.agents.repository as agents
+ import dashboard.backend.domain.runs.repository as runs
+ import dashboard.backend.domain.traces.repository as traces
+
+ class Users:
+ def get_user_admin(self, user_id):
+ return {"id": user_id} if user_id == 7 else None
+
+ class Agents:
+ def list_agents(self, *, owner_user_id):
+ assert owner_user_id == 7
+ return [{"agent_id": "agent-1", "name": "Momentum", "agent_type": "builtin", "created_at": "t1"}]
+
+ class Runs:
+ def list_runs(self, agent_id):
+ assert agent_id == "agent-1"
+ return [{"run_id": "run-1", "status": "running", "created_at": "t2"}]
+
+ class Traces:
+ def list_traces(self, *, agent_ids, limit, offset):
+ assert agent_ids == ["agent-1"]
+ assert limit == 50
+ assert offset == 0
+ return {
+ "items": [{
+ "agent_id": "agent-1",
+ "run_id": "run-1",
+ "trace_id": "trace-1",
+ "status": "running",
+ "created_at": "t3",
+ }],
+ "has_more": False,
+ "next_cursor": None,
+ }
+
+ monkeypatch.setattr(module.users_module, "user_store", Users())
+ monkeypatch.setattr(agents, "agent_store", Agents())
+ monkeypatch.setattr(runs, "run_store", Runs())
+ monkeypatch.setattr(traces, "trace_store", Traces())
+ app.dependency_overrides[require_admin] = lambda: {"id": 1, "is_admin": True}
+ try:
+ response = TestClient(app).get("/api/admin/users/7/agent-activity")
+ assert response.status_code == 200
+ assert response.json()["agents"][0]["runs"][0]["trace_id"] == "trace-1"
+ finally:
+ app.dependency_overrides.pop(require_admin, None)
+
+
+def test_admin_user_agent_activity_returns_404_for_unknown_user(monkeypatch):
+ import dashboard.backend.api.routers.admin_users as module
+
+ class Users:
+ def get_user_admin(self, _user_id):
+ return None
+
+ monkeypatch.setattr(module.users_module, "user_store", Users())
+ app.dependency_overrides[require_admin] = lambda: {"id": 1, "is_admin": True}
+ try:
+ response = TestClient(app).get("/api/admin/users/999/agent-activity")
+ assert response.status_code == 404
+ finally:
+ app.dependency_overrides.pop(require_admin, None)
diff --git a/dashboard/backend/tests/test_admin_page_shell.py b/dashboard/backend/tests/test_admin_page_shell.py
index 21cef449..bd144084 100644
--- a/dashboard/backend/tests/test_admin_page_shell.py
+++ b/dashboard/backend/tests/test_admin_page_shell.py
@@ -235,7 +235,13 @@ def test_pruned_css_carries_none_of_the_dead_mock_passes():
assert selector in ADMIN_CSS, selector
-RAIL_ICONS = ("icon-chart", "icon-users", "icon-network", "icon-activity")
+RAIL_ICONS = (
+ "icon-chart",
+ "icon-users",
+ "icon-network",
+ "icon-activity",
+ "icon-file-text",
+)
HEADER_ICONS = ("icon-github", "icon-discord", "icon-chevron-right")
SPRITE_ICONS = (
RAIL_ICONS
diff --git a/dashboard/backend/tests/test_admin_users.py b/dashboard/backend/tests/test_admin_users.py
index a5283ac7..80f5905c 100644
--- a/dashboard/backend/tests/test_admin_users.py
+++ b/dashboard/backend/tests/test_admin_users.py
@@ -195,6 +195,9 @@ def test_every_admin_route_refuses_a_non_admin(isolated_auth):
("GET", "/api/admin/users/{user_id}"): lambda: client.get(
f"/api/admin/users/{user['id']}"
),
+ ("GET", "/api/admin/users/{user_id}/agent-activity"): lambda: client.get(
+ f"/api/admin/users/{user['id']}/agent-activity"
+ ),
("PATCH", "/api/admin/users/{user_id}"): lambda: client.patch(
f"/api/admin/users/{user['id']}", json={"role": "admin"}
),
diff --git a/dashboard/backend/tests/test_admin_users_frontend.py b/dashboard/backend/tests/test_admin_users_frontend.py
index 0fb77290..4ca5fdc0 100644
--- a/dashboard/backend/tests/test_admin_users_frontend.py
+++ b/dashboard/backend/tests/test_admin_users_frontend.py
@@ -128,7 +128,7 @@ def test_profile_header_carries_badges_activation_week_and_milestones():
"Account signed upJul 1, 2026, 09:00 UTC", "Credential verifiedJul 1, 2026, 10:00 UTC",
"Agent createdJul 1, 2026, 11:00 UTC", "Backtest completedJul 2, 2026, 10:00 UTC",
]
- assert result["tabs"] == ["Overview", "Timeline", "Runs", "Usage", "Sessions"]
+ assert result["tabs"] == ["Overview", "Agents & traces", "Timeline", "Runs", "Usage", "Sessions"]
# D5: account management lives on this page; the hand-off rides in the
# hash query the shell parses and AdminCredits applies to its search form.
assert "#account?user=ada.synthetic%40example.test" in result["account"]
diff --git a/dashboard/backend/tests/test_app_composition.py b/dashboard/backend/tests/test_app_composition.py
index b3975d84..4a75e345 100644
--- a/dashboard/backend/tests/test_app_composition.py
+++ b/dashboard/backend/tests/test_app_composition.py
@@ -141,6 +141,7 @@
("GET", "/api/admin/stats"),
("GET", "/api/admin/users"),
("GET", "/api/admin/users/{user_id}"),
+ ("GET", "/api/admin/users/{user_id}/agent-activity"),
("PATCH", "/api/admin/users/{user_id}"),
("GET", "/api/admin/analytics/overview"),
("GET", "/api/admin/analytics/lifecycle"),
diff --git a/dashboard/frontend/admin.html b/dashboard/frontend/admin.html
index c3c39db4..66fa89b6 100644
--- a/dashboard/frontend/admin.html
+++ b/dashboard/frontend/admin.html
@@ -36,6 +36,7 @@