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 @@ + @@ -162,7 +163,7 @@

Agentic Trading Lab

Account Management Providers Activity - Agent traces + Agent traces