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
@@ -6,7 +6,8 @@ import {
|
||||
useWaitingAgents,
|
||||
useAgentDefinitions,
|
||||
} from "@/hooks/use-agents";
|
||||
import { AgentStatusResponse } from "@/types";
|
||||
import { useAgentUsage } from "@/hooks/use-usage";
|
||||
import { AgentStatusResponse, AgentUsageRow } from "@/types";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { RefreshCw } from "lucide-react";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
@@ -27,6 +28,7 @@ export default function AgentsPage() {
|
||||
const { data: agents = [], isLoading: agentsLoading } = useAgentDefinitions();
|
||||
const { data: status, isLoading, error, refetch } = useOrchestratorStatus();
|
||||
const { data: waitingAgents } = useWaitingAgents();
|
||||
const { data: usageRows } = useAgentUsage();
|
||||
|
||||
// Check if it's a connection error (backend not running)
|
||||
const isOffline = error && (
|
||||
@@ -46,6 +48,15 @@ export default function AgentsPage() {
|
||||
return result;
|
||||
}, [status]);
|
||||
|
||||
// Convert usage rows to a record keyed by agent_slug
|
||||
const agentUsageMap = useMemo(() => {
|
||||
const result: Record<string, AgentUsageRow> = {};
|
||||
for (const row of usageRows ?? []) {
|
||||
result[row.agent_slug] = row;
|
||||
}
|
||||
return result;
|
||||
}, [usageRows]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
@@ -83,6 +94,7 @@ export default function AgentsPage() {
|
||||
title="Board"
|
||||
agents={getBoardAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
columns={4}
|
||||
/>
|
||||
@@ -91,6 +103,7 @@ export default function AgentsPage() {
|
||||
title="Main PM"
|
||||
agents={getMainPm(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
columns={4}
|
||||
/>
|
||||
@@ -99,6 +112,7 @@ export default function AgentsPage() {
|
||||
title="Backend Cell"
|
||||
agents={getBackendAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
columns={5}
|
||||
/>
|
||||
@@ -107,6 +121,7 @@ export default function AgentsPage() {
|
||||
title="Frontend Cell"
|
||||
agents={getFrontendAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
columns={5}
|
||||
/>
|
||||
@@ -115,6 +130,7 @@ export default function AgentsPage() {
|
||||
title="UX/UI Cell"
|
||||
agents={getUxAgents(agents)}
|
||||
agentStatuses={agentStatuses}
|
||||
agentUsage={agentUsageMap}
|
||||
isLoading={(isLoading || agentsLoading) && !isOffline}
|
||||
columns={4}
|
||||
/>
|
||||
|
||||
@@ -2,14 +2,33 @@
|
||||
|
||||
import { useOrchestratorStatus } from "@/hooks/use-agents";
|
||||
import { useTasks } from "@/hooks/use-tasks";
|
||||
import {
|
||||
useUsageSummary,
|
||||
useUsageTimeSeries,
|
||||
useAgentUsage,
|
||||
useTeamUsage,
|
||||
useModelUsage,
|
||||
useUsageProjection,
|
||||
useCacheEfficiency,
|
||||
useUsageSessions,
|
||||
} from "@/hooks/use-usage";
|
||||
import { TaskStatus, Team } from "@/types";
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { OfflineState } from "@/components/ui/offline-state";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import {
|
||||
UsageTimeSeriesChart,
|
||||
ModelUsageDonut,
|
||||
AgentUsageChart,
|
||||
TeamUsageChart,
|
||||
SessionsTable,
|
||||
} from "@/components/metrics";
|
||||
import {
|
||||
Activity,
|
||||
TrendingUp,
|
||||
TrendingDown,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
Users,
|
||||
@@ -18,6 +37,8 @@ import {
|
||||
RefreshCw,
|
||||
Zap,
|
||||
Timer,
|
||||
Coins,
|
||||
Sparkles,
|
||||
} from "lucide-react";
|
||||
|
||||
interface MetricCardProps {
|
||||
@@ -295,8 +316,218 @@ export default function MetricsPage() {
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ─── Token Usage & Costs ─────────────────────────────────── */}
|
||||
<TokenUsageCostsSection />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TOKEN USAGE & COSTS SECTION
|
||||
// =============================================================================
|
||||
|
||||
function TokenUsageCostsSection() {
|
||||
const { data: summary, isLoading: loadingSnap } = useUsageSummary("24h");
|
||||
const { data: timeSeries, isLoading: loadingTS } = useUsageTimeSeries("24h");
|
||||
const { data: agentUsage, isLoading: loadingAgents } = useAgentUsage("24h");
|
||||
const { data: teamUsage, isLoading: loadingTeams } = useTeamUsage("24h");
|
||||
const { data: sessions, isLoading: loadingSessions } = useUsageSessions(100);
|
||||
const { data: modelUsage, isLoading: loadingModels } = useModelUsage("24h");
|
||||
const { data: projection, isLoading: loadingProj } = useUsageProjection();
|
||||
const { data: cacheStats, isLoading: loadingCache } = useCacheEfficiency("24h");
|
||||
|
||||
const trendUp = (summary?.trend_pct ?? 0) >= 0;
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h2 className="text-lg font-semibold">Token Usage & Costs</h2>
|
||||
|
||||
{/* Row 1 — Summary cards */}
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-6">
|
||||
<SummaryCard
|
||||
title="Tokens Input"
|
||||
value={summary ? fmtTokens(summary.tokens_input) : undefined}
|
||||
icon={<Zap className="h-4 w-4 text-yellow-500" />}
|
||||
isLoading={loadingSnap}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Tokens Output"
|
||||
value={summary ? fmtTokens(summary.tokens_output) : undefined}
|
||||
icon={<Zap className="h-4 w-4 text-blue-500" />}
|
||||
isLoading={loadingSnap}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Total Cost (24h)"
|
||||
value={summary ? "$" + summary.total_cost_usd.toFixed(4) : undefined}
|
||||
icon={<Coins className="h-4 w-4 text-green-500" />}
|
||||
isLoading={loadingSnap}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Trend vs Prior"
|
||||
value={summary ? (trendUp ? "+" : "") + summary.trend_pct.toFixed(1) + "%" : undefined}
|
||||
icon={
|
||||
trendUp ? (
|
||||
<TrendingUp className="h-4 w-4 text-red-500" />
|
||||
) : (
|
||||
<TrendingDown className="h-4 w-4 text-green-500" />
|
||||
)
|
||||
}
|
||||
isLoading={loadingSnap}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Total Tokens"
|
||||
value={summary ? fmtTokens(summary.total_tokens) : undefined}
|
||||
icon={<Activity className="h-4 w-4 text-blue-500" />}
|
||||
isLoading={loadingSnap}
|
||||
/>
|
||||
<SummaryCard
|
||||
title="Cache Saved"
|
||||
value={cacheStats ? "$" + cacheStats.cost_saved_by_cache_usd.toFixed(4) : undefined}
|
||||
icon={<Sparkles className="h-4 w-4 text-purple-500" />}
|
||||
isLoading={loadingCache}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Row 2 — Time series + model donut */}
|
||||
<div className="grid gap-4 lg:grid-cols-3">
|
||||
<div className="lg:col-span-2">
|
||||
<UsageTimeSeriesChart data={timeSeries} isLoading={loadingTS} />
|
||||
</div>
|
||||
<ModelUsageDonut data={modelUsage} isLoading={loadingModels} />
|
||||
</div>
|
||||
|
||||
{/* Row 3 — Agent bar + team bar */}
|
||||
<div className="grid gap-4 lg:grid-cols-2">
|
||||
<AgentUsageChart data={agentUsage} isLoading={loadingAgents} />
|
||||
<TeamUsageChart data={teamUsage} isLoading={loadingTeams} />
|
||||
</div>
|
||||
|
||||
{/* Row 4 — Projection + cache efficiency */}
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<ProjectionCard projection={projection} isLoading={loadingProj} />
|
||||
<CacheEfficiencyCard cacheStats={cacheStats} isLoading={loadingCache} />
|
||||
</div>
|
||||
|
||||
{/* Row 5 — Sessions table (mock-mode only; empty in production) */}
|
||||
<SessionsTable data={sessions} isLoading={loadingSessions} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Helper sub-components ────────────────────────────────────────────────────
|
||||
|
||||
function fmtTokens(n: number): string {
|
||||
if (n >= 1_000_000) return (n / 1_000_000).toFixed(2) + "M";
|
||||
if (n >= 1_000) return (n / 1_000).toFixed(1) + "K";
|
||||
return String(n);
|
||||
}
|
||||
|
||||
interface SummaryCardProps {
|
||||
title: string;
|
||||
value: string | undefined;
|
||||
icon: React.ReactNode;
|
||||
trend?: { dir: "up" | "down"; label: string };
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function SummaryCard({ title, value, icon, trend, isLoading }: SummaryCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">{title}</CardTitle>
|
||||
{icon}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-7 w-24" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{value ?? "—"}</div>
|
||||
{trend && (
|
||||
<p
|
||||
className={
|
||||
"text-xs mt-1 " +
|
||||
(trend.dir === "up" ? "text-red-500" : "text-green-500")
|
||||
}
|
||||
>
|
||||
{trend.label}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
import type { UsageProjection as UP, CacheEfficiencyResponse as CER } from "@/types";
|
||||
|
||||
interface ProjectionCardProps {
|
||||
projection: UP | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function ProjectionCard({ projection, isLoading }: ProjectionCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<TrendingUp className="h-4 w-4 text-blue-500" />
|
||||
Monthly Projection
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-3xl font-bold">
|
||||
{projection != null ? "$" + projection.projected_monthly_cost_usd.toFixed(2) : "—"}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
Based on {projection?.basis_days ?? 7}-day rolling average ($
|
||||
{projection?.avg_daily_cost_usd.toFixed(4) ?? "—"}/day)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
interface CacheEfficiencyCardProps {
|
||||
cacheStats: CER | undefined;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
function CacheEfficiencyCard({ cacheStats, isLoading }: CacheEfficiencyCardProps) {
|
||||
const pct = cacheStats ? cacheStats.cache_hit_rate * 100 : 0;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-purple-500" />
|
||||
Cache Efficiency
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<div>
|
||||
<div className="text-3xl font-bold">{pct.toFixed(1)}%</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{cacheStats ? fmtTokens(cacheStats.tokens_cache_read) : "—"} cache reads ·
|
||||
saved ${cacheStats?.cost_saved_by_cache_usd.toFixed(4) ?? "—"}
|
||||
</p>
|
||||
<Progress value={pct} className="mt-2" />
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -33,3 +33,4 @@ export * from "./use-websocket";
|
||||
export * from "./use-journals";
|
||||
export * from "./use-projects";
|
||||
export * from "./use-work-sessions";
|
||||
export * from "./use-usage";
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { usageApi } from "@/lib/api/usage";
|
||||
import type { UsagePeriod } from "@/lib/api/usage";
|
||||
import type {
|
||||
UsageSummary,
|
||||
AgentUsageRow,
|
||||
TeamUsageRow,
|
||||
ModelUsageSlice,
|
||||
UsageTimePoint,
|
||||
UsageProjection,
|
||||
CacheEfficiencyResponse,
|
||||
UsageSession,
|
||||
} from "@/types";
|
||||
|
||||
// =============================================================================
|
||||
// QUERY KEYS
|
||||
// =============================================================================
|
||||
|
||||
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,
|
||||
agentUsage: (period: UsagePeriod) => [...usageKeys.all, "by-agent", period] as const,
|
||||
teamUsage: (period: UsagePeriod) => [...usageKeys.all, "by-team", period] as const,
|
||||
modelUsage: (period: UsagePeriod) => [...usageKeys.all, "by-model", period] as const,
|
||||
projection: () => [...usageKeys.all, "projection"] as const,
|
||||
cacheEfficiency: (period: UsagePeriod) => [...usageKeys.all, "cache-efficiency", period] as const,
|
||||
sessions: (limit: number) => [...usageKeys.all, "sessions", limit] as const,
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// HOOKS
|
||||
// =============================================================================
|
||||
|
||||
/** Aggregated usage summary (tokens_input, tokens_output, total_cost_usd, …) */
|
||||
export function useUsageSummary(period: UsagePeriod = "24h") {
|
||||
return useQuery<UsageSummary>({
|
||||
queryKey: usageKeys.summary(period),
|
||||
queryFn: () => usageApi.getUsageSummary(period),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Bucketed time-series data for the stacked area chart */
|
||||
export function useUsageTimeSeries(period: UsagePeriod = "24h") {
|
||||
return useQuery<UsageTimePoint[]>({
|
||||
queryKey: usageKeys.timeSeries(period),
|
||||
queryFn: () => usageApi.getUsageTimeSeries(period),
|
||||
refetchInterval: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Per-agent usage rows for bar chart and agent card mini-bars */
|
||||
export function useAgentUsage(period: UsagePeriod = "24h") {
|
||||
return useQuery<AgentUsageRow[]>({
|
||||
queryKey: usageKeys.agentUsage(period),
|
||||
queryFn: () => usageApi.getAgentUsage(period),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Per-team usage rows from the dedicated by-team endpoint */
|
||||
export function useTeamUsage(period: UsagePeriod = "24h") {
|
||||
return useQuery<TeamUsageRow[]>({
|
||||
queryKey: usageKeys.teamUsage(period),
|
||||
queryFn: () => usageApi.getTeamUsage(period),
|
||||
refetchInterval: 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Per-model slices for the donut chart */
|
||||
export function useModelUsage(period: UsagePeriod = "24h") {
|
||||
return useQuery<ModelUsageSlice[]>({
|
||||
queryKey: usageKeys.modelUsage(period),
|
||||
queryFn: () => usageApi.getModelUsage(period),
|
||||
refetchInterval: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Monthly cost projection based on 7-day rolling average */
|
||||
export function useUsageProjection() {
|
||||
return useQuery<UsageProjection>({
|
||||
queryKey: usageKeys.projection(),
|
||||
queryFn: () => usageApi.getUsageProjection(),
|
||||
refetchInterval: 300_000,
|
||||
});
|
||||
}
|
||||
|
||||
/** Cache efficiency stats */
|
||||
export function useCacheEfficiency(period: UsagePeriod = "24h") {
|
||||
return useQuery<CacheEfficiencyResponse>({
|
||||
queryKey: usageKeys.cacheEfficiency(period),
|
||||
queryFn: () => usageApi.getCacheEfficiency(period),
|
||||
refetchInterval: 120_000,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Recent inference sessions — mock-mode only.
|
||||
*
|
||||
* Returns an empty array in production (no real backend endpoint for sessions).
|
||||
* The SessionsTable will display "No sessions recorded yet" gracefully.
|
||||
*/
|
||||
export function useUsageSessions(limit: number = 100) {
|
||||
return useQuery<UsageSession[]>({
|
||||
queryKey: usageKeys.sessions(limit),
|
||||
queryFn: () => usageApi.getUsageSessions(limit),
|
||||
refetchInterval: 30_000,
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { api, API_URL } from "./client";
|
||||
export { usageApi } from "./usage";
|
||||
export { tasksApi } from "./tasks";
|
||||
export { orchestratorApi } from "./orchestrator";
|
||||
export { channelsApi } from "./channels";
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
import api from "./client";
|
||||
import { isMockMode } from "@/lib/mock-data";
|
||||
import type {
|
||||
UsageSummary,
|
||||
AgentUsageRow,
|
||||
TeamUsageRow,
|
||||
ModelUsageSlice,
|
||||
UsageTimePoint,
|
||||
UsageProjection,
|
||||
CacheEfficiencyResponse,
|
||||
UsageSession,
|
||||
} from "@/types";
|
||||
|
||||
export type UsagePeriod = "24h" | "7d" | "30d";
|
||||
|
||||
// =============================================================================
|
||||
// MOCK DATA — shapes must exactly match the real backend response schemas
|
||||
// =============================================================================
|
||||
|
||||
function mockSummary(period: UsagePeriod = "24h"): UsageSummary {
|
||||
const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1;
|
||||
const base = 124_800 * scale;
|
||||
return {
|
||||
tokens_input: Math.round(base * 0.55),
|
||||
tokens_output: Math.round(base * 0.35),
|
||||
total_tokens: base,
|
||||
total_cost_usd: parseFloat((base * 0.000030).toFixed(6)),
|
||||
trend_pct: 12.5,
|
||||
period,
|
||||
};
|
||||
}
|
||||
|
||||
function mockTimeSeries(period: UsagePeriod = "24h"): UsageTimePoint[] {
|
||||
const now = new Date();
|
||||
const points = period === "24h" ? 24 : period === "7d" ? 7 : 30;
|
||||
const step = period === "24h" ? "hour" : "day";
|
||||
return Array.from({ length: points }, (_, i) => {
|
||||
const ts = new Date(now);
|
||||
if (step === "hour") {
|
||||
ts.setHours(now.getHours() - (points - 1 - i), 0, 0, 0);
|
||||
} else {
|
||||
ts.setDate(now.getDate() - (points - 1 - i));
|
||||
ts.setHours(0, 0, 0, 0);
|
||||
}
|
||||
const base = 3_000 + Math.round(Math.random() * 4_000);
|
||||
const tokens_input = Math.round(base * 0.55);
|
||||
const tokens_output = Math.round(base * 0.35);
|
||||
const total_tokens = base;
|
||||
return {
|
||||
bucket: ts.toISOString(),
|
||||
tokens_input,
|
||||
tokens_output,
|
||||
total_tokens,
|
||||
cost_usd: parseFloat((total_tokens * 0.000030).toFixed(6)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mockAgentUsage(period: UsagePeriod = "24h"): AgentUsageRow[] {
|
||||
const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1;
|
||||
const agents = [
|
||||
{ agent_slug: "be-dev-1" },
|
||||
{ agent_slug: "be-dev-2" },
|
||||
{ agent_slug: "fe-dev-1" },
|
||||
{ agent_slug: "fe-dev-2" },
|
||||
{ agent_slug: "ux-dev-1" },
|
||||
{ agent_slug: "be-qa" },
|
||||
{ agent_slug: "fe-qa" },
|
||||
{ agent_slug: "main-pm" },
|
||||
];
|
||||
const grand = agents.length * 15_000 * scale;
|
||||
return agents.map((a) => {
|
||||
const ti = Math.round((5_000 + Math.random() * 20_000) * scale);
|
||||
const to_ = Math.round(ti * 0.65);
|
||||
const total = ti + to_;
|
||||
return {
|
||||
agent_slug: a.agent_slug,
|
||||
tokens_input: ti,
|
||||
tokens_output: to_,
|
||||
total_tokens: total,
|
||||
cost_usd: parseFloat((total * 0.000030).toFixed(6)),
|
||||
pct_of_total: parseFloat(((total / grand) * 100).toFixed(2)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mockTeamUsage(period: UsagePeriod = "24h"): TeamUsageRow[] {
|
||||
const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1;
|
||||
const teams = ["backend", "frontend", "ux_ui", "main_pm"];
|
||||
const grand = teams.length * 50_000 * scale;
|
||||
return teams.map((team) => {
|
||||
const ti = Math.round((30_000 + Math.random() * 40_000) * scale);
|
||||
const to_ = Math.round(ti * 0.65);
|
||||
const total = ti + to_;
|
||||
return {
|
||||
team,
|
||||
tokens_input: ti,
|
||||
tokens_output: to_,
|
||||
total_tokens: total,
|
||||
cost_usd: parseFloat((total * 0.000030).toFixed(6)),
|
||||
pct_of_total: parseFloat(((total / grand) * 100).toFixed(2)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mockModelUsage(period: UsagePeriod = "24h"): ModelUsageSlice[] {
|
||||
const scale = period === "30d" ? 30 : period === "7d" ? 7 : 1;
|
||||
const models = [
|
||||
{ model: "claude-opus-4", share: 0.548 },
|
||||
{ model: "claude-sonnet-4", share: 0.346 },
|
||||
{ model: "claude-haiku-4", share: 0.106 },
|
||||
];
|
||||
const base = 124_800 * scale;
|
||||
return models.map((m) => {
|
||||
const ti = Math.round(base * m.share * 0.55);
|
||||
const to_ = Math.round(base * m.share * 0.35);
|
||||
const total = Math.round(base * m.share);
|
||||
return {
|
||||
model: m.model,
|
||||
tokens_input: ti,
|
||||
tokens_output: to_,
|
||||
total_tokens: total,
|
||||
cost_usd: parseFloat((total * 0.000030).toFixed(6)),
|
||||
pct_of_total: parseFloat((m.share * 100).toFixed(1)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function mockProjection(): UsageProjection {
|
||||
const total_cost_7d = parseFloat((124_800 * 7 * 0.000030).toFixed(6));
|
||||
return {
|
||||
total_cost_7d,
|
||||
avg_daily_cost_usd: parseFloat((total_cost_7d / 7).toFixed(6)),
|
||||
projected_monthly_cost_usd: parseFloat((total_cost_7d / 7 * 30).toFixed(4)),
|
||||
basis_days: 7,
|
||||
};
|
||||
}
|
||||
|
||||
function mockCacheEfficiency(period: UsagePeriod = "24h"): CacheEfficiencyResponse {
|
||||
return {
|
||||
cache_hit_rate: 0.3142,
|
||||
tokens_cache_read: 39_168,
|
||||
tokens_cache_write: 12_480,
|
||||
tokens_input: 85_632,
|
||||
cost_saved_by_cache_usd: parseFloat((39_168 * (3.00 - 0.30) / 1_000_000).toFixed(6)),
|
||||
period,
|
||||
};
|
||||
}
|
||||
|
||||
function mockSessions(): UsageSession[] {
|
||||
const models = ["claude-opus-4", "claude-sonnet-4", "claude-haiku-4"];
|
||||
const agentSlugs = ["be-dev-1", "be-dev-2", "fe-dev-1", "fe-qa", "main-pm"];
|
||||
return Array.from({ length: 35 }, (_, i) => {
|
||||
const agent_slug = agentSlugs[i % agentSlugs.length];
|
||||
const model = models[i % models.length];
|
||||
const input = Math.round(2_000 + Math.random() * 8_000);
|
||||
const output = Math.round(500 + Math.random() * 3_000);
|
||||
const cache = Math.round(100 + Math.random() * 1_000);
|
||||
const started = new Date(Date.now() - (i + 1) * 12 * 60_000);
|
||||
const ended = i < 3 ? null : new Date(started.getTime() + Math.round(5 + Math.random() * 55) * 60_000);
|
||||
return {
|
||||
id: `session-mock-${i + 1}`,
|
||||
agent_slug,
|
||||
started_at: started.toISOString(),
|
||||
ended_at: ended ? ended.toISOString() : null,
|
||||
tokens_input: input,
|
||||
tokens_output: output,
|
||||
tokens_cache: cache,
|
||||
total_tokens: input + output + cache,
|
||||
cost: parseFloat(((input + output) * 0.00003 + cache * 0.000003).toFixed(4)),
|
||||
model,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// API OBJECT
|
||||
// =============================================================================
|
||||
|
||||
export const usageApi = {
|
||||
/** Aggregated token usage summary — GET /usage/summary?period= */
|
||||
getUsageSummary: async (period: UsagePeriod = "24h"): Promise<UsageSummary> => {
|
||||
if (isMockMode()) return mockSummary(period);
|
||||
const { data } = await api.get<UsageSummary>("/usage/summary", {
|
||||
params: { period },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Bucketed time-series — GET /usage/time-series?period= */
|
||||
getUsageTimeSeries: async (period: UsagePeriod = "24h"): Promise<UsageTimePoint[]> => {
|
||||
if (isMockMode()) return mockTimeSeries(period);
|
||||
const { data } = await api.get<UsageTimePoint[]>("/usage/time-series", {
|
||||
params: { period },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Per-agent usage rows — GET /usage/by-agent?period= */
|
||||
getAgentUsage: async (period: UsagePeriod = "24h"): Promise<AgentUsageRow[]> => {
|
||||
if (isMockMode()) return mockAgentUsage(period);
|
||||
const { data } = await api.get<AgentUsageRow[]>("/usage/by-agent", {
|
||||
params: { period },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Per-team usage rows — GET /usage/by-team?period= */
|
||||
getTeamUsage: async (period: UsagePeriod = "24h"): Promise<TeamUsageRow[]> => {
|
||||
if (isMockMode()) return mockTeamUsage(period);
|
||||
const { data } = await api.get<TeamUsageRow[]>("/usage/by-team", {
|
||||
params: { period },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Per-model usage slices — GET /usage/by-model?period= */
|
||||
getModelUsage: async (period: UsagePeriod = "24h"): Promise<ModelUsageSlice[]> => {
|
||||
if (isMockMode()) return mockModelUsage(period);
|
||||
const { data } = await api.get<ModelUsageSlice[]>("/usage/by-model", {
|
||||
params: { period },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Monthly cost projection — GET /usage/projection */
|
||||
getUsageProjection: async (): Promise<UsageProjection> => {
|
||||
if (isMockMode()) return mockProjection();
|
||||
const { data } = await api.get<UsageProjection>("/usage/projection");
|
||||
return data;
|
||||
},
|
||||
|
||||
/** Cache efficiency stats — GET /usage/cache-efficiency?period= */
|
||||
getCacheEfficiency: async (period: UsagePeriod = "24h"): Promise<CacheEfficiencyResponse> => {
|
||||
if (isMockMode()) return mockCacheEfficiency(period);
|
||||
const { data } = await api.get<CacheEfficiencyResponse>("/usage/cache-efficiency", {
|
||||
params: { period },
|
||||
});
|
||||
return data;
|
||||
},
|
||||
|
||||
/**
|
||||
* Recent inference sessions — mock-mode only.
|
||||
*
|
||||
* The backend has no /usage/sessions endpoint. In production this
|
||||
* returns an empty array so SessionsTable shows a graceful "no data"
|
||||
* state instead of throwing a 404.
|
||||
*/
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
getUsageSessions: async (_limit: number = 100): Promise<UsageSession[]> => {
|
||||
if (isMockMode()) return mockSessions();
|
||||
return [];
|
||||
},
|
||||
};
|
||||
@@ -1253,3 +1253,92 @@ export interface CEOApprovalRequest {
|
||||
export interface CEORejectRequest {
|
||||
notes: string; // Required for rejection
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// TOKEN USAGE TYPES (aligned to real backend: GET /api/usage/*)
|
||||
// =============================================================================
|
||||
|
||||
/** Aggregated token and cost totals — GET /usage/summary?period=24h|7d|30d */
|
||||
export interface UsageSummary {
|
||||
tokens_input: number;
|
||||
tokens_output: number;
|
||||
total_tokens: number;
|
||||
total_cost_usd: number;
|
||||
trend_pct: number;
|
||||
period: string;
|
||||
}
|
||||
|
||||
/** Per-agent usage row — GET /usage/by-agent?period=24h|7d|30d */
|
||||
export interface AgentUsageRow {
|
||||
agent_slug: string;
|
||||
tokens_input: number;
|
||||
tokens_output: number;
|
||||
total_tokens: number;
|
||||
cost_usd: number;
|
||||
pct_of_total: number;
|
||||
}
|
||||
|
||||
/** Per-team usage row — GET /usage/by-team?period=24h|7d|30d */
|
||||
export interface TeamUsageRow {
|
||||
team: string;
|
||||
tokens_input: number;
|
||||
tokens_output: number;
|
||||
total_tokens: number;
|
||||
cost_usd: number;
|
||||
pct_of_total: number;
|
||||
}
|
||||
|
||||
/** Per-model usage slice — GET /usage/by-model?period=24h|7d|30d */
|
||||
export interface ModelUsageSlice {
|
||||
model: string;
|
||||
tokens_input: number;
|
||||
tokens_output: number;
|
||||
total_tokens: number;
|
||||
cost_usd: number;
|
||||
pct_of_total: number;
|
||||
}
|
||||
|
||||
/** One data point in a token-usage time series — GET /usage/time-series?period=24h|7d|30d
|
||||
*
|
||||
* - 24h → hourly buckets; 7d / 30d → daily buckets
|
||||
* - bucket is an ISO datetime string (from PostgreSQL date_trunc)
|
||||
*/
|
||||
export interface UsageTimePoint {
|
||||
bucket: string;
|
||||
tokens_input: number;
|
||||
tokens_output: number;
|
||||
total_tokens: number;
|
||||
cost_usd: number;
|
||||
}
|
||||
|
||||
/** Monthly cost projection — GET /usage/projection */
|
||||
export interface UsageProjection {
|
||||
total_cost_7d: number;
|
||||
avg_daily_cost_usd: number;
|
||||
projected_monthly_cost_usd: number;
|
||||
basis_days: number;
|
||||
}
|
||||
|
||||
/** Cache efficiency stats — GET /usage/cache-efficiency?period=24h|7d|30d */
|
||||
export interface CacheEfficiencyResponse {
|
||||
cache_hit_rate: number;
|
||||
tokens_cache_read: number;
|
||||
tokens_cache_write: number;
|
||||
tokens_input: number;
|
||||
cost_saved_by_cache_usd: number;
|
||||
period: string;
|
||||
}
|
||||
|
||||
/** Individual inference session for the sessions table (mock-mode only — no real backend endpoint) */
|
||||
export interface UsageSession {
|
||||
id: string;
|
||||
agent_slug: string;
|
||||
started_at: string;
|
||||
ended_at: string | null;
|
||||
tokens_input: number;
|
||||
tokens_output: number;
|
||||
tokens_cache: number;
|
||||
total_tokens: number;
|
||||
cost: number;
|
||||
model: string;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user