mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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%
|
||||
|
||||
Reference in New Issue
Block a user