From 9a364abb74c3a64200e48b0d16b47afe8ee7472d Mon Sep 17 00:00:00 2001 From: Renzo F <45401804+rennf93@users.noreply.github.com> Date: Wed, 15 Jul 2026 04:33:55 +0200 Subject: [PATCH] [W9-3a] Enrich agent detail page with sparkline + activity timeline (#529) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: optional agent_slug filter on GET /usage/time-series + UsageService.get_time_series (AgentSpawnSessionTable.agent_slug column already exists — no migration). Frontend: AgentActivityPanel on the agent detail page — a 7d per-agent token sparkline (recharts AreaChart) + a merged work-session/journal activity timeline. Work-sessions filter by the agent UUID (WorkSessionTable.agent_id is a UUID FK to agents.id), journals by slug. List grid left as-is (avoids 25-agent fan-out). Card last_active deferred (no live hook populates AgentMetrics). Co-authored-by: Renn F --- .../agents/[agentId]/__tests__/page.test.tsx | 1 + .../app/(dashboard)/agents/[agentId]/page.tsx | 5 + .../__tests__/agent-activity-panel.test.tsx | 80 +++++++ .../agents/agent-activity-panel.tsx | 200 ++++++++++++++++++ panel/src/components/agents/index.ts | 1 + panel/src/hooks/use-usage.ts | 13 +- panel/src/lib/api/usage.ts | 5 +- roboco/api/routes/usage.py | 10 +- roboco/services/usage.py | 14 +- tests/unit/services/test_usage.py | 34 +++ 10 files changed, 352 insertions(+), 11 deletions(-) create mode 100644 panel/src/components/agents/__tests__/agent-activity-panel.test.tsx create mode 100644 panel/src/components/agents/agent-activity-panel.tsx diff --git a/panel/src/app/(dashboard)/agents/[agentId]/__tests__/page.test.tsx b/panel/src/app/(dashboard)/agents/[agentId]/__tests__/page.test.tsx index ed868d7e..f25643c2 100644 --- a/panel/src/app/(dashboard)/agents/[agentId]/__tests__/page.test.tsx +++ b/panel/src/app/(dashboard)/agents/[agentId]/__tests__/page.test.tsx @@ -32,6 +32,7 @@ vi.mock("@/components/agents", () => ({ AgentStatusCards: () => null, ResolveWaitDialog: () => null, AgentStreamViewer: () => null, + AgentActivityPanel: () => null, SpawnAgentDialog: ({ agentId, agentName, diff --git a/panel/src/app/(dashboard)/agents/[agentId]/page.tsx b/panel/src/app/(dashboard)/agents/[agentId]/page.tsx index 948b9670..84758fcf 100644 --- a/panel/src/app/(dashboard)/agents/[agentId]/page.tsx +++ b/panel/src/app/(dashboard)/agents/[agentId]/page.tsx @@ -34,6 +34,7 @@ import { ResolveWaitDialog, AgentStreamViewer, SpawnAgentDialog, + AgentActivityPanel, } from "@/components/agents"; // Role display labels @@ -196,6 +197,10 @@ export default function AgentDetailPage() { + {/* Per-agent activity: token sparkline + work-session/journal timeline. + History exists independent of live status, so render for any slug. */} + + {isLoading ? (
{Array.from({ length: 4 }).map((_, i) => ( diff --git a/panel/src/components/agents/__tests__/agent-activity-panel.test.tsx b/panel/src/components/agents/__tests__/agent-activity-panel.test.tsx new file mode 100644 index 00000000..169a5ba7 --- /dev/null +++ b/panel/src/components/agents/__tests__/agent-activity-panel.test.tsx @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; + +const { mockSeries, mockSessions, mockJournals } = vi.hoisted(() => ({ + mockSeries: vi.fn(), + mockSessions: vi.fn(), + mockJournals: vi.fn(), +})); + +vi.mock("@/hooks/use-usage", () => ({ + useUsageTimeSeries: mockSeries, +})); +vi.mock("@/hooks/use-work-sessions", () => ({ + useWorkSessions: mockSessions, +})); +vi.mock("@/hooks/use-journals", () => ({ + useAgentJournalEntries: mockJournals, +})); + +import { AgentActivityPanel } from "../agent-activity-panel"; + +describe("AgentActivityPanel", () => { + beforeEach(() => { + mockSeries.mockReturnValue({ data: [], isLoading: false }); + mockSessions.mockReturnValue({ data: [], isLoading: false }); + mockJournals.mockReturnValue({ data: [], isLoading: false }); + }); + + it("renders both card titles", () => { + render( + , + ); + expect(screen.getByText("Token Activity")).toBeInTheDocument(); + expect(screen.getByText("Recent Activity")).toBeInTheDocument(); + }); + + it("shows empty states when there is no history", () => { + render( + , + ); + expect( + screen.getByText("No token usage in the last 7 days"), + ).toBeInTheDocument(); + expect(screen.getByText("No recent activity")).toBeInTheDocument(); + }); + + it("does not show the empty state while loading", () => { + mockSeries.mockReturnValue({ data: [], isLoading: true }); + mockSessions.mockReturnValue({ data: [], isLoading: true }); + render( + , + ); + expect( + screen.queryByText("No token usage in the last 7 days"), + ).not.toBeInTheDocument(); + expect( + screen.queryByText("No recent activity"), + ).not.toBeInTheDocument(); + }); + + it("renders a work-session timeline entry", () => { + mockSessions.mockReturnValue({ + data: [ + { + id: "s1", + task_id: "abcdef12-3456-7890", + branch_name: "feature/backend/ABC12345", + status: "completed", + started_at: "2026-07-14T10:00:00Z", + has_pr: true, + }, + ], + isLoading: false, + }); + render( + , + ); + expect(screen.getByText("Task abcdef12")).toBeInTheDocument(); + }); +}); \ No newline at end of file diff --git a/panel/src/components/agents/agent-activity-panel.tsx b/panel/src/components/agents/agent-activity-panel.tsx new file mode 100644 index 00000000..3d993cdd --- /dev/null +++ b/panel/src/components/agents/agent-activity-panel.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useMemo } from "react"; +import { formatDistanceToNow } from "date-fns"; +import { + Area, + AreaChart, + ResponsiveContainer, + Tooltip as RTooltip, +} from "recharts"; +import { + Card, + CardContent, + CardHeader, + CardTitle, + CardDescription, +} from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { GitBranch, BookOpen } from "lucide-react"; +import { useUsageTimeSeries } from "@/hooks/use-usage"; +import { useWorkSessions } from "@/hooks/use-work-sessions"; +import { useAgentJournalEntries } from "@/hooks/use-journals"; +import { WorkSessionStatus, type UsageTimePoint } from "@/types"; + +interface AgentActivityPanelProps { + agentSlug: string; + // WorkSessionTable.agent_id is a UUID FK to agents.id, so work-sessions are + // filtered by the agent UUID — NOT the slug. Journals take the slug. + agentUuid?: string; +} + +const SESSION_STATUS_LABEL: Record = { + [WorkSessionStatus.ACTIVE]: "In progress", + [WorkSessionStatus.COMPLETED]: "Completed", + [WorkSessionStatus.ABANDONED]: "Abandoned", +}; + +function fmtK(n: number): string { + if (n >= 1_000) return (n / 1_000).toFixed(0) + "k"; + return String(n); +} + +type TimelineItem = { + kind: "session" | "journal"; + title: string; + subtitle: string; + timestamp: string; +}; + +function mergeTimeline( + sessions: { task_id: string; branch_name: string; status: WorkSessionStatus; started_at: string }[], + journals: { title: string; type: string; timestamp: string; task_id: string | null }[], +): TimelineItem[] { + const items: TimelineItem[] = [ + ...sessions.map((s) => ({ + kind: "session" as const, + title: `Task ${s.task_id.slice(0, 8)}`, + subtitle: `${s.branch_name} · ${SESSION_STATUS_LABEL[s.status] ?? s.status}`, + timestamp: s.started_at, + })), + ...journals.map((j) => ({ + kind: "journal" as const, + title: j.title, + subtitle: j.task_id ? `Task ${j.task_id.slice(0, 8)} · ${j.type}` : j.type, + timestamp: j.timestamp, + })), + ]; + return items + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, 8); +} + +export function AgentActivityPanel({ + agentSlug, + agentUuid, +}: AgentActivityPanelProps) { + const { data: series, isLoading: seriesLoading } = useUsageTimeSeries( + "7d", + agentSlug, + ); + const { data: sessions, isLoading: sessionsLoading } = useWorkSessions( + agentUuid ? { agent_id: agentUuid } : undefined, + ); + const { data: journals, isLoading: journalsLoading } = useAgentJournalEntries( + agentSlug, + { limit: 5 }, + ); + + const timeline = useMemo( + () => mergeTimeline(sessions ?? [], journals ?? []), + [sessions, journals], + ); + const timelineLoading = sessionsLoading || journalsLoading; + const hasTokens = (series ?? []).some((p: UsageTimePoint) => p.total_tokens > 0); + + return ( +
+ {/* Token activity sparkline */} + + + Token Activity + Last 7 days + + + {seriesLoading ? ( + + ) : hasTokens ? ( + + + + + + + + + [ + fmtK(typeof value === "number" ? value : 0), + "Tokens", + ]} + contentStyle={{ fontSize: 12 }} + labelFormatter={() => ""} + /> + + + + ) : ( +

+ No token usage in the last 7 days +

+ )} +
+
+ + {/* Activity timeline */} + + + Recent Activity + Work sessions & journal entries + + + {timelineLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : timeline.length === 0 ? ( +

+ No recent activity +

+ ) : ( +
    + {timeline.map((item, i) => ( +
  1. +
    + {item.kind === "session" ? ( + + ) : ( + + )} +
    +
    +

    {item.title}

    +

    + {item.subtitle} +

    +
    + + {formatDistanceToNow(new Date(item.timestamp), { + addSuffix: true, + })} + +
  2. + ))} +
+ )} +
+
+
+ ); +} \ No newline at end of file diff --git a/panel/src/components/agents/index.ts b/panel/src/components/agents/index.ts index 82e12131..d8e2ecbe 100644 --- a/panel/src/components/agents/index.ts +++ b/panel/src/components/agents/index.ts @@ -5,5 +5,6 @@ export { AgentStatusCards } from "./agent-status-cards"; export { SpawnAgentDialog } from "./spawn-agent-dialog"; export { ResolveWaitDialog } from "./resolve-wait-dialog"; export { AgentStreamViewer } from "./stream-viewer"; +export { AgentActivityPanel } from "./agent-activity-panel"; export { OrchestratorStatusCards } from "./orchestrator-status"; export { WaitingAgentsAlert } from "./waiting-agents-alert"; diff --git a/panel/src/hooks/use-usage.ts b/panel/src/hooks/use-usage.ts index 021952df..f5de6b63 100644 --- a/panel/src/hooks/use-usage.ts +++ b/panel/src/hooks/use-usage.ts @@ -24,8 +24,8 @@ export const usageKeys = { all: ["usage"] as const, summary: (period: UsagePeriod) => [...usageKeys.all, "summary", period] as const, - timeSeries: (period: UsagePeriod) => - [...usageKeys.all, "time-series", period] as const, + timeSeries: (period: UsagePeriod, agentSlug?: string) => + [...usageKeys.all, "time-series", period, agentSlug ?? null] as const, agentUsage: (period: UsagePeriod) => [...usageKeys.all, "by-agent", period] as const, teamUsage: (period: UsagePeriod) => @@ -56,10 +56,13 @@ export function useUsageSummary(period: UsagePeriod = "24h") { } /** Bucketed time-series data for the stacked area chart */ -export function useUsageTimeSeries(period: UsagePeriod = "24h") { +export function useUsageTimeSeries( + period: UsagePeriod = "24h", + agentSlug?: string, +) { return useQuery({ - queryKey: usageKeys.timeSeries(period), - queryFn: () => usageApi.getUsageTimeSeries(period), + queryKey: usageKeys.timeSeries(period, agentSlug), + queryFn: () => usageApi.getUsageTimeSeries(period, agentSlug), refetchInterval: 120_000, }); } diff --git a/panel/src/lib/api/usage.ts b/panel/src/lib/api/usage.ts index 9092e7af..a158e80a 100644 --- a/panel/src/lib/api/usage.ts +++ b/panel/src/lib/api/usage.ts @@ -275,13 +275,14 @@ export const usageApi = { return data; }, - /** Bucketed time-series — GET /usage/time-series?period= */ + /** Bucketed time-series — GET /usage/time-series?period=&agent_slug= */ getUsageTimeSeries: async ( period: UsagePeriod = "24h", + agentSlug?: string, ): Promise => { if (isMockMode()) return mockTimeSeries(period); const { data } = await api.get("/usage/time-series", { - params: { period }, + params: agentSlug ? { period, agent_slug: agentSlug } : { period }, }); return data; }, diff --git a/roboco/api/routes/usage.py b/roboco/api/routes/usage.py index 5ecb8fec..e42a1099 100644 --- a/roboco/api/routes/usage.py +++ b/roboco/api/routes/usage.py @@ -54,6 +54,10 @@ async def get_usage_summary( async def get_usage_time_series( db: DbSession, period: _PeriodQuery = "24h", + agent_slug: Annotated[ + str | None, + Query(description="Restrict to one agent's spawn sessions"), + ] = None, ) -> list[dict[str, Any]]: """Return bucketed time-series data points. @@ -61,10 +65,12 @@ async def get_usage_time_series( - 7d / 30d / 90d → daily buckets Each point has: bucket (ISO timestamp), tokens_input, tokens_output, - total_tokens, cost_usd. + total_tokens, cost_usd. When ``agent_slug`` is set the series is scoped to + that agent's spawn sessions (the per-agent sparkline on the agent detail + page); otherwise the whole fleet is summed. """ svc = get_usage_service(db) - return await svc.get_time_series(period) + return await svc.get_time_series(period, agent_slug=agent_slug) # ============================================================================= diff --git a/roboco/services/usage.py b/roboco/services/usage.py index 45fca9a0..854dd1f8 100644 --- a/roboco/services/usage.py +++ b/roboco/services/usage.py @@ -174,7 +174,9 @@ class UsageService(BaseService): # TIME SERIES # ========================================================================= - async def get_time_series(self, period: str = "24h") -> list[dict[str, Any]]: + async def get_time_series( + self, period: str = "24h", agent_slug: str | None = None + ) -> list[dict[str, Any]]: """Return bucketed time-series data points. - 24h → hourly buckets @@ -186,6 +188,10 @@ class UsageService(BaseService): total_tokens includes all 4 token types (input + output + cache_read + cache_write) so it is consistent with get_summary()'s total_tokens field — the two sums must match for the same period. + + When ``agent_slug`` is set the query is scoped to that one agent's + spawn sessions (the per-agent sparkline); otherwise the whole fleet + is summed. """ start_dt, _hours = _parse_period(period) @@ -196,7 +202,7 @@ class UsageService(BaseService): # Daily buckets trunc_fn = func.date_trunc("day", AgentSpawnSessionTable.started_at) - result = await self.session.execute( + stmt = ( select( trunc_fn.label("bucket"), func.coalesce(func.sum(AgentSpawnSessionTable.tokens_input), 0).label( @@ -222,6 +228,10 @@ class UsageService(BaseService): .group_by(trunc_fn) .order_by(trunc_fn) ) + if agent_slug is not None: + stmt = stmt.where(AgentSpawnSessionTable.agent_slug == agent_slug) + + result = await self.session.execute(stmt) rows = result.fetchall() points = [] diff --git a/tests/unit/services/test_usage.py b/tests/unit/services/test_usage.py index 436ae1d5..75a8cf95 100644 --- a/tests/unit/services/test_usage.py +++ b/tests/unit/services/test_usage.py @@ -336,6 +336,40 @@ class TestGetTimeSeries: ): assert field in point, f"Missing field: {field}" + @pytest.mark.asyncio + async def test_agent_slug_filter_scopes_query(self) -> None: + """agent_slug adds an agent_slug WHERE clause to the time-series query.""" + row = _make_row( + bucket=datetime.datetime(2026, 6, 9, 12, 0, 0, tzinfo=datetime.UTC), + tokens_input=100, + tokens_output=100, + tokens_cache_read=_ZERO, + tokens_cache_write=_ZERO, + cost_usd=0.01, + ) + svc = _service_with_execute(_result_fetchall([row])) + await svc.get_time_series("7d", agent_slug="be-dev-1") + stmt = svc.session.execute.call_args_list[0][0][0] + sql = str(stmt.compile(compile_kwargs={"literal_binds": True})) + assert "be-dev-1" in sql + + @pytest.mark.asyncio + async def test_no_agent_slug_filter_when_none(self) -> None: + """Without agent_slug the query is not scoped to one agent.""" + row = _make_row( + bucket=datetime.datetime(2026, 6, 9, 12, 0, 0, tzinfo=datetime.UTC), + tokens_input=100, + tokens_output=100, + tokens_cache_read=_ZERO, + tokens_cache_write=_ZERO, + cost_usd=0.01, + ) + svc = _service_with_execute(_result_fetchall([row])) + await svc.get_time_series("7d") + stmt = svc.session.execute.call_args_list[0][0][0] + sql = str(stmt.compile(compile_kwargs={"literal_binds": True})) + assert "agent_slug" not in sql + # --------------------------------------------------------------------------- # get_by_agent — pct_of_total sums to 100%