Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions dashboard/backend/api/routers/admin_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions dashboard/backend/domain/traces/repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions dashboard/backend/domain/traces/repository_postgres.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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:
Expand Down
70 changes: 70 additions & 0 deletions dashboard/backend/tests/test_admin_agent_activity_api.py
Original file line number Diff line number Diff line change
@@ -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)
8 changes: 7 additions & 1 deletion dashboard/backend/tests/test_admin_page_shell.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 3 additions & 0 deletions dashboard/backend/tests/test_admin_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
),
Expand Down
2 changes: 1 addition & 1 deletion dashboard/backend/tests/test_admin_users_frontend.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
1 change: 1 addition & 0 deletions dashboard/backend/tests/test_app_composition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
3 changes: 2 additions & 1 deletion dashboard/frontend/admin.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
<symbol id="icon-chart" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3v18h18"/><path d="m19 9-5 5-4-4-3 3"/></symbol>
<symbol id="icon-users" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M22 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></symbol>
<symbol id="icon-network" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><rect x="16" y="16" width="6" height="6" rx="1"/><rect x="2" y="16" width="6" height="6" rx="1"/><rect x="9" y="2" width="6" height="6" rx="1"/><path d="M5 16v-3a1 1 0 0 1 1-1h12a1 1 0 0 1 1 1v3"/><path d="M12 12V8"/></symbol>
<symbol id="icon-file-text" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/><path d="M16 13H8"/><path d="M16 17H8"/><path d="M10 9H8"/></symbol>
<symbol id="icon-activity" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-2.48a2 2 0 0 0-1.93 1.46l-2.35 8.36a.25.25 0 0 1-.48 0L9.24 2.18a.25.25 0 0 0-.48 0l-2.35 8.36A2 2 0 0 1 4.49 12H2"/></symbol>
<symbol id="icon-refresh" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M20 11a8.1 8.1 0 0 0-15.5-2M4 4v5h5"/><path d="M4 13a8.1 8.1 0 0 0 15.5 2M20 20v-5h-5"/></symbol>
<symbol id="icon-x" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18"/><path d="m6 6 12 12"/></symbol>
Expand Down Expand Up @@ -162,7 +163,7 @@ <h1>Agentic Trading Lab</h1>
<a class="admin-tab" data-rail="account" href="#account"><svg aria-hidden="true"><use href="#icon-users"/></svg><span>Account Management</span></a>
<a class="admin-tab" data-rail="providers" href="#providers"><svg aria-hidden="true"><use href="#icon-network"/></svg><span>Providers</span></a>
<a class="admin-tab" data-rail="activity" href="#activity"><svg aria-hidden="true"><use href="#icon-activity"/></svg><span>Activity</span></a>
<a class="admin-tab" data-rail="traces" href="#traces"><svg aria-hidden="true"><use href="#icon-activity"/></svg><span>Agent traces</span></a>
<a class="admin-tab" data-rail="traces" href="#traces"><svg aria-hidden="true"><use href="#icon-file-text"/></svg><span>Agent traces</span></a>
</aside>
<main>
<div class="page-header">
Expand Down
17 changes: 9 additions & 8 deletions dashboard/frontend/js/admin-traces.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
const SURFACE = 'traces';
const state = {
traceId: null, trace: null, events: [], nextSequence: 0,
pollTimer: null, stale: false, listItems: [], listHasMore: false, listNextCursor: 0,
pollTimer: null, stale: false, context: {}, listItems: [], listHasMore: false, listNextCursor: 0,
};

function shell() {
Expand Down Expand Up @@ -133,12 +133,12 @@
return section;
}

