mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
[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:
co-authored by
Frontend Developer 1
Backend Developer 1
Renn F
parent
93c6ef8a57
commit
b3057628b0
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user