[W9-3a] Enrich agent detail page with sparkline + activity timeline (#529)

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 <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-07-15 04:33:55 +02:00
committed by GitHub
co-authored by Renn F
parent d9084eeb07
commit 9a364abb74
10 changed files with 352 additions and 11 deletions
@@ -32,6 +32,7 @@ vi.mock("@/components/agents", () => ({
AgentStatusCards: () => null,
ResolveWaitDialog: () => null,
AgentStreamViewer: () => null,
AgentActivityPanel: () => null,
SpawnAgentDialog: ({
agentId,
agentName,
@@ -34,6 +34,7 @@ import {
ResolveWaitDialog,
AgentStreamViewer,
SpawnAgentDialog,
AgentActivityPanel,
} from "@/components/agents";
// Role display labels
@@ -196,6 +197,10 @@ export default function AgentDetailPage() {
</div>
</div>
{/* Per-agent activity: token sparkline + work-session/journal timeline.
History exists independent of live status, so render for any slug. */}
<AgentActivityPanel agentSlug={agentId} agentUuid={definition?.uuid} />
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
@@ -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(
<AgentActivityPanel agentSlug="be-dev-1" agentUuid="uuid-1" />,
);
expect(screen.getByText("Token Activity")).toBeInTheDocument();
expect(screen.getByText("Recent Activity")).toBeInTheDocument();
});
it("shows empty states when there is no history", () => {
render(
<AgentActivityPanel agentSlug="be-dev-1" agentUuid="uuid-1" />,
);
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(
<AgentActivityPanel agentSlug="be-dev-1" agentUuid="uuid-1" />,
);
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(
<AgentActivityPanel agentSlug="be-dev-1" agentUuid="uuid-1" />,
);
expect(screen.getByText("Task abcdef12")).toBeInTheDocument();
});
});
@@ -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, string> = {
[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 (
<div className="grid gap-4 md:grid-cols-2">
{/* Token activity sparkline */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Token Activity</CardTitle>
<CardDescription>Last 7 days</CardDescription>
</CardHeader>
<CardContent>
{seriesLoading ? (
<Skeleton className="h-20 w-full" />
) : hasTokens ? (
<ResponsiveContainer width="100%" height={80}>
<AreaChart
data={series}
margin={{ top: 4, right: 0, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="agentSpark" x1="0" y1="0" x2="0" y2="1">
<stop
offset="5%"
stopColor="var(--chart-1)"
stopOpacity={0.3}
/>
<stop
offset="95%"
stopColor="var(--chart-1)"
stopOpacity={0}
/>
</linearGradient>
</defs>
<RTooltip
formatter={(value) => [
fmtK(typeof value === "number" ? value : 0),
"Tokens",
]}
contentStyle={{ fontSize: 12 }}
labelFormatter={() => ""}
/>
<Area
type="monotone"
dataKey="total_tokens"
stroke="var(--chart-1)"
strokeWidth={2}
fill="url(#agentSpark)"
isAnimationActive={false}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<p className="text-muted-foreground text-sm py-6 text-center">
No token usage in the last 7 days
</p>
)}
</CardContent>
</Card>
{/* Activity timeline */}
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Recent Activity</CardTitle>
<CardDescription>Work sessions &amp; journal entries</CardDescription>
</CardHeader>
<CardContent>
{timelineLoading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : timeline.length === 0 ? (
<p className="text-muted-foreground text-sm py-6 text-center">
No recent activity
</p>
) : (
<ol className="space-y-3">
{timeline.map((item, i) => (
<li key={`${item.kind}-${i}`} className="flex gap-3">
<div className="mt-0.5 shrink-0">
{item.kind === "session" ? (
<GitBranch className="h-4 w-4 text-muted-foreground" />
) : (
<BookOpen className="h-4 w-4 text-muted-foreground" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium truncate">{item.title}</p>
<p className="text-muted-foreground text-xs truncate">
{item.subtitle}
</p>
</div>
<span className="text-muted-foreground text-xs whitespace-nowrap">
{formatDistanceToNow(new Date(item.timestamp), {
addSuffix: true,
})}
</span>
</li>
))}
</ol>
)}
</CardContent>
</Card>
</div>
);
}
+1
View File
@@ -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";
+8 -5
View File
@@ -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<UsageTimePoint[]>({
queryKey: usageKeys.timeSeries(period),
queryFn: () => usageApi.getUsageTimeSeries(period),
queryKey: usageKeys.timeSeries(period, agentSlug),
queryFn: () => usageApi.getUsageTimeSeries(period, agentSlug),
refetchInterval: 120_000,
});
}
+3 -2
View File
@@ -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<UsageTimePoint[]> => {
if (isMockMode()) return mockTimeSeries(period);
const { data } = await api.get<UsageTimePoint[]>("/usage/time-series", {
params: { period },
params: agentSlug ? { period, agent_slug: agentSlug } : { period },
});
return data;
},
+8 -2
View File
@@ -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)
# =============================================================================
+12 -2
View File
@@ -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 = []
+34
View File
@@ -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%