function renderTraceDetail(trace, events) {
function renderTraceDetail(trace, events, context = {}) {
const section = node('section', 'trace-detail-panel');
const breadcrumb = node('nav', 'breadcrumb');
breadcrumb.setAttribute('aria-label', 'Breadcrumb');
const back = node('a', '', 'Agent traces');
back.setAttribute('href', '#traces');
const back = node('a', '', context.user_id ? 'Back to user analytics' : 'Agent traces');
back.setAttribute('href', context.user_id ? `#users/${encodeURIComponent(String(context.user_id))}` : '#traces');
breadcrumb.append(back, node('span', '', '/'), node('span', '', value(trace?.trace_id)));
section.appendChild(breadcrumb);
const head = node('div', 'page-head');
Expand Down Expand Up @@ -228,7 +228,7 @@
return shell().request(`/api/admin/traces/${encodeURIComponent(traceId)}`);
}

async function loadDetail(traceId) {
async function loadDetail(traceId, context = {}) {
stopPolling();
const seq = shell().nextSeq(SURFACE);
try {
Expand All @@ -238,14 +238,15 @@
if (terminal) trace = await refreshTraceEnvelope(traceId);
if (!shell().isCurrent(SURFACE, seq)) return;
state.traceId = traceId;
state.context = { ...context };
state.trace = trace;
state.events = events;
state.nextSequence = state.events.length ? state.events[state.events.length - 1].sequence_no : 0;
state.stale = false;
const host = element('tracesView');
if (!host) return;
shell().clear(host);
host.appendChild(renderTraceDetail(trace, state.events));
host.appendChild(renderTraceDetail(trace, state.events, context));
if (trace.status === 'running') startPolling();
} catch (error) {
if (await shell().handleAccessLost(error)) return;
Expand Down Expand Up @@ -275,7 +276,7 @@
const host = element('tracesView');
if (host && state.trace) {
shell().clear(host);
host.appendChild(renderTraceDetail(state.trace, state.events));
host.appendChild(renderTraceDetail(state.trace, state.events, state.context));
}
}
state.stale = false;
Expand All @@ -296,7 +297,7 @@

function onRoute(event) {
if (event.detail.route !== SURFACE) return;
if (event.detail.id) loadDetail(event.detail.id);
if (event.detail.id) loadDetail(event.detail.id, event.detail.query || {});
else loadList();
}

Expand Down
38 changes: 33 additions & 5 deletions dashboard/frontend/js/admin-users.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@

const USERS_PATH = '/api/admin/analytics/users';
const PAGE_SIZE = 50;
const SECTIONS = ['overview', 'timeline', 'runs', 'usage', 'sessions'];
const SECTION_LABELS = Object.freeze({ overview: 'Overview', timeline: 'Timeline', runs: 'Runs', usage: 'Usage', sessions: 'Sessions' });
const SECTIONS = ['overview', 'agents', 'timeline', 'runs', 'usage', 'sessions'];
const SECTION_LABELS = Object.freeze({ overview: 'Overview', agents: 'Agents & traces', timeline: 'Timeline', runs: 'Runs', usage: 'Usage', sessions: 'Sessions' });
// Harvested from the retired admin-analytics.js:35-50 (deleted in PR C; see git history).
const EVENT_LABELS = Object.freeze({
account_signed_up: 'Account signed up',
Expand Down Expand Up @@ -446,6 +446,31 @@
const s = shell();
const rows = Array.isArray(items) ? items : [];
if (!rows.length) return s.el('p', 'panel-empty', 'No activity in this section.');
if (section === 'agents') {
const root = s.el('div', 'agent-activity-list');
rows.forEach((agent) => {
const card = s.el('article', 'agent-activity-card');
card.appendChild(s.el('h3', '', agent.name || agent.agent_id || s.DASH));
card.appendChild(s.el('p', 'muted', `${agent.agent_type || 'external'} · ${agent.agent_id || s.DASH}`));
const runs = s.el('ul', 'agent-run-list');
(agent.runs || []).forEach((run) => {
const item = s.el('li');
item.appendChild(s.el('span', '', `${run.run_id || s.DASH} · ${s.humanize(run.status || 'unknown')}`));
if (run.trace_id) {
const link = s.el('a', 'module-link', run.trace_status === 'running' ? 'View live trace' : 'View trace');
link.setAttribute('href', `#traces/${encodeURIComponent(String(run.trace_id))}?user_id=${encodeURIComponent(String(state.profile.userId))}&agent_id=${encodeURIComponent(String(agent.agent_id))}&run_id=${encodeURIComponent(String(run.run_id))}`);
item.appendChild(link);
} else {
item.appendChild(s.el('span', 'muted', 'Trace unavailable'));
}
runs.appendChild(item);
});
if (!runs.children.length) runs.appendChild(s.el('li', 'panel-empty', 'No runs recorded.'));
card.appendChild(runs);
root.appendChild(card);
});
return root;
}
if (section === 'timeline') {
const list = s.el('ol', 'timeline');
rows.forEach((item) => {
Expand Down Expand Up @@ -527,12 +552,15 @@
sectionState.error = null;
const seq = ++sectionState.requestSeq;
renderSectionPanel(section);
const params = new URLSearchParams({ section, limit: String(PAGE_SIZE) });
const params = new URLSearchParams({ section, limit: String(PAGE_SIZE) });
if (append) params.set('cursor', sectionState.nextCursor);
try {
const payload = await s.request(`${USERS_PATH}/${encodeURIComponent(String(userId))}/activity?${params}`);
const path = section === 'agents'
? `/api/admin/users/${encodeURIComponent(String(userId))}/agent-activity`
: `${USERS_PATH}/${encodeURIComponent(String(userId))}/activity?${params}`;
const payload = await s.request(path);
if (String(state.profile.userId) !== String(userId) || seq !== sectionState.requestSeq) return;
const next = Array.isArray(payload.items) ? payload.items : [];
const next = section === 'agents' ? (Array.isArray(payload.agents) ? payload.agents : []) : (Array.isArray(payload.items) ? payload.items : []);
sectionState.items = append ? sectionState.items.concat(next) : next;
sectionState.nextCursor = payload.next_cursor || null;
sectionState.loaded = true;
Expand Down
Loading
Loading