"use client"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Skeleton } from "@/components/ui/skeleton"; import { useUsageSummary } from "@/hooks/use-usage"; import { useUsageStore } from "@/store/usage-store"; import type { ConnectionState } from "@/lib/websocket/connection"; import { Coins, TrendingUp, TrendingDown, Zap, Activity, Wifi, WifiOff, Loader2, } 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 (
{icon} {label}
{value} {sub}
); } /** * Badge props mirroring the AgentStreamViewer connection-status pattern: * green/Live when the /ws/system stream is connected, yellow while * (re)connecting, gray/Polling when it is down and the panel uses HTTP polling. */ function getConnectionBadge(wsState: ConnectionState) { switch (wsState) { case "connected": return { className: "bg-green-500 text-white", icon: , label: "Live", }; case "connecting": case "reconnecting": return { className: "bg-yellow-500 text-white", icon: , label: wsState === "reconnecting" ? "Reconnecting..." : "Connecting...", }; case "disconnected": default: return { className: "bg-gray-500 text-white", icon: , label: "Polling", }; } } export function UsageOverviewPanel() { // The polling summary always runs in the background: it is the fallback when // the WS is down, and it supplies the trend (which the live snapshot, being a // point-in-time aggregate, does not compute). const { data: summary, isLoading } = useUsageSummary("24h"); // Live token/cost pushed over /ws/system (USAGE_SNAPSHOT). Prefer it whenever // the stream is connected; `live` is non-null only then, so it narrows safely. const { wsState, usageData } = useUsageStore(); const live = wsState === "connected" ? usageData : null; const tokensInput = live ? live.tokens_input : summary?.tokens_input; const tokensOutput = live ? live.tokens_output : summary?.tokens_output; const totalCost = live ? live.total_cost_usd : summary?.total_cost_usd; const periodLabel = live ? live.period : summary?.period; // Trend always comes from the polling summary (needs a prior-period baseline). const trendPct = summary?.trend_pct ?? 0; const trendUp = trendPct >= 0; const badge = getConnectionBadge(wsState); // Only show skeletons on first load with no data from either source. const showSkeleton = isLoading && !live; return (
Token Usage & Cost {badge.icon} {badge.label}
{showSkeleton ? (
{Array.from({ length: 5 }).map((_, i) => ( ))}
) : (
} label="Tokens (input)" value={tokensInput != null ? fmt(tokensInput) : "—"} /> } label="Tokens (output)" value={tokensOutput != null ? fmt(tokensOutput) : "—"} /> } label="Total cost" value={totalCost != null ? fmtCost(totalCost) : "—"} /> ) : ( ) } label="Trend vs prior period" value={summary ? (trendUp ? "+" : "") + trendPct.toFixed(1) + "%" : "—"} sub={ summary ? ( {trendUp ? "▲" : "▼"} ) : undefined } /> } label="Period" value={periodLabel ?? "—"} />
)}
); }