[499f9eb1] Token Usage & Cost Analytics — Full-Stack Instrumentation, Persistence, and Visualization (#90)

* [cd2bf666] feat(usage): add token usage types, API client, hooks, and UI components (#87) (#88)

- Append 5 TypeScript interfaces to src/types/index.ts: TokenUsageSnapshot, AgentUsageRow, UsageSession, UsageTimePoint, ModelUsageSlice
- Create src/lib/api/usage.ts: Axios singleton + isMockMode guards for getUsageSnapshot, getUsageTimeSeries, getAgentUsage, getUsageSessions, getModelUsage
- Create src/hooks/use-usage.ts: usageKeys factory + useUsageSnapshot, useUsageTimeSeries, useAgentUsage, useUsageSessions, useModelUsage hooks
- Create UsageOverviewPanel (dashboard/usage-overview-panel.tsx): 6 metric rows with Skeleton loading state; week-over-week trend arrow for cost
- Update CommandCenter: Metrics+Alerts row expanded from 2-col to 3-col grid adding UsageOverviewPanel
- Create src/components/metrics/ folder: UsageTimeSeriesChart (recharts stacked AreaChart with var(--chart-1/2/3)), ModelUsageDonut (PieChart), AgentUsageChart and TeamUsageChart (BarChart), SessionsTable (sortable columns + 10-row Prev/Next pagination)
- Update Metrics page: Token Usage & Costs section with 5 rows (summary cards, time series+donut, agent+team bar charts, projection+cache efficiency, sessions table)
- Add usage mini-bar to AgentCard: token count + cost + progress bar; AgentGrid and Agents page pass agentUsageMap through
- Install recharts 3.8.1
- Export all new symbols through their barrel index.ts files

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [10372f0f] Implement full token usage instrumentation: DB migration, SDK endpoints, orchestrator hooks, analytics API, WebSocket events, dashboard integration (#86) (#89)

* [10372f0f] feat(token-usage): add Alembic migration 026 for token usage tables

Create agent_spawn_sessions, token_usage_snapshots, and daily_usage_rollups
tables with correct BIGINT columns, indexes, and unique constraint.
Chain: 025_agentrole_prompter → 026_token_usage_tables.

* [10372f0f] feat(token-usage): add ORM table classes for token usage instrumentation

Add AgentSpawnSessionTable, TokenUsageSnapshotTable, DailyUsageRollupTable
to db/tables.py. Import BigInteger and Date from SQLAlchemy. All columns
match the migration schema with BIGINT token counts and proper indexes.

* [10372f0f] feat(billing): add pricing module with calculate_cost() function

Create roboco/billing/__init__.py and roboco/billing/pricing.py with
calculate_cost() supporting Claude opus/sonnet/haiku models with
input/output/cache pricing. Unknown models return 0.0 without raising.

* [10372f0f] feat(sdk): add POST /usage/report and GET /usage/status endpoints to agent SDK

Extend _SessionState with token counters. Add TokenReportRequest and
TokenUsageStatus models. POST /usage/report additively accumulates token
counts; GET /usage/status returns current session totals for sweeper polling.

* [10372f0f] feat(orchestrator): add token usage instrumentation hooks

- _launch_spawn() calls _record_spawn_session() after successful container spawn
- stop_agent() calls _finalize_spawn_session() before container removal
- _run_sweep() calls _sweep_token_snapshots() and _sweep_daily_rollup() each tick
- New methods: _record_spawn_session, _finalize_spawn_session,
  _sweep_token_snapshots, _sweep_daily_rollup in TOKEN USAGE section

* [10372f0f] feat(api): add token usage analytics API with 7 endpoints

Create roboco/services/usage.py (UsageService) and roboco/api/routes/usage.py.
Endpoints: GET /api/usage/summary, /time-series, /by-agent, /by-team,
/by-model, /projection, /cache-efficiency. Register in app.py.

* [10372f0f] feat(dashboard): add usage_summary field to CEO dashboard

Add UsageSummary schema (tokens_today, cost_today_usd) to dashboard schemas.
Add usage_summary: UsageSummary | None to CEOOverview. Update
get_ceo_overview() to populate usage_summary from daily_usage_rollups.

* [10372f0f] fix(billing/tests): remove dead except block in _sweep_daily_rollup, add unit tests for pricing.py and services/usage.py

- Remove unreachable `except Exception as e` block in orchestrator.py
  _sweep_daily_rollup() (lines 3376-3381) which referenced undefined
  `agent_id` and was copy-pasted from _sweep_token_snapshots by mistake
- Add tests/unit/billing/test_pricing.py: 31 tests covering opus/sonnet/
  haiku tiers with all 4 token types, unknown model → 0.0, empty string
  → 0.0, and substring-match priority (longer fragment wins)
- Add tests/unit/services/test_usage.py: 25 tests covering get_summary
  trend_pct edge cases (prev=0, both=0, prev>0), get_by_agent/team/model
  pct_of_total summing to 100%, get_projection formula (avg_daily×30),
  and get_cache_efficiency hit-rate and cost_saved arithmetic
- pricing.py: 100% coverage; services/usage.py: 83% coverage (>80% target)

* [10372f0f] fix(usage): include cache tokens in time-series total_tokens to fix AC9 consistency violation

get_time_series() previously computed total_tokens as tokens_input +
tokens_output only. get_summary() includes all 4 token types (input +
output + cache_read + cache_write). AC9 requires both endpoints to agree
on their totals for the same period.

Fix: add tokens_cache_read and tokens_cache_write to the SELECT query in
get_time_series() and include them in the total_tokens calculation.

Also adds 4 new unit tests in TestGetTimeSeries covering:
- total_tokens includes cache_read and cache_write (the AC9 guard)
- zero cache tokens still produces correct total
- empty result returns empty list
- required fields are present in each point

* [10372f0f] fix(usage): remove unused imports and include cache tokens in breakdown totals (AC10)

- Remove import math (F401 — never used)
- Remove text from sqlalchemy import (F401 — never used)
- Remove unused local calculate_cost import inside get_cache_efficiency (F401)
- Add tokens_cache_read and tokens_cache_write to SELECT in get_by_agent,
  get_by_team, and get_by_model; update grand_total and per-item total to
  include all 4 token types so totals match get_summary() (AC10 fix)
- Update test mock rows to include explicit tokens_cache_read=0 and
  tokens_cache_write=0 so they work with the fixed code
- Add new test cases: test_cache_tokens_included_in_total_tokens and
  test_pct_of_total_sums_to_100_with_cache_tokens for each breakdown class

---------

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [44b9eb1f] feat(usage): align frontend API client, TS types, and chart components to real backend contract (#92) (#94)

Update all usage-related frontend code to match the actual FastAPI backend
response shapes and endpoint paths:

- panel/src/lib/api/usage.ts: rewrite all 7 API functions to use correct
  endpoint paths (/usage/summary, /usage/by-agent, /usage/by-model,
  /usage/by-team, /usage/time-series, /usage/projection,
  /usage/cache-efficiency); send period query param (24h/7d/30d not hours);
  mock generators produce data matching real backend shapes exactly;
  getUsageSessions returns [] in prod (no /usage/sessions endpoint exists)

- panel/src/types/index.ts: replace TokenUsageSnapshot with UsageSummary
  (tokens_input/tokens_output/total_cost_usd/trend_pct); update AgentUsageRow
  to use agent_slug/total_tokens/cost_usd/pct_of_total; add TeamUsageRow,
  UsageProjection, CacheEfficiencyResponse; update UsageTimePoint to use
  bucket field; update UsageSession to use agent_slug

- panel/src/hooks/use-usage.ts: rewrite all hooks to match new API and types;
  add useTeamUsage, useUsageProjection, useCacheEfficiency hooks

- panel/src/components/metrics/usage-time-series-chart.tsx: use bucket field
  (not timestamp) for axis labels
- panel/src/components/metrics/agent-usage-chart.tsx: use agent_slug and
  total_tokens (not agent_name/tokens_today)
- panel/src/components/metrics/team-usage-chart.tsx: rewrite to accept
  TeamUsageRow[] from API directly
- panel/src/components/metrics/model-usage-donut.tsx: use total_tokens,
  cost_usd, pct_of_total (not tokens/cost/percentage)
- panel/src/components/metrics/sessions-table.tsx: use agent_slug, sort keys
  updated
- panel/src/components/dashboard/usage-overview-panel.tsx: use useUsageSummary
  with tokens_input/tokens_output/total_cost_usd/trend_pct
- panel/src/app/(dashboard)/metrics/page.tsx: wire all new hooks, add
  TeamUsageChart, ProjectionCard, CacheEfficiencyCard with correct types
- panel/src/app/(dashboard)/agents/page.tsx: key agentUsageMap by agent_slug
- panel/src/components/agents/agent-card.tsx: use total_tokens and cost_usd

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>

* [2161b832] fix: SDK_PORT constant, stop_agent lock refactor, usage_session_id binding, rollup 7-day window (#93) (#95)

- Add SDK_PORT = 9000 module-level constant to orchestrator.py; replace
  hardcoded 9000 in _sweep_budget_exceeded URL with SDK_PORT
- Add UUID to TYPE_CHECKING imports to satisfy ruff F821
- Refactor stop_agent: call _finalize_spawn_session BEFORE acquiring
  self._lock so the SDK HTTP round-trip does not hold the lock
- Add usage_session_id: UUID | None field to AgentInstance dataclass
- Change _record_spawn_session to return UUID | None; wire return value
  back to instance.usage_session_id in _launch_spawn
- Update _finalize_spawn_session to use WHERE id=usage_session_id for
  direct session row lookup when usage_session_id is not None
- Add started_at >= (now_utc - 7 days) filter to _sweep_daily_rollup
  aggregate query to avoid re-aggregating all-time history each sweep

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* [2e0759e1] fix: pricing accuracy, import ordering, session-id binding, rollup cleanup, write-hook tests (#97) (#98)

- pricing.py: correct claude-opus-4 prices (5/25/0.50/6.25 not 15/75/1.5/3.75)
  and haiku family prices (1/5/0.10/1.25 not 0.8/4/0.08/0.20); add Ollama
  zero-cost early-return; add structlog warning for unmatched model names
- app.py: move usage_router import before routes.v1 block (ruff isort fix)
- orchestrator.py _sweep_daily_rollup: remove unused calculate_cost import;
  add blank line between stdlib (uuid4) and third-party (sqlalchemy) imports
- orchestrator.py _sweep_token_snapshots: prefer direct lookup by
  instance.usage_session_id; fall back to agent_slug heuristic only when None
- tests: add test_sweep_daily_rollup_inserts_new_row and
  test_stop_agent_finalizes_before_lock to test_orchestrator_write_hooks.py
- usage.py, routes/usage.py, stream_bus.py, test files: ruff format/lint fixes

Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>

* Mypy compliance

* fix(migrations,tests): linearize forked migration chain + correct ceo_reject coordination-root expectation

The master merge brought in 026_completed_dependency_ids alongside the rework's
026_token_usage_tables — both off 025, forking the alembic head and breaking
the enum-parity test. Rebase token-usage onto 026_completed_dependency_ids
(linear chain, single head).

Also: test_ceo_reject_routes_coordination_task_to_main_pm asserted the old
NEEDS_REVISION behavior; the lifecycle fix correctly routes a coordination root
to PENDING (Main PM's claim source). Update the assertion.

---------

Co-authored-by: Frontend Developer 1 <fe-dev-1@agents.roboco.dev>
Co-authored-by: Backend Developer 1 <be-dev-1@agents.roboco.dev>
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
Renzo F
2026-06-10 14:38:44 +02:00
committed by GitHub
co-authored by Frontend Developer 1 Backend Developer 1 Renn F
parent 93c6ef8a57
commit b3057628b0
40 changed files with 5039 additions and 19 deletions
+27 -1
View File
@@ -17,13 +17,15 @@ import { MoreHorizontal, Activity, Square } from "lucide-react";
import { toast } from "sonner";
import { AgentStateBadge } from "./agent-state-badge";
import { SpawnAgentDialog } from "./spawn-agent-dialog";
import type { AgentUsageRow } from "@/types";
interface AgentCardProps {
agent: AgentDefinition;
agentStatus: AgentStatusResponse | null;
usageRow?: AgentUsageRow | null;
}
export function AgentCard({ agent, agentStatus }: AgentCardProps) {
export function AgentCard({ agent, agentStatus, usageRow }: AgentCardProps) {
const stopAgent = useStopAgent();
const state = agentStatus?.state || "stopped";
const isActive = ["running", "ready", "starting", "waiting_long"].includes(state);
@@ -100,6 +102,30 @@ export function AgentCard({ agent, agentStatus }: AgentCardProps) {
Errors: {agentStatus.error_count}
</p>
)}
{usageRow && (
<div className="mt-3 pt-2 border-t">
<div className="flex items-center justify-between text-xs text-muted-foreground mb-1">
<span>
{usageRow.total_tokens >= 1_000
? (usageRow.total_tokens / 1_000).toFixed(1) + "K"
: String(usageRow.total_tokens)}{" "}
tokens
</span>
<span className="font-medium text-foreground">
${usageRow.cost_usd.toFixed(4)}
</span>
</div>
<div className="w-full bg-muted rounded-full h-1.5 overflow-hidden">
<div
className="h-full rounded-full bg-[var(--chart-1)]"
style={{
width:
Math.min(100, (usageRow.total_tokens / 30_000) * 100) + "%",
}}
/>
</div>
</div>
)}
</CardContent>
</Card>
);
+5 -2
View File
@@ -1,4 +1,4 @@
import { AgentStatusResponse } from "@/types";
import { AgentStatusResponse, AgentUsageRow } from "@/types";
import { AgentDefinition } from "@/lib/agent-definitions";
import { Card, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
@@ -8,6 +8,7 @@ interface AgentGridProps {
title: string;
agents: AgentDefinition[];
agentStatuses: Record<string, AgentStatusResponse>;
agentUsage?: Record<string, AgentUsageRow>;
isLoading: boolean;
columns?: number;
}
@@ -16,8 +17,9 @@ export function AgentGrid({
title,
agents,
agentStatuses,
agentUsage,
isLoading,
columns = 4
columns = 4,
}: AgentGridProps) {
const gridCols = {
3: "md:grid-cols-3",
@@ -44,6 +46,7 @@ export function AgentGrid({
key={agent.id}
agent={agent}
agentStatus={agentStatuses[agent.id] || null}
usageRow={agentUsage?.[agent.id] ?? null}
/>
))
)}
@@ -11,6 +11,7 @@ import { QuickActionsBar } from "./quick-actions-bar";
import { CeoApprovalQueue } from "./ceo-approval-queue";
import type { Activity } from "./activity-item";
import { Button } from "@/components/ui/button";
import { UsageOverviewPanel } from "./usage-overview-panel";
import { RefreshCw, Settings } from "lucide-react";
import Link from "next/link";
@@ -61,13 +62,14 @@ export function CommandCenter() {
<CeoApprovalQueue />
</section>
{/* Metrics and Alerts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Metrics, Alerts, and Usage Row */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<KeyMetricsPanel
metrics={overview?.key_metrics}
isLoading={loadingOverview}
/>
<AuditorAlertsPanel alerts={flags} isLoading={loadingFlags} />
<UsageOverviewPanel />
</div>
{/* Blockers and Activity Row */}
+1
View File
@@ -9,3 +9,4 @@ export { ActivityItem } from "./activity-item";
export { QuickActionsBar } from "./quick-actions-bar";
export { HealthIndicator } from "./health-indicator";
export { CeoApprovalQueue } from "./ceo-approval-queue";
export { UsageOverviewPanel } from "./usage-overview-panel";
@@ -0,0 +1,105 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { useUsageSummary } from "@/hooks/use-usage";
import { Coins, TrendingUp, TrendingDown, Zap, Activity } from "lucide-react";
function fmt(n: number, decimals = 0): string {
if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + "M";
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
return n.toFixed(decimals);
}
function fmtCost(n: number): string {
return "$" + n.toFixed(2);
}
interface MetricRowProps {
icon: React.ReactNode;
label: string;
value: string;
sub?: React.ReactNode;
}
function MetricRow({ icon, label, value, sub }: MetricRowProps) {
return (
<div className="flex items-center justify-between py-1">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{icon}
{label}
</div>
<div className="flex items-center gap-1">
<span className="font-semibold text-sm">{value}</span>
{sub}
</div>
</div>
);
}
export function UsageOverviewPanel() {
const { data: summary, isLoading } = useUsageSummary("24h");
const trendUp = (summary?.trend_pct ?? 0) >= 0;
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Coins className="h-5 w-5" />
Token Usage &amp; Cost
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-6" />
))}
</div>
) : (
<div className="divide-y">
<MetricRow
icon={<Zap className="h-4 w-4" />}
label="Tokens (input)"
value={summary ? fmt(summary.tokens_input) : "—"}
/>
<MetricRow
icon={<Zap className="h-4 w-4 text-muted-foreground" />}
label="Tokens (output)"
value={summary ? fmt(summary.tokens_output) : "—"}
/>
<MetricRow
icon={<Coins className="h-4 w-4" />}
label="Total cost"
value={summary ? fmtCost(summary.total_cost_usd) : "—"}
/>
<MetricRow
icon={
trendUp ? (
<TrendingUp className="h-4 w-4 text-red-500" />
) : (
<TrendingDown className="h-4 w-4 text-green-500" />
)
}
label="Trend vs prior period"
value={summary ? (trendUp ? "+" : "") + summary.trend_pct.toFixed(1) + "%" : "—"}
sub={
summary ? (
<span className={"text-xs " + (trendUp ? "text-red-500" : "text-green-500")}>
{trendUp ? "▲" : "▼"}
</span>
) : undefined
}
/>
<MetricRow
icon={<Activity className="h-4 w-4 text-blue-500" />}
label="Period"
value={summary?.period ?? "—"}
/>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,79 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { AgentUsageRow } from "@/types";
interface AgentUsageChartProps {
data: AgentUsageRow[] | undefined;
isLoading: boolean;
}
function fmtK(n: number): string {
if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
return String(n);
}
export function AgentUsageChart({ data, isLoading }: AgentUsageChartProps) {
const chartData = [...(data ?? [])]
.sort((a, b) => b.total_tokens - a.total_tokens)
.slice(0, 10)
.map((row) => ({
name: row.agent_slug,
Tokens: row.total_tokens,
}));
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Agent Tokens Today</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : (
<ResponsiveContainer width="100%" height={208}>
<BarChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 24 }}
>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="name"
tick={{ fontSize: 10 }}
angle={-30}
textAnchor="end"
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={fmtK}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
width={36}
/>
<Tooltip
formatter={(value) => [
fmtK(typeof value === "number" ? value : 0),
"Tokens",
]}
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="Tokens" fill="var(--chart-1)" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
+5
View File
@@ -0,0 +1,5 @@
export { UsageTimeSeriesChart } from "./usage-time-series-chart";
export { ModelUsageDonut } from "./model-usage-donut";
export { AgentUsageChart } from "./agent-usage-chart";
export { TeamUsageChart } from "./team-usage-chart";
export { SessionsTable } from "./sessions-table";
@@ -0,0 +1,78 @@
"use client";
import {
PieChart,
Pie,
Cell,
Tooltip,
ResponsiveContainer,
Legend,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { ModelUsageSlice } from "@/types";
const CHART_COLORS = [
"var(--chart-1)",
"var(--chart-2)",
"var(--chart-3)",
"var(--chart-4)",
"var(--chart-5)",
];
interface ModelUsageDonutProps {
data: ModelUsageSlice[] | undefined;
isLoading: boolean;
}
export function ModelUsageDonut({ data, isLoading }: ModelUsageDonutProps) {
const chartData = (data ?? []).map((s) => ({
name: s.model,
value: s.total_tokens,
cost: s.cost_usd,
pct: s.pct_of_total,
}));
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">By Model</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : (
<ResponsiveContainer width="100%" height={208}>
<PieChart>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={52}
outerRadius={80}
dataKey="value"
paddingAngle={3}
>
{chartData.map((_, idx) => (
<Cell
key={idx}
fill={CHART_COLORS[idx % CHART_COLORS.length]}
/>
))}
</Pie>
<Tooltip
formatter={(value, name) => [
(typeof value === "number" ? value : 0).toLocaleString() +
" tokens",
name,
]}
contentStyle={{ fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 11 }} />
</PieChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,191 @@
"use client";
import { useState, useMemo } from "react";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { ChevronUp, ChevronDown } from "lucide-react";
import type { UsageSession } from "@/types";
const PAGE_SIZE = 10;
type SortKey = keyof Pick<
UsageSession,
| "agent_slug"
| "started_at"
| "total_tokens"
| "tokens_input"
| "tokens_output"
| "tokens_cache"
| "cost"
| "model"
>;
type SortDir = "asc" | "desc";
interface Column {
key: SortKey;
label: string;
}
const COLUMNS: Column[] = [
{ key: "agent_slug", label: "Agent" },
{ key: "model", label: "Model" },
{ key: "started_at", label: "Started" },
{ key: "total_tokens", label: "Total" },
{ key: "tokens_input", label: "Input" },
{ key: "tokens_output", label: "Output" },
{ key: "tokens_cache", label: "Cache" },
{ key: "cost", label: "Cost" },
];
function formatTime(ts: string): string {
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function fmtK(n: number): string {
if (n >= 1_000) return (n / 1_000).toFixed(1) + "k";
return String(n);
}
interface SessionsTableProps {
data: UsageSession[] | undefined;
isLoading: boolean;
}
export function SessionsTable({ data, isLoading }: SessionsTableProps) {
const [sortKey, setSortKey] = useState<SortKey>("started_at");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [page, setPage] = useState(0);
const sorted = useMemo(() => {
const rows = [...(data ?? [])];
rows.sort((a, b) => {
const av = a[sortKey];
const bv = b[sortKey];
const cmp =
typeof av === "number" && typeof bv === "number"
? av - bv
: String(av).localeCompare(String(bv));
return sortDir === "asc" ? cmp : -cmp;
});
return rows;
}, [data, sortKey, sortDir]);
const totalPages = Math.max(1, Math.ceil(sorted.length / PAGE_SIZE));
const visible = sorted.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
function toggleSort(key: SortKey) {
if (sortKey === key) {
setSortDir((d) => (d === "asc" ? "desc" : "asc"));
} else {
setSortKey(key);
setSortDir("desc");
}
setPage(0);
}
function SortIcon({ col }: { col: SortKey }) {
if (sortKey !== col) return <ChevronUp className="h-3 w-3 opacity-30 ml-1 inline" />;
return sortDir === "asc" ? (
<ChevronUp className="h-3 w-3 ml-1 inline" />
) : (
<ChevronDown className="h-3 w-3 ml-1 inline" />
);
}
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Recent Sessions</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-2">
{Array.from({ length: PAGE_SIZE }).map((_, i) => (
<Skeleton key={i} className="h-8" />
))}
</div>
) : (
<>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
{COLUMNS.map((col) => (
<TableHead
key={col.key}
className="cursor-pointer select-none text-xs whitespace-nowrap"
onClick={() => toggleSort(col.key)}
>
{col.label}
<SortIcon col={col.key} />
</TableHead>
))}
</TableRow>
</TableHeader>
<TableBody>
{visible.length === 0 ? (
<TableRow>
<TableCell colSpan={COLUMNS.length} className="text-center text-muted-foreground text-sm py-8">
No sessions recorded yet
</TableCell>
</TableRow>
) : (
visible.map((s) => (
<TableRow key={s.id}>
<TableCell className="text-xs font-medium">{s.agent_slug}</TableCell>
<TableCell className="text-xs">{s.model}</TableCell>
<TableCell className="text-xs">{formatTime(s.started_at)}</TableCell>
<TableCell className="text-xs">{fmtK(s.total_tokens)}</TableCell>
<TableCell className="text-xs">{fmtK(s.tokens_input)}</TableCell>
<TableCell className="text-xs">{fmtK(s.tokens_output)}</TableCell>
<TableCell className="text-xs">{fmtK(s.tokens_cache)}</TableCell>
<TableCell className="text-xs">${s.cost.toFixed(4)}</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* Pagination */}
<div className="flex items-center justify-between mt-3 pt-3 border-t text-sm">
<span className="text-muted-foreground text-xs">
{sorted.length === 0
? "No sessions"
: `${page * PAGE_SIZE + 1}${Math.min((page + 1) * PAGE_SIZE, sorted.length)} of ${sorted.length}`}
</span>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(0, p - 1))}
disabled={page === 0}
>
Prev
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.min(totalPages - 1, p + 1))}
disabled={page >= totalPages - 1}
>
Next
</Button>
</div>
</div>
</>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,76 @@
"use client";
import {
BarChart,
Bar,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { TeamUsageRow } from "@/types";
interface TeamUsageChartProps {
data: TeamUsageRow[] | undefined;
isLoading: boolean;
}
function fmtK(n: number): string {
if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
return String(n);
}
export function TeamUsageChart({ data, isLoading }: TeamUsageChartProps) {
const chartData = [...(data ?? [])]
.sort((a, b) => b.total_tokens - a.total_tokens)
.map((row) => ({
name: row.team.replace(/_/g, " "),
Tokens: row.total_tokens,
}));
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Team Tokens</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : (
<ResponsiveContainer width="100%" height={208}>
<BarChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 8 }}
>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="name"
tick={{ fontSize: 11 }}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={fmtK}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
width={36}
/>
<Tooltip
formatter={(value) => [
fmtK(typeof value === "number" ? value : 0),
"Tokens",
]}
contentStyle={{ fontSize: 12 }}
/>
<Bar dataKey="Tokens" fill="var(--chart-2)" radius={[3, 3, 0, 0]} />
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,112 @@
"use client";
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
Legend,
ResponsiveContainer,
} from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { UsageTimePoint } from "@/types";
interface UsageTimeSeriesChartProps {
data: UsageTimePoint[] | undefined;
isLoading: boolean;
}
function formatBucket(bucket: string): string {
const d = new Date(bucket);
// If the bucket has a non-zero time component it is an hourly bucket → show HH:00.
// Otherwise it is a daily bucket → show MM/DD.
const isHourly = d.getMinutes() === 0 && (d.getHours() !== 0 || bucket.includes("T"));
if (isHourly && d.getSeconds() === 0 && !bucket.endsWith("T00:00:00.000Z")) {
return d.getHours().toString().padStart(2, "0") + ":00";
}
return (d.getMonth() + 1) + "/" + d.getDate();
}
function fmtK(n: number): string {
if (n >= 1_000) return (n / 1_000).toFixed(0) + "k";
return String(n);
}
export function UsageTimeSeriesChart({ data, isLoading }: UsageTimeSeriesChartProps) {
const chartData = (data ?? []).map((p) => ({
hour: formatBucket(p.bucket),
Input: p.tokens_input,
Output: p.tokens_output,
}));
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-base">Token Usage Over Time</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-52 w-full" />
) : (
<ResponsiveContainer width="100%" height={208}>
<AreaChart
data={chartData}
margin={{ top: 4, right: 8, left: 0, bottom: 0 }}
>
<defs>
<linearGradient id="fillInput" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--chart-1)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--chart-1)" stopOpacity={0.1} />
</linearGradient>
<linearGradient id="fillOutput" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="var(--chart-2)" stopOpacity={0.8} />
<stop offset="95%" stopColor="var(--chart-2)" stopOpacity={0.1} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" className="opacity-20" />
<XAxis
dataKey="hour"
tick={{ fontSize: 10 }}
interval={3}
axisLine={false}
tickLine={false}
/>
<YAxis
tickFormatter={fmtK}
tick={{ fontSize: 10 }}
axisLine={false}
tickLine={false}
width={36}
/>
<Tooltip
formatter={(value, name) => [
fmtK(typeof value === "number" ? value : 0),
name,
]}
contentStyle={{ fontSize: 12 }}
/>
<Legend wrapperStyle={{ fontSize: 12 }} />
<Area
type="monotone"
dataKey="Input"
stackId="1"
stroke="var(--chart-1)"
fill="url(#fillInput)"
/>
<Area
type="monotone"
dataKey="Output"
stackId="1"
stroke="var(--chart-2)"
fill="url(#fillOutput)"
/>
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}