diff --git a/packages/stats/CHANGELOG.md b/packages/stats/CHANGELOG.md index 66c41cc79..5fa3a5a65 100644 --- a/packages/stats/CHANGELOG.md +++ b/packages/stats/CHANGELOG.md @@ -2,6 +2,19 @@ ## [Unreleased] +### Added + +- New Projects view summarizing usage, cost, and reliability per project folder (backed by the existing `/api/stats/folders` endpoint). +- System-aware light/dark theme toggle — follows the OS by default, and an explicit choice persists across reloads. + +### Changed + +- Redesigned the local stats dashboard with an OMP-themed product shell, dedicated per-section views, accessible loading/empty/error states, and flicker-free navigation between screens and time ranges. + +### Fixed + +- The 1h time-range chart rendered an empty/single-point line; it now buckets at 5-minute granularity for a real trend. + ## [15.13.3] - 2026-06-15 ### Changed diff --git a/packages/stats/build.ts b/packages/stats/build.ts index 28de76b81..c7b535457 100644 --- a/packages/stats/build.ts +++ b/packages/stats/build.ts @@ -71,6 +71,17 @@ const indexHtml = ` AI Usage Statistics + diff --git a/packages/stats/src/aggregator.ts b/packages/stats/src/aggregator.ts index 4021238db..de81f6743 100644 --- a/packages/stats/src/aggregator.ts +++ b/packages/stats/src/aggregator.ts @@ -255,6 +255,7 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed: const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; +const FIVE_MIN_MS = 5 * 60 * 1000; type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all"; @@ -274,11 +275,11 @@ const DEFAULT_TIME_RANGE: TimeRange = "24h"; const TIME_RANGE_TO_CONFIG: Record> = { "1h": { timeSeriesHours: 1, - timeSeriesBucketMs: HOUR_MS, + timeSeriesBucketMs: FIVE_MIN_MS, modelSeriesDays: 1, - modelSeriesBucketMs: HOUR_MS, + modelSeriesBucketMs: FIVE_MIN_MS, modelPerformanceDays: 1, - modelPerformanceBucketMs: HOUR_MS, + modelPerformanceBucketMs: FIVE_MIN_MS, costSeriesDays: 1, }, "24h": { diff --git a/packages/stats/src/client/App.tsx b/packages/stats/src/client/App.tsx index bf1fb9ac4..7855b8f14 100644 --- a/packages/stats/src/client/App.tsx +++ b/packages/stats/src/client/App.tsx @@ -1,221 +1,103 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useRef, useState } from "react"; +import { AppLayout } from "./app/AppLayout"; +import type { DashboardSection } from "./app/routes"; +import { useHashRoute } from "./data/useHashRoute"; import { - getBehaviorDashboardStats, - getCostDashboardStats, - getModelDashboardStats, - getOverviewStats, - getRecentErrors, - getRecentRequests, - sync, -} from "./api"; -import { BehaviorChart } from "./components/BehaviorChart"; -import { BehaviorModelsTable } from "./components/BehaviorModelsTable"; -import { BehaviorSummary } from "./components/BehaviorSummary"; -import { ChartsContainer } from "./components/ChartsContainer"; -import { CostChart } from "./components/CostChart"; -import { CostSummary } from "./components/CostSummary"; -import { Header } from "./components/Header"; -import { ModelsTable } from "./components/ModelsTable"; -import { RequestDetail } from "./components/RequestDetail"; -import { RequestList } from "./components/RequestList"; -import { StatsGrid } from "./components/StatsGrid"; -import type { - BehaviorDashboardStats, - CostDashboardStats, - MessageStats, - ModelDashboardStats, - OverviewStats, - TimeRange, -} from "./types"; - -type Tab = "overview" | "requests" | "errors" | "models" | "costs" | "behavior"; + BehaviorRoute, + CostsRoute, + ErrorsRoute, + ModelsRoute, + OverviewRoute, + ProjectsRoute, + RequestsRoute, +} from "./routes"; +import { RequestDrawer } from "./ui/RequestDrawer"; export default function App() { - const [overviewStats, setOverviewStats] = useState(null); - const [modelStats, setModelStats] = useState(null); - const [costStats, setCostStats] = useState(null); - const [behaviorStats, setBehaviorStats] = useState(null); - const [recentRequests, setRecentRequests] = useState([]); - const [recentErrors, setRecentErrors] = useState([]); - const [selectedRequest, setSelectedRequest] = useState(null); - const [syncing, setSyncing] = useState(false); - const [activeTab, setActiveTab] = useState("overview"); - const [timeRange, setTimeRange] = useState("24h"); + const { section, setSection, range, setRange } = useHashRoute(); + const [refreshTrigger, setRefreshTrigger] = useState(0); + const [selectedRequestId, setSelectedRequestId] = useState(null); + const [updatedAt, setUpdatedAt] = useState(() => Date.now()); - const loadRecentLists = useCallback(async () => { - try { - const [requests, errors] = await Promise.all([getRecentRequests(50), getRecentErrors(50)]); - setRecentRequests(requests); - setRecentErrors(errors); - } catch (err) { - console.error(err); + const handleSyncComplete = useCallback((result: { success: boolean }) => { + if (result.success) { + setRefreshTrigger(prev => prev + 1); + setUpdatedAt(Date.now()); } }, []); - const loadActiveTabStats = useCallback(async () => { - try { - if (activeTab === "models") { - setModelStats(await getModelDashboardStats(timeRange)); - return; - } - if (activeTab === "costs") { - setCostStats(await getCostDashboardStats(timeRange)); - return; - } - if (activeTab === "behavior") { - setBehaviorStats(await getBehaviorDashboardStats(timeRange)); - return; - } - if (activeTab === "overview") { - setOverviewStats(await getOverviewStats(timeRange)); - } - } catch (err) { - console.error(err); - } - }, [activeTab, timeRange]); + // Stable identity so the drawer's effects don't tear down on every App render. + const closeDrawer = useCallback(() => setSelectedRequestId(null), []); - const handleSync = async () => { - setSyncing(true); - try { - await sync(); - await Promise.all([loadActiveTabStats(), loadRecentLists()]); - } finally { - setSyncing(false); + const active = section; + + // Keep every visited section mounted and just toggle visibility. Remounting a + // route on each navigation replays the chart entry animations (a visible + // flicker); keeping it alive makes revisits instant while the live chart + // instances still animate in place on data/range updates. Only the active + // route fetches/polls (enabled), so hidden routes don't keep hitting the API. + const mountedRef = useRef>(new Set()); + mountedRef.current.add(active); + + const renderRoute = (target: DashboardSection) => { + const isActive = target === active; + switch (target) { + case "overview": + return ( + + ); + case "requests": + return ( + + ); + case "errors": + return ( + + ); + case "models": + return ; + case "costs": + return ; + case "behavior": + return ; + case "projects": + return ; } }; - useEffect(() => { - loadRecentLists(); - const interval = setInterval(loadRecentLists, 30000); - return () => clearInterval(interval); - }, [loadRecentLists]); - - useEffect(() => { - loadActiveTabStats(); - const interval = setInterval(loadActiveTabStats, 30000); - return () => clearInterval(interval); - }, [loadActiveTabStats]); - return ( -
-
-
- - {activeTab === "overview" && ( -
- {overviewStats ? ( - - ) : ( - - )} - -
- r.id && setSelectedRequest(r.id)} - /> - r.id && setSelectedRequest(r.id)} - /> -
+ <> + + {[...mountedRef.current].map(target => ( + - )} + ))} + - {activeTab === "requests" && ( -
- r.id && setSelectedRequest(r.id)} - /> -
- )} - - {activeTab === "errors" && ( -
- r.id && setSelectedRequest(r.id)} - /> -
- )} - - {activeTab === "models" && ( -
- {modelStats ? ( - <> - - - - ) : ( - - )} -
- )} - - {activeTab === "costs" && ( -
- {costStats ? ( - <> - - - - ) : ( - - )} -
- )} - - {activeTab === "behavior" && ( -
- {behaviorStats ? ( - <> - - - - - ) : ( - - )} -
- )} - - {selectedRequest !== null && ( - setSelectedRequest(null)} /> - )} -
-
- ); -} - -function LoadingState({ label }: { label: string }) { - return ( -
-
-
- {label} -
-
+ + ); } diff --git a/packages/stats/src/client/api.ts b/packages/stats/src/client/api.ts index 93aacabe4..bcb91f43e 100644 --- a/packages/stats/src/client/api.ts +++ b/packages/stats/src/client/api.ts @@ -1,65 +1,83 @@ import type { BehaviorDashboardStats, CostDashboardStats, - DashboardStats, + FolderStats, MessageStats, ModelDashboardStats, OverviewStats, RequestDetails, + TimeRange, } from "./types"; const API_BASE = "/api"; -export async function getStats(range = "24h"): Promise { - const res = await fetch(`${API_BASE}/stats?range=${encodeURIComponent(range)}`); - if (!res.ok) throw new Error("Failed to fetch stats"); - return res.json() as Promise; +export class ApiError extends Error { + status: number; + endpoint: string; + + constructor(status: number, endpoint: string, message: string) { + super(message); + this.name = "ApiError"; + this.status = status; + this.endpoint = endpoint; + } } -export async function getOverviewStats(range = "24h"): Promise { - const res = await fetch(`${API_BASE}/stats/overview?range=${encodeURIComponent(range)}`); - if (!res.ok) throw new Error("Failed to fetch overview stats"); - return res.json() as Promise; +async function fetchJson(endpoint: string, options?: RequestInit): Promise { + const res = await fetch(endpoint, options); + if (!res.ok) { + throw new ApiError(res.status, endpoint, `HTTP error ${res.status} on ${endpoint}`); + } + return res.json() as Promise; } -export async function getModelDashboardStats(range = "24h"): Promise { - const res = await fetch(`${API_BASE}/stats/model-dashboard?range=${encodeURIComponent(range)}`); - if (!res.ok) throw new Error("Failed to fetch model stats"); - return res.json() as Promise; +export async function getOverviewStats(range: TimeRange = "24h", signal?: AbortSignal): Promise { + return fetchJson(`${API_BASE}/stats/overview?range=${encodeURIComponent(range)}`, { + signal, + }); } -export async function getCostDashboardStats(range = "24h"): Promise { - const res = await fetch(`${API_BASE}/stats/costs?range=${encodeURIComponent(range)}`); - if (!res.ok) throw new Error("Failed to fetch cost stats"); - return res.json() as Promise; +export async function getModelDashboardStats( + range: TimeRange = "24h", + signal?: AbortSignal, +): Promise { + return fetchJson(`${API_BASE}/stats/model-dashboard?range=${encodeURIComponent(range)}`, { + signal, + }); } -export async function getRecentRequests(limit = 50): Promise { - const res = await fetch(`${API_BASE}/stats/recent?limit=${limit}`); - if (!res.ok) throw new Error("Failed to fetch recent requests"); - return res.json() as Promise; +export async function getCostDashboardStats( + range: TimeRange = "24h", + signal?: AbortSignal, +): Promise { + return fetchJson(`${API_BASE}/stats/costs?range=${encodeURIComponent(range)}`, { signal }); } -export async function getRecentErrors(limit = 50): Promise { - const res = await fetch(`${API_BASE}/stats/errors?limit=${limit}`); - if (!res.ok) throw new Error("Failed to fetch recent errors"); - return res.json() as Promise; +export async function getRecentRequests(limit = 50, signal?: AbortSignal): Promise { + return fetchJson(`${API_BASE}/stats/recent?limit=${limit}`, { signal }); } -export async function getRequestDetails(id: number): Promise { - const res = await fetch(`${API_BASE}/request/${id}`); - if (!res.ok) throw new Error("Failed to fetch request details"); - return res.json() as Promise; +export async function getRecentErrors(limit = 50, signal?: AbortSignal): Promise { + return fetchJson(`${API_BASE}/stats/errors?limit=${limit}`, { signal }); } -export async function sync(): Promise { - const res = await fetch(`${API_BASE}/sync`); - if (!res.ok) throw new Error("Failed to sync"); - return res.json(); +export async function getRequestDetails(id: number, signal?: AbortSignal): Promise { + return fetchJson(`${API_BASE}/request/${id}`, { signal }); } -export async function getBehaviorDashboardStats(range = "24h"): Promise { - const res = await fetch(`${API_BASE}/stats/behavior?range=${encodeURIComponent(range)}`); - if (!res.ok) throw new Error("Failed to fetch behavior stats"); - return res.json() as Promise; +export async function sync(signal?: AbortSignal): Promise<{ processed: number; files: number; totalMessages: number }> { + return fetchJson<{ processed: number; files: number; totalMessages: number }>(`${API_BASE}/sync`, { signal }); +} + +export async function getBehaviorDashboardStats( + range: TimeRange = "24h", + signal?: AbortSignal, +): Promise { + return fetchJson(`${API_BASE}/stats/behavior?range=${encodeURIComponent(range)}`, { + signal, + }); +} + +export async function getFolderStats(range: TimeRange = "24h", signal?: AbortSignal): Promise { + return fetchJson(`${API_BASE}/stats/folders?range=${encodeURIComponent(range)}`, { signal }); } diff --git a/packages/stats/src/client/app/AppLayout.tsx b/packages/stats/src/client/app/AppLayout.tsx new file mode 100644 index 000000000..c68f3d90e --- /dev/null +++ b/packages/stats/src/client/app/AppLayout.tsx @@ -0,0 +1,93 @@ +import { X } from "lucide-react"; +import type React from "react"; +import { useState } from "react"; +import type { TimeRange } from "../types"; +import { NavRail } from "./NavRail"; +import type { DashboardSection } from "./routes"; +import { TopBar } from "./TopBar"; + +export interface AppLayoutProps { + activeSection: DashboardSection; + onSectionChange: (section: DashboardSection) => void; + range: TimeRange; + onRangeChange: (range: TimeRange) => void; + updatedAt: number | null; + onSyncStart?: () => void; + onSyncComplete?: (result: { success: boolean }) => void; + children: React.ReactNode; +} + +export function AppLayout({ + activeSection, + onSectionChange, + range, + onRangeChange, + updatedAt, + onSyncStart, + onSyncComplete, + children, +}: AppLayoutProps) { + const [menuOpen, setMenuOpen] = useState(false); + + const handleSectionChange = (section: DashboardSection) => { + onSectionChange(section); + setMenuOpen(false); + }; + + return ( +
+ {/* Desktop Rail */} + + + {/* Mobile Nav Drawer */} + {menuOpen && ( +
setMenuOpen(false)} role="presentation"> +
e.stopPropagation()} + role="dialog" + aria-modal="true" + aria-label="Navigation menu" + > +
+
+ OH MY PI + Observability +
+ +
+ +
+
+ )} + + {/* Main Layout Pane */} +
+ setMenuOpen(true)} + /> + +
+
{children}
+
+
+
+ ); +} diff --git a/packages/stats/src/client/app/NavRail.tsx b/packages/stats/src/client/app/NavRail.tsx new file mode 100644 index 000000000..a9a5a28b3 --- /dev/null +++ b/packages/stats/src/client/app/NavRail.tsx @@ -0,0 +1,44 @@ +import { type DashboardSection, routes } from "./routes"; + +export interface NavRailProps { + activeSection: DashboardSection; + onSectionChange: (section: DashboardSection) => void; + className?: string; +} + +export function NavRail({ activeSection, onSectionChange, className = "" }: NavRailProps) { + return ( + + ); +} diff --git a/packages/stats/src/client/app/RangeControl.tsx b/packages/stats/src/client/app/RangeControl.tsx new file mode 100644 index 000000000..622f90213 --- /dev/null +++ b/packages/stats/src/client/app/RangeControl.tsx @@ -0,0 +1,39 @@ +import type { TimeRange } from "../types"; + +export interface RangeControlProps { + value: TimeRange; + onChange: (value: TimeRange) => void; + className?: string; +} + +const RANGE_OPTIONS: { value: TimeRange; label: string }[] = [ + { value: "1h", label: "1h" }, + { value: "24h", label: "24h" }, + { value: "7d", label: "7d" }, + { value: "30d", label: "30d" }, + { value: "90d", label: "90d" }, + { value: "all", label: "All" }, +]; + +export function RangeControl({ value, onChange, className = "" }: RangeControlProps) { + return ( +
+ {RANGE_OPTIONS.map(opt => { + const isActive = opt.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/packages/stats/src/client/app/SyncButton.tsx b/packages/stats/src/client/app/SyncButton.tsx new file mode 100644 index 000000000..b87f60c1b --- /dev/null +++ b/packages/stats/src/client/app/SyncButton.tsx @@ -0,0 +1,75 @@ +import { RefreshCw } from "lucide-react"; +import { useState } from "react"; +import { sync } from "../api"; + +export interface SyncButtonProps { + onSyncStart?: () => void; + onSyncComplete?: (result: { + success: boolean; + data?: { processed: number; files: number; totalMessages: number }; + error?: string; + }) => void; + className?: string; +} + +export function SyncButton({ onSyncStart, onSyncComplete, className = "" }: SyncButtonProps) { + const [syncing, setSyncing] = useState(false); + const [status, setStatus] = useState<{ type: "success" | "error"; message: string } | null>(null); + + const handleSync = async () => { + if (syncing) return; + + setSyncing(true); + setStatus(null); + if (onSyncStart) { + onSyncStart(); + } + + try { + const data = await sync(); + const result = { + processed: typeof data?.processed === "number" ? data.processed : 0, + files: typeof data?.files === "number" ? data.files : 0, + totalMessages: typeof data?.totalMessages === "number" ? data.totalMessages : 0, + }; + setStatus({ + type: "success", + message: `Synced: ${result.processed} new request${result.processed === 1 ? "" : "s"} found.`, + }); + if (onSyncComplete) { + onSyncComplete({ success: true, data: result }); + } + } catch (err) { + const errorMessage = err instanceof Error ? err.message : String(err); + setStatus({ + type: "error", + message: `Sync failed: ${errorMessage}`, + }); + if (onSyncComplete) { + onSyncComplete({ success: false, error: errorMessage }); + } + } finally { + setSyncing(false); + } + }; + + return ( +
+ {status && ( + + {status.message} + + )} + +
+ ); +} diff --git a/packages/stats/src/client/app/ThemeToggle.tsx b/packages/stats/src/client/app/ThemeToggle.tsx new file mode 100644 index 000000000..dc939e82a --- /dev/null +++ b/packages/stats/src/client/app/ThemeToggle.tsx @@ -0,0 +1,37 @@ +import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react"; +import { type ThemePreference, useThemePreference } from "../useSystemTheme"; + +const NEXT_PREFERENCE: Record = { + system: "light", + light: "dark", + dark: "system", +}; + +const PREFERENCE_ICON: Record = { + system: Monitor, + light: Sun, + dark: Moon, +}; + +const PREFERENCE_LABEL: Record = { + system: "System theme", + light: "Light theme", + dark: "Dark theme", +}; + +export function ThemeToggle() { + const { preference, setPreference } = useThemePreference(); + const Icon = PREFERENCE_ICON[preference]; + + return ( + + ); +} diff --git a/packages/stats/src/client/app/TopBar.tsx b/packages/stats/src/client/app/TopBar.tsx new file mode 100644 index 000000000..a3196c465 --- /dev/null +++ b/packages/stats/src/client/app/TopBar.tsx @@ -0,0 +1,73 @@ +import { Menu } from "lucide-react"; +import type { TimeRange } from "../types"; +import { RangeControl } from "./RangeControl"; +import type { DashboardSection } from "./routes"; +import { routes } from "./routes"; +import { SyncButton } from "./SyncButton"; +import { ThemeToggle } from "./ThemeToggle"; + +export interface TopBarProps { + activeSection: DashboardSection; + range: TimeRange; + onRangeChange: (range: TimeRange) => void; + updatedAt: number | null; + onSyncStart?: () => void; + onSyncComplete?: (result: { success: boolean }) => void; + onMenuToggle?: () => void; + className?: string; +} + +export function TopBar({ + activeSection, + range, + onRangeChange, + updatedAt, + onSyncStart, + onSyncComplete, + onMenuToggle, + className = "", +}: TopBarProps) { + const currentRoute = routes.find(r => r.id === activeSection); + const title = currentRoute?.label || "Observability"; + + const formatLastUpdated = (time: number | null) => { + if (!time) return "Not updated"; + const date = new Date(time); + return `Updated ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`; + }; + + return ( +
+
+ {onMenuToggle && ( + + )} +

{title}

+
+ +
+
+ + {formatLastUpdated(updatedAt)} + +
+ + + + + + +
+
+ ); +} diff --git a/packages/stats/src/client/app/routes.ts b/packages/stats/src/client/app/routes.ts new file mode 100644 index 000000000..2fea0512b --- /dev/null +++ b/packages/stats/src/client/app/routes.ts @@ -0,0 +1,50 @@ +import { Activity, AlertCircle, Coins, Cpu, Folder, LayoutDashboard, Smile } from "lucide-react"; +import type React from "react"; + +export type DashboardSection = "overview" | "requests" | "errors" | "models" | "costs" | "behavior" | "projects"; + +export interface DashboardRoute { + id: DashboardSection; + label: string; + shortLabel?: string; + icon: React.ComponentType<{ size?: number; className?: string }>; +} + +export const routes: DashboardRoute[] = [ + { + id: "overview", + label: "Overview", + icon: LayoutDashboard, + }, + { + id: "requests", + label: "Requests", + icon: Activity, + }, + { + id: "errors", + label: "Errors", + icon: AlertCircle, + }, + { + id: "models", + label: "Models", + icon: Cpu, + }, + { + id: "costs", + label: "Costs", + icon: Coins, + }, + { + id: "behavior", + label: "Behavior", + shortLabel: "Behavior", + icon: Smile, + }, + { + id: "projects", + label: "Projects", + icon: Folder, + }, +]; diff --git a/packages/stats/src/client/components/BehaviorChart.tsx b/packages/stats/src/client/components/BehaviorChart.tsx deleted file mode 100644 index 9ebe78d3c..000000000 --- a/packages/stats/src/client/components/BehaviorChart.tsx +++ /dev/null @@ -1,189 +0,0 @@ -import { - BarElement, - CategoryScale, - Chart as ChartJS, - type ChartOptions, - Filler, - Legend, - LinearScale, - LineElement, - PointElement, - Title, - Tooltip, -} from "chart.js"; -import { useMemo, useState } from "react"; -import { Bar, Line } from "react-chartjs-2"; -import type { BehaviorTimeSeriesPoint } from "../types"; -import { useSystemTheme } from "../useSystemTheme"; -import { - barDatasetStyle, - buildAggregateTimeSeries, - buildSharedPlugins, - buildSharedScales, - buildTopNByModelSeries, - CHART_THEMES, - ChartFrame, - type ChartSeries, - lineDatasetStyle, - MODEL_COLORS, - styleDatasets, -} from "./chart-shared"; - -ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler); - -const METRIC_OPTIONS = [ - { value: "yelling", label: "Yelling" }, - { value: "profanity", label: "Profanity" }, - { value: "anguish", label: "Anguish (!!!, nooo, dude, ..)" }, - { value: "negation", label: "Negation (no/nope/wrong)" }, - { value: "repetition", label: "Repetition (i meant, still doesnt)" }, - { value: "blame", label: "Blame (you didnt, stop X-ing)" }, - { value: "frustration", label: "Frustration (neg + rep + blame)" }, - { value: "total", label: "All signals combined" }, -] as const; -type Metric = (typeof METRIC_OPTIONS)[number]["value"]; - -function formatRateAxis(value: number): string { - if (!Number.isFinite(value)) return "-"; - if (value === 0) return "0%"; - if (Math.abs(value) < 1) return `${value.toFixed(1)}%`; - return `${value.toFixed(0)}%`; -} - -interface BehaviorChartProps { - behaviorSeries: BehaviorTimeSeriesPoint[]; -} - -function pointHits(point: BehaviorTimeSeriesPoint, metric: Metric): number { - if (metric === "frustration") return point.negation + point.repetition + point.blame; - if (metric === "total") { - return point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame; - } - return point[metric]; -} - -/** Hits per 100 user messages, 0 when there were no messages. */ -function ratePercent(hits: number, messages: number): number { - if (messages <= 0) return 0; - return (hits / messages) * 100; -} - -interface DailyBucket { - hits: number; - messages: number; -} - -function buildAggregateSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries { - const label = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? "Hits"; - return buildAggregateTimeSeries(points, label, { - initBucket: () => ({ hits: 0, messages: 0 }), - accumulate: (bucket, point) => { - bucket.hits += pointHits(point, metric); - bucket.messages += point.messages; - }, - bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages), - }); -} - -function buildByModelSeries(points: BehaviorTimeSeriesPoint[], metric: Metric): ChartSeries { - // Rank by message volume so the models you actually use surface first, - // matching the Behavior-by-Model table. Per-bucket math tracks hits + - // messages separately so the final rate isn't skewed by low-volume days. - return buildTopNByModelSeries(points, { - rankWeight: point => point.messages, - initBucket: () => ({ hits: 0, messages: 0 }), - accumulate: (bucket, point) => { - bucket.hits += pointHits(point, metric); - bucket.messages += point.messages; - }, - bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages), - }); -} - -export function BehaviorChart({ behaviorSeries }: BehaviorChartProps) { - const [byModel, setByModel] = useState(false); - const [metric, setMetric] = useState("total"); - const theme = useSystemTheme(); - const chartTheme = CHART_THEMES[theme]; - - const chartData = useMemo( - () => (byModel ? buildByModelSeries(behaviorSeries, metric) : buildAggregateSeries(behaviorSeries, metric)), - [behaviorSeries, byModel, metric], - ); - - const sharedPlugins = buildSharedPlugins({ - chartTheme, - showLegend: byModel, - defaultLabel: "Hits", - formatValue: formatRateAxis, - }); - - const { sharedScaleBase, yScale } = buildSharedScales({ chartTheme, formatY: formatRateAxis }); - - const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.label ?? ""; - const metricTabs = ( -
- {METRIC_OPTIONS.map(opt => ( - - ))} -
- ); - - let chartNode: React.ReactNode; - if (byModel) { - const lineData = { - labels: chartData.labels, - datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), - }; - - const lineOptions: ChartOptions<"line"> = { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index", intersect: false }, - plugins: sharedPlugins, - scales: { x: sharedScaleBase, y: yScale }, - }; - - chartNode = ; - } else { - const barData = { - labels: chartData.labels, - datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), - }; - - const barOptions: ChartOptions<"bar"> = { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index", intersect: false }, - plugins: sharedPlugins, - scales: { - x: { ...sharedScaleBase, stacked: true }, - y: { ...yScale, stacked: true }, - }, - layout: { padding: { top: 8 } }, - }; - - chartNode = ; - } - - return ( - - {chartNode} - - ); -} diff --git a/packages/stats/src/client/components/BehaviorModelsTable.tsx b/packages/stats/src/client/components/BehaviorModelsTable.tsx deleted file mode 100644 index 16b5fccf6..000000000 --- a/packages/stats/src/client/components/BehaviorModelsTable.tsx +++ /dev/null @@ -1,342 +0,0 @@ -import { - CategoryScale, - Chart as ChartJS, - Legend, - LinearScale, - LineElement, - PointElement, - Title, - Tooltip, -} from "chart.js"; -import { format } from "date-fns"; -import { useMemo, useState } from "react"; -import { Line } from "react-chartjs-2"; -import type { BehaviorModelStats, BehaviorTimeSeriesPoint } from "../types"; -import { useSystemTheme } from "../useSystemTheme"; -import { - DetailChartEmpty, - detailChartPlugins, - detailChartScalesSingleAxis, - ExpandableModelRow, - lineSeriesStyle, - MiniSparkline, - MODEL_COLORS, - ModelNameCell, - ModelTableBody, - ModelTableHeader, - ModelTableShell, - TABLE_CHART_THEMES, - type TableChartTheme, - TrendEmpty, -} from "./models-table-shared"; - -ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); - -const SERIES_COLORS = { - yelling: "#fbbf24", // amber - profanity: "#f87171", // red - anguish: "#a78bfa", // violet - frustration: "#22d3ee", // cyan - new semantic signals -} as const; - -interface BehaviorModelsTableProps { - models: BehaviorModelStats[]; - behaviorSeries: BehaviorTimeSeriesPoint[]; -} - -interface DailyPoint { - timestamp: number; - yelling: number; - profanity: number; - anguish: number; - frustration: number; - total: number; -} - -interface ModelTrendSeries { - data: DailyPoint[]; -} - -const GRID_TEMPLATE = "2fr 0.9fr 0.8fr 0.8fr 0.8fr 0.9fr 0.8fr 140px 40px"; - -function formatInt(value: number): string { - return value.toLocaleString(); -} - -function totalHitRate(model: BehaviorModelStats): number { - if (model.totalMessages === 0) return 0; - const hits = - model.totalYelling + - model.totalProfanity + - model.totalAnguish + - model.totalNegation + - model.totalRepetition + - model.totalBlame; - return hits / model.totalMessages; -} - -/** - * Rate-as-percent. < 1% shows one decimal so a 0.4% model doesn't read as 0%. - */ -function formatRate(total: number, messages: number): string { - if (messages === 0) return "-"; - const pct = (total / messages) * 100; - if (pct === 0) return "0%"; - if (pct < 1) return `${pct.toFixed(1)}%`; - return `${pct.toFixed(0)}%`; -} - -export function BehaviorModelsTable({ models, behaviorSeries }: BehaviorModelsTableProps) { - const [expandedKey, setExpandedKey] = useState(null); - const theme = useSystemTheme(); - const chartTheme = TABLE_CHART_THEMES[theme]; - - const trendByKey = useMemo(() => buildTrendLookup(behaviorSeries), [behaviorSeries]); - - // Sort by usage so the models you actually rely on surface first; rates - // stay visible per row so a low-volume freak doesn't dominate. - const sortedModels = [...models].sort((a, b) => { - if (b.totalMessages !== a.totalMessages) return b.totalMessages - a.totalMessages; - return totalHitRate(b) - totalHitRate(a); - }); - - return ( - - - - - {sortedModels.map((model, index) => { - const key = `${model.model}::${model.provider}`; - const trend = trendByKey.get(key)?.data ?? []; - const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; - const isExpanded = expandedKey === key; - const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame; - const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration; - - return ( - setExpandedKey(isExpanded ? null : key)} - cells={[ - , -
- {formatInt(model.totalMessages)} -
, -
- {formatRate(model.totalYelling, model.totalMessages)} -
, -
- {formatRate(model.totalProfanity, model.totalMessages)} -
, -
- {formatRate(model.totalAnguish, model.totalMessages)} -
, -
- {formatRate(totalFrustration, model.totalMessages)} -
, -
- {formatRate(totalHits, model.totalMessages)} -
, - ]} - trendCell={ - trend.length === 0 ? ( - - ) : ( - d.timestamp)} - values={trend.map(d => d.total)} - color={trendColor} - /> - ) - } - expandedContent={ -
-
- - - - - - - -
-
- {trend.length === 0 ? ( - - ) : ( - - )} -
-
- } - /> - ); - })} - {sortedModels.length === 0 ? ( -
- No user behavior recorded for this range yet. -
- ) : null} -
-
- ); -} - -function DetailRow({ - label, - total, - messages, - valueClass, - mode = "rate", -}: { - label: string; - total: number; - messages: number; - valueClass: string; - mode?: "rate" | "average"; -}) { - const perMsgLabel = mode === "rate" ? "% of msgs" : "Per msg"; - const perMsgValue = - messages > 0 ? (mode === "rate" ? formatRate(total, messages) : (total / messages).toFixed(0)) : "-"; - return ( -
-
{label}
-
-
- Total - {formatInt(total)} -
-
- {perMsgLabel} - {perMsgValue} -
-
-
- ); -} - -function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: TableChartTheme }) { - const chartData = { - labels: data.map(d => format(new Date(d.timestamp), "MMM d")), - datasets: [ - { label: "CAPS", data: data.map(d => d.yelling), ...lineSeriesStyle(SERIES_COLORS.yelling) }, - { label: "Profanity", data: data.map(d => d.profanity), ...lineSeriesStyle(SERIES_COLORS.profanity) }, - { label: "Anguish", data: data.map(d => d.anguish), ...lineSeriesStyle(SERIES_COLORS.anguish) }, - { label: "Frustration", data: data.map(d => d.frustration), ...lineSeriesStyle(SERIES_COLORS.frustration) }, - ], - }; - - const options = { - responsive: true, - maintainAspectRatio: false, - plugins: detailChartPlugins(chartTheme), - scales: detailChartScalesSingleAxis(chartTheme), - }; - - return ; -} - -/** - * Group the daily time-series by model+provider, producing one continuous - * day-bucket array per model so the sparkline / breakdown chart can render - * without missing-day artifacts. - */ -function buildTrendLookup(points: BehaviorTimeSeriesPoint[]): Map { - if (points.length === 0) return new Map(); - - const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b); - const byKey = new Map>(); - - for (const point of points) { - const key = `${point.model}::${point.provider}`; - let dayMap = byKey.get(key); - if (!dayMap) { - dayMap = new Map(); - byKey.set(key, dayMap); - } - const existing = dayMap.get(point.timestamp) ?? { - timestamp: point.timestamp, - yelling: 0, - profanity: 0, - anguish: 0, - frustration: 0, - total: 0, - }; - existing.yelling += point.yelling; - existing.profanity += point.profanity; - existing.anguish += point.anguish; - existing.frustration += point.negation + point.repetition + point.blame; - existing.total = existing.yelling + existing.profanity + existing.anguish + existing.frustration; - dayMap.set(point.timestamp, existing); - } - - const out = new Map(); - for (const [key, dayMap] of byKey) { - const data = allDays.map( - ts => - dayMap.get(ts) ?? { - timestamp: ts, - yelling: 0, - profanity: 0, - anguish: 0, - frustration: 0, - total: 0, - }, - ); - out.set(key, { data }); - } - return out; -} diff --git a/packages/stats/src/client/components/BehaviorSummary.tsx b/packages/stats/src/client/components/BehaviorSummary.tsx deleted file mode 100644 index 7ce0131b1..000000000 --- a/packages/stats/src/client/components/BehaviorSummary.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useMemo } from "react"; -import type { BehaviorOverallStats, BehaviorTimeSeriesPoint } from "../types"; - -interface BehaviorSummaryProps { - overall: BehaviorOverallStats; - behaviorSeries: BehaviorTimeSeriesPoint[]; -} - -function formatInt(value: number): string { - return value.toLocaleString(); -} - -/** - * Per-message rate for a signal. Uses 2 decimals so a 0.01-hits-per-msg model - * still distinguishes from a true zero, and never shows `NaN` or `Infinity` - * when there are no messages. - */ -function perMsg(total: number, messages: number): string | undefined { - if (messages <= 0) return undefined; - return `${(total / messages).toFixed(2)} / msg`; -} - -export function BehaviorSummary({ overall, behaviorSeries }: BehaviorSummaryProps) { - // Top "ranted-at" model: model that absorbed the most caps + profanity + - // anguish + frustration (negation/repetition/blame). - const topModel = useMemo(() => { - const totals = new Map(); - for (const point of behaviorSeries) { - const key = `${point.model}::${point.provider}`; - const existing = totals.get(key); - const score = - point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame; - if (existing) { - existing.score += score; - } else { - totals.set(key, { model: point.model, provider: point.provider, score }); - } - } - let best: { model: string; provider: string; score: number } | null = null; - for (const entry of totals.values()) { - if (!best || entry.score > best.score) best = entry; - } - return best; - }, [behaviorSeries]); - - const totalFrustration = overall.totalNegation + overall.totalRepetition + overall.totalBlame; - const messages = overall.totalMessages; - - const cards: Array<{ label: string; value: string; sub?: string }> = [ - { - label: "Messages", - value: formatInt(overall.totalMessages), - sub: messages > 0 ? "in selected range" : undefined, - }, - { - label: "Yelling", - value: formatInt(overall.totalYelling), - sub: perMsg(overall.totalYelling, messages), - }, - { - label: "Profanity hits", - value: formatInt(overall.totalProfanity), - sub: perMsg(overall.totalProfanity, messages), - }, - { - label: "Anguish", - value: formatInt(overall.totalAnguish), - sub: perMsg(overall.totalAnguish, messages), - }, - { - label: "Frustration", - value: formatInt(totalFrustration), - sub: perMsg(totalFrustration, messages), - }, - { - label: "Most yelled-at", - value: topModel?.model ?? "\u2014", - sub: topModel ? `${formatInt(topModel.score)} hits` : undefined, - }, - ]; - - return ( -
- {cards.map(card => ( -
-

{card.label}

-

- {card.value} -

- {card.sub &&

{card.sub}

} -
- ))} -
- ); -} diff --git a/packages/stats/src/client/components/ChartsContainer.tsx b/packages/stats/src/client/components/ChartsContainer.tsx deleted file mode 100644 index d63fb5a67..000000000 --- a/packages/stats/src/client/components/ChartsContainer.tsx +++ /dev/null @@ -1,221 +0,0 @@ -import { - CategoryScale, - Chart as ChartJS, - Filler, - Legend, - LinearScale, - LineElement, - PointElement, - Title, - Tooltip, -} from "chart.js"; -import { useMemo } from "react"; -import { Line } from "react-chartjs-2"; -import type { ModelTimeSeriesPoint, TimeRange } from "../types"; -import { useSystemTheme } from "../useSystemTheme"; -import { formatRangeTick, rangeMeta } from "./range-meta"; - -ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler); - -const MODEL_COLORS = [ - "#a78bfa", // violet - "#22d3ee", // cyan - "#ec4899", // pink - "#4ade80", // green - "#fbbf24", // amber - "#f87171", // red - "#60a5fa", // blue -]; - -const CHART_THEMES = { - dark: { - legendLabel: "#94a3b8", - tooltipBackground: "#16161e", - tooltipTitle: "#f8fafc", - tooltipBody: "#94a3b8", - tooltipBorder: "rgba(255, 255, 255, 0.1)", - grid: "rgba(255, 255, 255, 0.06)", - tick: "#64748b", - }, - light: { - legendLabel: "#475569", - tooltipBackground: "#ffffff", - tooltipTitle: "#0f172a", - tooltipBody: "#334155", - tooltipBorder: "rgba(15, 23, 42, 0.18)", - grid: "rgba(15, 23, 42, 0.08)", - tick: "#64748b", - }, -} as const; -interface ChartsContainerProps { - modelSeries: ModelTimeSeriesPoint[]; - timeRange: TimeRange; -} - -export function ChartsContainer({ modelSeries, timeRange }: ChartsContainerProps) { - const chartData = useMemo(() => buildModelPreferenceSeries(modelSeries), [modelSeries]); - const theme = useSystemTheme(); - const chartTheme = CHART_THEMES[theme]; - const meta = rangeMeta(timeRange); - const data = { - labels: chartData.data.map(d => formatRangeTick(d.timestamp, timeRange)), - datasets: chartData.series.map((seriesName, index) => ({ - label: seriesName, - data: chartData.data.map(d => d[seriesName] ?? 0), - borderColor: MODEL_COLORS[index % MODEL_COLORS.length], - backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`, - fill: true, - tension: 0.4, - pointRadius: 0, - pointHoverRadius: 4, - borderWidth: 2, - })), - }; - - const options = { - responsive: true, - maintainAspectRatio: false, - interaction: { - mode: "index" as const, - intersect: false, - }, - plugins: { - legend: { - position: "top" as const, - align: "start" as const, - labels: { - color: chartTheme.legendLabel, - usePointStyle: true, - padding: 16, - font: { size: 12 }, - boxWidth: 8, - }, - }, - tooltip: { - backgroundColor: chartTheme.tooltipBackground, - titleColor: chartTheme.tooltipTitle, - bodyColor: chartTheme.tooltipBody, - borderColor: chartTheme.tooltipBorder, - borderWidth: 1, - padding: 12, - cornerRadius: 8, - callbacks: { - label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => { - const label = context.dataset.label ?? ""; - const value = context.parsed.y; - return `${label}: ${(value ?? 0).toFixed(1)}%`; - }, - }, - }, - }, - scales: { - x: { - grid: { - color: chartTheme.grid, - drawBorder: false, - }, - ticks: { - color: chartTheme.tick, - font: { size: 11 }, - }, - }, - y: { - grid: { - color: chartTheme.grid, - drawBorder: false, - }, - ticks: { - color: chartTheme.tick, - font: { size: 11 }, - callback: (value: number | string) => `${value}%`, - }, - min: 0, - max: 100, - }, - }, - }; - - return ( -
-
-

Model Preference

-

Share of requests over {meta.windowLabel}

-
-
- {chartData.data.length === 0 ? ( -
- No data available -
- ) : ( -
- -
- )} -
-
- ); -} - -function buildModelPreferenceSeries( - points: ModelTimeSeriesPoint[], - topN = 5, -): { - data: Array>; - series: string[]; -} { - if (points.length === 0) return { data: [], series: [] }; - - const totals = new Map(); - for (const point of points) { - const key = `${point.model}::${point.provider}`; - const existing = totals.get(key); - if (existing) { - existing.total += point.requests; - } else { - totals.set(key, { model: point.model, provider: point.provider, total: point.requests }); - } - } - - const sorted = [...totals.entries()].map(([key, value]) => ({ key, ...value })).sort((a, b) => b.total - a.total); - const topEntries = sorted.slice(0, topN); - const topKeys = new Set(topEntries.map(entry => entry.key)); - - const topModelCounts = new Map(); - for (const entry of topEntries) { - topModelCounts.set(entry.model, (topModelCounts.get(entry.model) ?? 0) + 1); - } - - const labelByKey = new Map(); - for (const entry of topEntries) { - const showProvider = (topModelCounts.get(entry.model) ?? 0) > 1; - labelByKey.set(entry.key, showProvider ? `${entry.model} (${entry.provider})` : entry.model); - } - - const dataMap = new Map>(); - - for (const point of points) { - const key = `${point.model}::${point.provider}`; - const bucket = dataMap.get(point.timestamp) ?? { timestamp: point.timestamp, total: 0 }; - bucket.total += point.requests; - const seriesLabel = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other"; - bucket[seriesLabel] = (bucket[seriesLabel] ?? 0) + point.requests; - dataMap.set(point.timestamp, bucket); - } - - const series = topEntries.map(entry => labelByKey.get(entry.key) ?? entry.model); - if ([...dataMap.values()].some(row => (row.Other ?? 0) > 0)) { - series.push("Other"); - } - - const data = [...dataMap.values()] - .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)) - .map(row => { - const total = row.total ?? 0; - for (const key of series) { - row[key] = total > 0 ? ((row[key] ?? 0) / total) * 100 : 0; - } - return row; - }); - - return { data, series }; -} diff --git a/packages/stats/src/client/components/CostChart.tsx b/packages/stats/src/client/components/CostChart.tsx deleted file mode 100644 index 205daf943..000000000 --- a/packages/stats/src/client/components/CostChart.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import { - BarElement, - CategoryScale, - Chart as ChartJS, - type ChartOptions, - Filler, - Legend, - LinearScale, - LineElement, - type Plugin, - PointElement, - Title, - Tooltip, -} from "chart.js"; -import { useMemo, useState } from "react"; -import { Bar, Line } from "react-chartjs-2"; -import type { CostTimeSeriesPoint } from "../types"; -import { useSystemTheme } from "../useSystemTheme"; -import { - barDatasetStyle, - buildAggregateTimeSeries, - buildSharedPlugins, - buildSharedScales, - buildTopNByModelSeries, - CHART_THEMES, - ChartFrame, - type ChartSeries, - lineDatasetStyle, - MODEL_COLORS, - styleDatasets, -} from "./chart-shared"; - -ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler); - -/** Cost bar labels need a per-theme color that the generic chart theme doesn't carry. */ -const BAR_LABEL_COLORS = { - dark: "rgba(248, 250, 252, 0.7)", - light: "rgba(15, 23, 42, 0.6)", -} as const; - -interface CostChartProps { - costSeries: CostTimeSeriesPoint[]; -} - -/** Inline Chart.js plugin — draws cost value centered above each bar. */ -function makeBarLabelPlugin(color: string): Plugin<"bar"> { - return { - id: "costBarLabels", - afterDatasetsDraw(chart) { - const { ctx } = chart; - const dataset = chart.data.datasets[0]; - if (!dataset) return; - const meta = chart.getDatasetMeta(0); - ctx.save(); - ctx.font = "11px system-ui, sans-serif"; - ctx.fillStyle = color; - ctx.textAlign = "center"; - ctx.textBaseline = "bottom"; - for (const bar of meta.data) { - const value = (bar as unknown as { $context: { parsed: { y: number } } }).$context.parsed.y; - if (!value) continue; - const label = `$${Math.round(value)}`; - const { x, y } = bar.getProps(["x", "y"], true) as { x: number; y: number }; - ctx.fillText(label, x, y - 3); - } - ctx.restore(); - }, - }; -} - -function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries { - return buildAggregateTimeSeries(points, "Cost", { - initBucket: () => ({ total: 0 }), - accumulate: (bucket, point) => { - bucket.total += point.cost; - }, - bucketToValue: bucket => bucket.total, - }); -} - -function buildByModelSeries(points: CostTimeSeriesPoint[]): ChartSeries { - // Rank models by total cost; per-day buckets are simple cost sums. - return buildTopNByModelSeries(points, { - rankWeight: point => point.cost, - initBucket: () => ({ total: 0 }), - accumulate: (bucket, point) => { - bucket.total += point.cost; - }, - bucketToValue: bucket => bucket.total, - }); -} - -export function CostChart({ costSeries }: CostChartProps) { - const [byModel, setByModel] = useState(false); - const theme = useSystemTheme(); - const chartTheme = CHART_THEMES[theme]; - - const chartData = useMemo( - () => (byModel ? buildByModelSeries(costSeries) : buildAggregateSeries(costSeries)), - [costSeries, byModel], - ); - - const sharedPlugins = buildSharedPlugins({ - chartTheme, - showLegend: byModel, - defaultLabel: "Cost", - formatValue: v => `$${Math.round(v)}`, - footer: items => { - if (!byModel || items.length < 2) return undefined; - const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0); - return `Total: $${Math.round(total)}`; - }, - }); - - const { sharedScaleBase, yScale } = buildSharedScales({ - chartTheme, - formatY: v => `$${Math.round(v)}`, - }); - - let chartNode: React.ReactNode; - if (byModel) { - const lineData = { - labels: chartData.labels, - datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), - }; - - const lineOptions: ChartOptions<"line"> = { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index", intersect: false }, - plugins: sharedPlugins, - scales: { x: sharedScaleBase, y: yScale }, - }; - - chartNode = ; - } else { - const barData = { - labels: chartData.labels, - datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), - }; - - const barLabelPlugin = makeBarLabelPlugin(BAR_LABEL_COLORS[theme]); - - const barOptions: ChartOptions<"bar"> = { - responsive: true, - maintainAspectRatio: false, - interaction: { mode: "index", intersect: false }, - plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"], - scales: { - x: { ...sharedScaleBase, stacked: true }, - y: { ...yScale, stacked: true }, - }, - layout: { padding: { top: 24 } }, - }; - - chartNode = ; - } - - return ( - - {chartNode} - - ); -} diff --git a/packages/stats/src/client/components/CostSummary.tsx b/packages/stats/src/client/components/CostSummary.tsx deleted file mode 100644 index 43774d9da..000000000 --- a/packages/stats/src/client/components/CostSummary.tsx +++ /dev/null @@ -1,53 +0,0 @@ -import type { CostTimeSeriesPoint } from "../types"; - -interface CostSummaryProps { - costSeries: CostTimeSeriesPoint[]; -} - -function formatCost(value: number): string { - return `$${Math.round(value)}`; -} - -export function CostSummary({ costSeries }: CostSummaryProps) { - const totalCost = costSeries.reduce((sum, p) => sum + p.cost, 0); - const dayBuckets = new Set(costSeries.map(p => p.timestamp)).size; - const avgDaily = dayBuckets > 0 ? totalCost / dayBuckets : 0; - - // Most expensive model over the visible window - const modelTotals = new Map(); - for (const point of costSeries) { - modelTotals.set(point.model, (modelTotals.get(point.model) ?? 0) + point.cost); - } - let topModel = ""; - let topModelCost = 0; - for (const [model, cost] of modelTotals) { - if (cost > topModelCost) { - topModel = model; - topModelCost = cost; - } - } - - const cards = [ - { label: "Total", value: formatCost(totalCost) }, - { label: "Avg / day", value: formatCost(avgDaily) }, - { - label: "Top model", - value: topModel || "—", - sub: topModel ? formatCost(topModelCost) : undefined, - }, - ]; - - return ( -
- {cards.map(card => ( -
-

{card.label}

-

- {card.value} -

- {card.sub &&

{card.sub}

} -
- ))} -
- ); -} diff --git a/packages/stats/src/client/components/Header.tsx b/packages/stats/src/client/components/Header.tsx deleted file mode 100644 index 162b50db8..000000000 --- a/packages/stats/src/client/components/Header.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { Activity, RefreshCw } from "lucide-react"; -import type { TimeRange } from "../types"; - -type Tab = "overview" | "requests" | "errors" | "models" | "costs" | "behavior"; - -const tabs: Tab[] = ["overview", "requests", "errors", "models", "costs", "behavior"]; -const timeRanges: { label: string; value: TimeRange }[] = [ - { label: "1h", value: "1h" }, - { label: "24h", value: "24h" }, - { label: "7d", value: "7d" }, - { label: "30d", value: "30d" }, - { label: "90d", value: "90d" }, - { label: "All", value: "all" }, -]; - -interface HeaderProps { - activeTab: Tab; - onTabChange: (tab: Tab) => void; - onSync: () => void; - syncing: boolean; - timeRange: TimeRange; - onTimeRangeChange: (timeRange: TimeRange) => void; -} - -export function Header({ activeTab, onTabChange, onSync, syncing, timeRange, onTimeRangeChange }: HeaderProps) { - return ( -
-
-
- -
-
-

AI Usage

-

Statistics & Analytics

-
-
- -
-
- {tabs.map(tab => ( - - ))} -
-
- {timeRanges.map(range => ( - - ))} -
- - -
-
- ); -} diff --git a/packages/stats/src/client/components/ModelsTable.tsx b/packages/stats/src/client/components/ModelsTable.tsx deleted file mode 100644 index be83010e6..000000000 --- a/packages/stats/src/client/components/ModelsTable.tsx +++ /dev/null @@ -1,265 +0,0 @@ -import { - CategoryScale, - Chart as ChartJS, - Legend, - LinearScale, - LineElement, - PointElement, - Title, - Tooltip, -} from "chart.js"; -import { format } from "date-fns"; -import { useMemo, useState } from "react"; -import { Line } from "react-chartjs-2"; -import type { ModelPerformancePoint, ModelStats, TimeRange } from "../types"; -import { useSystemTheme } from "../useSystemTheme"; -import { - DetailChartEmpty, - detailChartPlugins, - detailChartScalesDualAxis, - ExpandableModelRow, - lineSeriesStyle, - MiniSparkline, - MODEL_COLORS, - ModelNameCell, - ModelTableBody, - ModelTableHeader, - ModelTableShell, - TABLE_CHART_THEMES, - type TableChartTheme, - TrendEmpty, -} from "./models-table-shared"; -import { rangeMeta } from "./range-meta"; - -ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend); - -const GRID_TEMPLATE = "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px"; - -interface ModelsTableProps { - models: ModelStats[]; - performanceSeries: ModelPerformancePoint[]; - timeRange: TimeRange; -} - -type ModelPerformanceSeries = { - label: string; - data: Array<{ - timestamp: number; - avgTtftSeconds: number | null; - avgTokensPerSecond: number | null; - requests: number; - }>; -}; - -export function ModelsTable({ models, performanceSeries, timeRange }: ModelsTableProps) { - const [expandedKey, setExpandedKey] = useState(null); - const meta = rangeMeta(timeRange); - - const performanceSeriesByKey = useMemo( - () => buildModelPerformanceLookup(performanceSeries, meta.bucketCount, meta.bucketMs), - [performanceSeries, meta.bucketCount, meta.bucketMs], - ); - const theme = useSystemTheme(); - const chartTheme = TABLE_CHART_THEMES[theme]; - const sortedModels = [...models].sort( - (a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens), - ); - - return ( - - - - - {sortedModels.map((model, index) => { - const key = `${model.model}::${model.provider}`; - const performance = performanceSeriesByKey.get(key); - const trendData = performance?.data ?? []; - const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; - const isExpanded = expandedKey === key; - const errorRate = model.errorRate * 100; - - return ( - setExpandedKey(isExpanded ? null : key)} - cells={[ - , -
- {model.totalRequests.toLocaleString()} -
, -
- ${model.totalCost.toFixed(2)} -
, -
- {(model.totalInputTokens + model.totalOutputTokens).toLocaleString()} -
, -
- {model.avgTokensPerSecond?.toFixed(1) ?? "-"} -
, -
- {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"} -
, - ]} - trendCell={ - trendData.length === 0 ? ( - - ) : ( - d.timestamp)} - values={trendData.map(d => d.avgTokensPerSecond ?? 0)} - color={trendColor} - /> - ) - } - expandedContent={ -
-
-
-
Quality
-
-
- Error rate - 5 ? "text-[var(--accent-red)]" : "text-[var(--accent-green)]" - } - > - {errorRate.toFixed(1)}% - -
-
- Cache rate - - {(model.cacheRate * 100).toFixed(1)}% - -
-
-
-
-
Latency
-
-
- Avg duration - - {model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"} - -
-
- Avg TTFT - - {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"} - -
-
-
-
-
- {trendData.length === 0 ? ( - - ) : ( - - )} -
-
- } - /> - ); - })} -
-
- ); -} - -function PerformanceChart({ - data, - color, - chartTheme, -}: { - data: Array<{ timestamp: number; avgTtftSeconds: number | null; avgTokensPerSecond: number | null }>; - color: string; - chartTheme: TableChartTheme; -}) { - const chartData = { - labels: data.map(d => format(new Date(d.timestamp), "MMM d")), - datasets: [ - { - label: "TTFT", - data: data.map(d => d.avgTtftSeconds ?? null), - ...lineSeriesStyle("#fbbf24"), - yAxisID: "y" as const, - }, - { - label: "Tokens/s", - data: data.map(d => d.avgTokensPerSecond ?? null), - ...lineSeriesStyle(color), - yAxisID: "y1" as const, - }, - ], - }; - - const options = { - responsive: true, - maintainAspectRatio: false, - plugins: detailChartPlugins(chartTheme), - scales: detailChartScalesDualAxis(chartTheme), - }; - - return ; -} - -function buildModelPerformanceLookup( - points: ModelPerformancePoint[], - bucketCount: number, - bucketMs: number, -): Map { - const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0); - const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / bucketMs) * bucketMs; - const uniqueTimestamps = new Set(points.map(p => p.timestamp)); - const effectiveCount = bucketCount > 0 ? bucketCount : Math.max(1, uniqueTimestamps.size); - const start = anchor - (effectiveCount - 1) * bucketMs; - const buckets = Array.from({ length: effectiveCount }, (_, index) => start + index * bucketMs); - const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index])); - const seriesByKey = new Map(); - - for (const point of points) { - const key = `${point.model}::${point.provider}`; - let series = seriesByKey.get(key); - if (!series) { - series = { - label: `${point.model} (${point.provider})`, - data: buckets.map(timestamp => ({ - timestamp, - avgTtftSeconds: null, - avgTokensPerSecond: null, - requests: 0, - })), - }; - seriesByKey.set(key, series); - } - - const index = bucketIndex.get(point.timestamp); - if (index === undefined) continue; - - series.data[index] = { - timestamp: point.timestamp, - avgTtftSeconds: point.avgTtft !== null ? point.avgTtft / 1000 : null, - avgTokensPerSecond: point.avgTokensPerSecond, - requests: point.requests, - }; - } - - return seriesByKey; -} diff --git a/packages/stats/src/client/components/RequestDetail.tsx b/packages/stats/src/client/components/RequestDetail.tsx deleted file mode 100644 index 4bb5e223c..000000000 --- a/packages/stats/src/client/components/RequestDetail.tsx +++ /dev/null @@ -1,172 +0,0 @@ -import { Clock, Coins, FileJson, Gauge, Hash, Star, X, Zap } from "lucide-react"; -import { useEffect, useState } from "react"; -import { getRequestDetails } from "../api"; -import type { RequestDetails } from "../types"; - -interface RequestDetailProps { - id: number; - onClose: () => void; -} - -export function RequestDetail({ id, onClose }: RequestDetailProps) { - const [details, setDetails] = useState(null); - const [loading, setLoading] = useState(true); - - useEffect(() => { - getRequestDetails(id) - .then(setDetails) - .catch(console.error) - .finally(() => setLoading(false)); - }, [id]); - - if (!details && loading) { - return ( -
-
-
-
- Loading... -
-
-
- ); - } - - if (!details) return null; - - return ( -
-
e.stopPropagation()} - > - {/* Header */} -
-
-
- -
-

Request Details

-
- -
- -
- {/* Model Info */} -
-
-
-
{details.model}
-
{details.provider}
-
- {details.errorMessage ? ( - Error - ) : ( - Success - )} -
-
- - {/* Stats Grid */} -
-
-
- - Cost -
-
- ${details.usage.cost.total.toFixed(4)} -
-
- -
-
- - Premium Reqs -
-
- {(details.usage.premiumRequests ?? 0).toLocaleString()} -
-
-
-
- - Tokens -
-
- {details.usage.totalTokens.toLocaleString()} -
-
- {details.usage.input.toLocaleString()} in · {details.usage.output.toLocaleString()} out -
-
- -
-
- - Duration -
-
- {details.duration ? `${(details.duration / 1000).toFixed(2)}s` : "-"} -
-
- -
-
- - TTFT -
-
- {details.ttft ? `${(details.ttft / 1000).toFixed(2)}s` : "-"} -
-
-
- - {/* Tokens/Sec */} - {details.duration && details.usage.output > 0 && ( -
-
-
- - Throughput -
- - {((details.usage.output * 1000) / details.duration).toFixed(1)} - -
-
tokens/second
-
- )} - - {/* Output */} -
-

Output

-
-							{JSON.stringify(details.output, null, 2)}
-						
-
- - {/* Raw Metadata */} -
-

Raw Metadata

-
-							{JSON.stringify(details, null, 2)}
-						
-
-
-
-
- ); -} diff --git a/packages/stats/src/client/components/RequestList.tsx b/packages/stats/src/client/components/RequestList.tsx deleted file mode 100644 index 7c0ee6fdf..000000000 --- a/packages/stats/src/client/components/RequestList.tsx +++ /dev/null @@ -1,73 +0,0 @@ -import { formatDistanceToNow } from "date-fns"; -import { CheckCircle2, XCircle } from "lucide-react"; -import type { MessageStats } from "../types"; - -interface RequestListProps { - requests: MessageStats[]; - onSelect: (req: MessageStats) => void; - title: string; -} - -export function RequestList({ requests, onSelect, title }: RequestListProps) { - return ( -
-
-

{title}

-
-
- - - - - - - - - - - - - {requests.map(req => ( - onSelect(req)} - className="table-row cursor-pointer border-b border-[var(--border-subtle)] last:border-b-0" - > - - - - - - - - ))} - {requests.length === 0 && ( - - - - )} - -
ModelTimeTokensCostDurationStatus
-
{req.model}
-
{req.provider}
-
- {formatDistanceToNow(req.timestamp, { addSuffix: true })} - - {req.usage.totalTokens.toLocaleString()} - - ${req.usage.cost.total.toFixed(4)} - - {req.duration ? `${(req.duration / 1000).toFixed(1)}s` : "-"} - - {req.errorMessage ? ( - - ) : ( - - )} -
- No requests found -
-
-
- ); -} diff --git a/packages/stats/src/client/components/StatsGrid.tsx b/packages/stats/src/client/components/StatsGrid.tsx deleted file mode 100644 index 1b72af69b..000000000 --- a/packages/stats/src/client/components/StatsGrid.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { Activity, AlertCircle, BarChart3, Database, Download, Server, Star, Upload, Zap } from "lucide-react"; -import type { AggregatedStats } from "../types"; - -interface StatsGridProps { - stats: AggregatedStats; -} - -const compactNumberFormatter = new Intl.NumberFormat(undefined, { - notation: "compact", - maximumFractionDigits: 1, -}); - -function formatCompactNumber(value: number): string { - return compactNumberFormatter.format(value); -} - -function formatExactNumber(value: number): string { - return value.toLocaleString(); -} - -const totalPromptCompletionTokens = (stats: AggregatedStats) => stats.totalInputTokens + stats.totalOutputTokens; - -const statConfig = [ - { - key: "requests", - title: "Total Requests", - icon: Server, - color: "var(--accent-violet)", - getValue: (s: AggregatedStats) => s.totalRequests.toLocaleString(), - getDetail: (s: AggregatedStats) => - `${s.successfulRequests.toLocaleString()} success · ${s.failedRequests.toLocaleString()} errors`, - }, - { - key: "cost", - title: "Total Cost", - icon: Activity, - color: "var(--accent-pink)", - getValue: (s: AggregatedStats) => `$${s.totalCost.toFixed(2)}`, - getDetail: (s: AggregatedStats) => - s.totalRequests > 0 ? `$${(s.totalCost / s.totalRequests).toFixed(4)} avg/req` : "-", - }, - { - key: "premiumRequests", - title: "Premium Reqs", - icon: Star, - color: "var(--accent-amber)", - getValue: (s: AggregatedStats) => formatExactNumber(s.totalPremiumRequests), - getDetail: (s: AggregatedStats) => - s.totalRequests > 0 ? `${((s.totalPremiumRequests / s.totalRequests) * 100).toFixed(1)}% of requests` : "-", - }, - { - key: "cache", - title: "Cache Rate", - icon: Database, - color: "var(--accent-cyan)", - getValue: (s: AggregatedStats) => `${(s.cacheRate * 100).toFixed(1)}%`, - getDetail: (s: AggregatedStats) => `${formatCompactNumber(s.totalCacheReadTokens)} cached tokens`, - }, - { - key: "inputTokens", - title: "Input Tokens", - icon: Download, - color: "var(--accent-violet)", - getValue: (s: AggregatedStats) => formatExactNumber(s.totalInputTokens), - getDetail: (s: AggregatedStats) => - totalPromptCompletionTokens(s) > 0 - ? `${((s.totalInputTokens / totalPromptCompletionTokens(s)) * 100).toFixed(1)}% of prompt+completion` - : "-", - }, - { - key: "outputTokens", - title: "Output Tokens", - icon: Upload, - color: "var(--accent-pink)", - getValue: (s: AggregatedStats) => formatExactNumber(s.totalOutputTokens), - getDetail: (s: AggregatedStats) => - totalPromptCompletionTokens(s) > 0 - ? `${((s.totalOutputTokens / totalPromptCompletionTokens(s)) * 100).toFixed(1)}% of prompt+completion` - : "-", - }, - { - key: "errors", - title: "Error Rate", - icon: AlertCircle, - color: "var(--accent-red)", - getValue: (s: AggregatedStats) => `${(s.errorRate * 100).toFixed(1)}%`, - getDetail: (s: AggregatedStats) => `${s.failedRequests.toLocaleString()} failed requests`, - }, - { - key: "tokens", - title: "Tokens/Sec", - icon: BarChart3, - color: "var(--accent-green)", - getValue: (s: AggregatedStats) => s.avgTokensPerSecond?.toFixed(1) ?? "-", - getDetail: (s: AggregatedStats) => - `${formatCompactNumber(totalPromptCompletionTokens(s))} total prompt+completion`, - }, - { - key: "ttft", - title: "TTFT", - icon: Zap, - color: "var(--accent-amber)", - getValue: (s: AggregatedStats) => (s.avgTtft ? `${(s.avgTtft / 1000).toFixed(2)}s` : "-"), - getDetail: () => "Time to first token", - }, -]; - -export function StatsGrid({ stats }: StatsGridProps) { - return ( -
- {statConfig.map(stat => { - const Icon = stat.icon; - return ( -
-
- {stat.title} -
- -
-
-
{stat.getValue(stats)}
-
{stat.getDetail(stats)}
-
- ); - })} -
- ); -} diff --git a/packages/stats/src/client/components/chart-shared.tsx b/packages/stats/src/client/components/chart-shared.tsx index 869b02f51..b59b75963 100644 --- a/packages/stats/src/client/components/chart-shared.tsx +++ b/packages/stats/src/client/components/chart-shared.tsx @@ -1,40 +1,43 @@ /** - * Shared chart primitives for the dashboard timeline charts (BehaviorChart, - * CostChart). Each chart owns its data shape and metric labels — this module - * owns the layout, theme, legend/tooltip plumbing, and the top-N-by-model - * bucketing scaffold that's identical between cost and behavior series. + * Shared chart primitives for the dashboard timeline charts: the OMP color + * palette, light/dark chart chrome, legend/tooltip + scale plumbing, dataset + * styling, and the top-N-by-model / aggregate bucketing used by the cost and + * behavior series. */ import { format } from "date-fns"; +// OMP brand palette (packages/collab-web/src/styles/tokens.css): pink/purple/cyan. +// Categorical series lead with the brand gradient hues (pink -> purple -> cyan). export const MODEL_COLORS = [ - "#a78bfa", // violet - "#22d3ee", // cyan - "#ec4899", // pink - "#4ade80", // green - "#fbbf24", // amber - "#f87171", // red - "#60a5fa", // blue + "#ed4abf", // brand pink (accent) + "#9b4dff", // brand violet + "#5ad8e6", // brand cyan + "#62d394", // green + "#c77dff", // light purple + "#ff8fd1", // light pink + "#f5c14b", // amber + "#ff6b7d", // rose ]; export const CHART_THEMES = { dark: { - legendLabel: "#94a3b8", - tooltipBackground: "#16161e", - tooltipTitle: "#f8fafc", - tooltipBody: "#94a3b8", - tooltipBorder: "rgba(255, 255, 255, 0.1)", + legendLabel: "#a89fb3", + tooltipBackground: "#241a2e", + tooltipTitle: "#eae5ef", + tooltipBody: "#a89fb3", + tooltipBorder: "rgba(255, 255, 255, 0.12)", grid: "rgba(255, 255, 255, 0.06)", - tick: "#64748b", + tick: "#867a93", }, light: { - legendLabel: "#475569", + legendLabel: "#5a5462", tooltipBackground: "#ffffff", - tooltipTitle: "#0f172a", - tooltipBody: "#334155", - tooltipBorder: "rgba(15, 23, 42, 0.18)", - grid: "rgba(15, 23, 42, 0.08)", - tick: "#64748b", + tooltipTitle: "#241a2e", + tooltipBody: "#5a5462", + tooltipBorder: "rgba(20, 12, 28, 0.15)", + grid: "rgba(20, 12, 28, 0.08)", + tick: "#6a6275", }, } as const; @@ -128,7 +131,8 @@ export function barDatasetStyle(color: string) { backgroundColor: color, borderColor: color, borderWidth: 0, - borderRadius: 3, + borderRadius: 4, + maxBarThickness: 56, }; } @@ -251,70 +255,3 @@ export function buildTopNByModelSeries( })), }; } - -/** All Models / By Model segmented toggle — identical UI in every time chart. */ -function ByModelToggle({ byModel, onChange }: { byModel: boolean; onChange: (v: boolean) => void }) { - return ( -
- - -
- ); -} - -/** - * Outer surface card used by both time charts. `controls` slot covers - * chart-specific tabs (e.g. behavior metric picker); the by-model toggle and - * empty-state are part of the frame so callers don't redeclare them. - */ -export function ChartFrame({ - title, - subtitle, - empty, - emptyMessage, - controls, - byModel, - onByModelChange, - children, -}: { - title: string; - subtitle: string; - empty: boolean; - emptyMessage: string; - controls?: React.ReactNode; - byModel: boolean; - onByModelChange: (v: boolean) => void; - children: React.ReactNode; -}) { - return ( -
-
-
-

{title}

-

{subtitle}

-
-
- {controls} - -
-
-
- {empty ? ( -
- {emptyMessage} -
- ) : ( -
{children}
- )} -
-
- ); -} diff --git a/packages/stats/src/client/components/models-table-shared.tsx b/packages/stats/src/client/components/models-table-shared.tsx index 8c6dd60de..a5a0e8e43 100644 --- a/packages/stats/src/client/components/models-table-shared.tsx +++ b/packages/stats/src/client/components/models-table-shared.tsx @@ -1,39 +1,19 @@ /** - * Shared primitives for the per-model breakdown tables (ModelsTable, - * BehaviorModelsTable). Each table still owns its column definitions, sort - * order, sidebar contents and chart type — this module owns the surface - * chrome, expand-row plumbing, theme palette, and the mini-sparkline plus + * Shared primitives for the per-model breakdown tables. Each table owns its + * column definitions, sort order, and chart type — this module owns the + * surface chrome, expand-row plumbing, theme palette, the mini-sparkline, and * the shared plugin/scale config consumed by multi-line detail charts. */ import { format } from "date-fns"; import { ChevronDown, ChevronUp } from "lucide-react"; import { Line } from "react-chartjs-2"; +import type { ChartTheme } from "./chart-shared"; -export { MODEL_COLORS } from "./chart-shared"; - -export const TABLE_CHART_THEMES = { - dark: { - legendLabel: "#cbd5e1", - tooltipBackground: "#16161e", - tooltipTitle: "#f8fafc", - tooltipBody: "#94a3b8", - tooltipBorder: "rgba(255, 255, 255, 0.1)", - grid: "rgba(255, 255, 255, 0.06)", - tick: "#94a3b8", - }, - light: { - legendLabel: "#334155", - tooltipBackground: "#ffffff", - tooltipTitle: "#0f172a", - tooltipBody: "#334155", - tooltipBorder: "rgba(15, 23, 42, 0.18)", - grid: "rgba(15, 23, 42, 0.08)", - tick: "#475569", - }, -} as const; - -export type TableChartTheme = (typeof TABLE_CHART_THEMES)[keyof typeof TABLE_CHART_THEMES]; +// Detail-table charts share the exact OMP chart chrome as the timeline charts; +// re-export rather than duplicate so the palette has a single source of truth. +export { CHART_THEMES as TABLE_CHART_THEMES, MODEL_COLORS } from "./chart-shared"; +export type TableChartTheme = ChartTheme; /** Style defaults for one line in a non-stacked detail chart. */ export function lineSeriesStyle(color: string) { @@ -167,7 +147,7 @@ export function ModelTableShell({ children: React.ReactNode; }) { return ( -
+

{title}

{subtitle ?

{subtitle}

: null} diff --git a/packages/stats/src/client/components/range-meta.ts b/packages/stats/src/client/components/range-meta.ts index dcc05dbcc..935e1f377 100644 --- a/packages/stats/src/client/components/range-meta.ts +++ b/packages/stats/src/client/components/range-meta.ts @@ -9,6 +9,7 @@ import type { TimeRange } from "../types"; const HOUR_MS = 60 * 60 * 1000; const DAY_MS = 24 * HOUR_MS; +const FIVE_MIN_MS = 5 * 60 * 1000; export interface RangeMeta { /** Human label used in chart subtitles ("the last 24 hours"). */ @@ -27,8 +28,8 @@ const RANGE_META: Record = { "1h": { windowLabel: "the last hour", trendLabel: "1h Trend", - bucketMs: HOUR_MS, - bucketCount: 1, + bucketMs: FIVE_MIN_MS, + bucketCount: 12, tickFormat: "HH:mm", }, "24h": { diff --git a/packages/stats/src/client/data/charts.ts b/packages/stats/src/client/data/charts.ts new file mode 100644 index 000000000..5e499a096 --- /dev/null +++ b/packages/stats/src/client/data/charts.ts @@ -0,0 +1,14 @@ +import { + BarElement, + CategoryScale, + Chart as ChartJS, + Filler, + Legend, + LinearScale, + LineElement, + PointElement, + Title, + Tooltip, +} from "chart.js"; + +ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler); diff --git a/packages/stats/src/client/data/formatters.ts b/packages/stats/src/client/data/formatters.ts new file mode 100644 index 000000000..d2b9c1478 --- /dev/null +++ b/packages/stats/src/client/data/formatters.ts @@ -0,0 +1,38 @@ +import { formatDistanceToNow } from "date-fns"; + +export function formatInteger(value: number): string { + return value.toLocaleString(); +} + +export function formatCompact(value: number): string { + return value.toLocaleString(undefined, { notation: "compact" }); +} + +export function formatCost(value: number, digits?: number): string { + if (value === 0) return "$0"; + const fractionDigits = digits !== undefined ? digits : value > 0 && value < 0.01 ? 4 : 2; + return `$${value.toLocaleString(undefined, { + minimumFractionDigits: fractionDigits, + maximumFractionDigits: fractionDigits, + })}`; +} + +export function formatPercent(value: number, digits = 1): string { + return `${(value * 100).toFixed(digits)}%`; +} + +export function formatDurationMs(value: number | null, digits?: number): string { + if (value === null) return "-"; + const sec = value / 1000; + const d = digits !== undefined ? digits : sec < 1 ? 2 : 1; + return `${sec.toFixed(d)}s`; +} + +export function formatTokensPerSecond(value: number | null): string { + if (value === null) return "-"; + return value.toFixed(1); +} + +export function formatRelativeTime(timestamp: number): string { + return formatDistanceToNow(new Date(timestamp), { addSuffix: true }); +} diff --git a/packages/stats/src/client/data/useHashRoute.ts b/packages/stats/src/client/data/useHashRoute.ts new file mode 100644 index 000000000..ec451b70b --- /dev/null +++ b/packages/stats/src/client/data/useHashRoute.ts @@ -0,0 +1,85 @@ +import { useCallback, useEffect, useState } from "react"; +import type { DashboardSection } from "../app/routes"; +import type { TimeRange } from "../types"; + +const VALID_SECTIONS: DashboardSection[] = [ + "overview", + "requests", + "errors", + "models", + "costs", + "behavior", + "projects", +]; + +const VALID_RANGES: TimeRange[] = ["1h", "24h", "7d", "30d", "90d", "all"]; + +function parseHash(hash: string): { section: DashboardSection; range: TimeRange } { + const cleanHash = hash.replace(/^#\/?/, ""); + const [pathPart, queryPart] = cleanHash.split("?"); + + const section: DashboardSection = (VALID_SECTIONS as string[]).includes(pathPart) + ? (pathPart as DashboardSection) + : "overview"; + + let range: TimeRange = "24h"; + if (queryPart) { + const params = new URLSearchParams(queryPart); + const rangeParam = params.get("range") as TimeRange; + if (VALID_RANGES.includes(rangeParam)) { + range = rangeParam; + } + } + + return { section, range }; +} + +export function useHashRoute() { + const [route, setRouteState] = useState(() => parseHash(window.location.hash)); + + useEffect(() => { + const handleHashChange = () => { + setRouteState(parseHash(window.location.hash)); + }; + + window.addEventListener("hashchange", handleHashChange); + return () => { + window.removeEventListener("hashchange", handleHashChange); + }; + }, []); + + const updateHash = useCallback((section: string, range: TimeRange) => { + window.location.hash = `/${section}?range=${range}`; + }, []); + + const setSection = useCallback( + (newSection: DashboardSection) => { + updateHash(newSection, route.range); + }, + [route.range, updateHash], + ); + + const setRange = useCallback( + (newRange: string) => { + const nextRange = VALID_RANGES.includes(newRange as TimeRange) ? (newRange as TimeRange) : "24h"; + updateHash(route.section, nextRange); + }, + [route.section, updateHash], + ); + + useEffect(() => { + const currentHash = window.location.hash; + const parsed = parseHash(currentHash); + const expectedHash = `#/${parsed.section}?range=${parsed.range}`; + if (currentHash !== expectedHash) { + window.location.hash = `/${parsed.section}?range=${parsed.range}`; + } + }, []); + + return { + section: route.section, + setSection, + range: route.range, + setRange, + }; +} diff --git a/packages/stats/src/client/data/useResource.ts b/packages/stats/src/client/data/useResource.ts new file mode 100644 index 000000000..d6653e9e2 --- /dev/null +++ b/packages/stats/src/client/data/useResource.ts @@ -0,0 +1,154 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +export interface ResourceResult { + data: T | null; + error: Error | null; + loading: boolean; + refreshing: boolean; + refetch: () => Promise; + updatedAt: number | null; +} + +export interface ResourceOptions { + pollMs?: number; + enabled?: boolean; +} + +// Session-scoped cache keyed by the resource key. Lets navigation between +// screens (and back to an already-visited range) render instantly from the +// last value and refresh in the background, instead of unmounting to a +// skeleton on every visit. Cleared only on full page reload. +const resourceCache = new Map(); +const RESOURCE_CACHE_LIMIT = 64; + +export function useResource( + key: readonly unknown[], + fetcher: (signal: AbortSignal) => Promise, + options?: ResourceOptions, +): ResourceResult { + const keyString = JSON.stringify(key); + + const [data, setData] = useState(() => (resourceCache.get(keyString)?.data as T | undefined) ?? null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(() => !resourceCache.has(keyString)); + const [refreshing, setRefreshing] = useState(false); + const [updatedAt, setUpdatedAt] = useState(() => resourceCache.get(keyString)?.updatedAt ?? null); + + const fetcherRef = useRef(fetcher); + fetcherRef.current = fetcher; + const keyStringRef = useRef(keyString); + keyStringRef.current = keyString; + + const enabled = options?.enabled ?? true; + const pollMs = options?.pollMs; + + const controllerRef = useRef(null); + + // Track whether we already hold data so a key change refreshes in the + // background — keeping the prior view mounted so charts animate to the new + // data instead of flashing a skeleton. + const hasDataRef = useRef(false); + hasDataRef.current = data !== null; + + const executeFetch = useCallback(async (isBackground: boolean) => { + if (controllerRef.current) { + controllerRef.current.abort(); + } + + const controller = new AbortController(); + controllerRef.current = controller; + + if (isBackground) { + setRefreshing(true); + } else { + setLoading(true); + setData(null); + } + setError(null); + + try { + const result = await fetcherRef.current(controller.signal); + if (controller.signal.aborted) { + return; + } + resourceCache.set(keyStringRef.current, { data: result, updatedAt: Date.now() }); + if (resourceCache.size > RESOURCE_CACHE_LIMIT) { + const oldestKey = resourceCache.keys().next().value; + if (oldestKey !== undefined) resourceCache.delete(oldestKey); + } + setData(result); + setUpdatedAt(Date.now()); + setError(null); + } catch (err) { + if (controller.signal.aborted) { + return; + } + setError(err instanceof Error ? err : new Error(String(err))); + } finally { + if (!controller.signal.aborted) { + setLoading(false); + setRefreshing(false); + if (controllerRef.current === controller) { + controllerRef.current = null; + } + } + } + }, []); + + useEffect(() => { + if (!enabled) { + setLoading(false); + setRefreshing(false); + return; + } + + const cached = resourceCache.get(keyString); + if (cached) { + // Show the cached value immediately, then revalidate in the background. + setData(cached.data as T); + setUpdatedAt(cached.updatedAt); + setLoading(false); + executeFetch(true); + } else { + // No cache: keep any stale data (range morph) or show a skeleton (first load). + executeFetch(hasDataRef.current); + } + + return () => { + if (controllerRef.current) { + controllerRef.current.abort(); + controllerRef.current = null; + } + }; + }, [keyString, enabled, executeFetch]); + + useEffect(() => { + if (!enabled || !pollMs) { + return; + } + + const interval = setInterval(() => { + if (document.hidden) { + return; + } + void executeFetch(true); + }, pollMs); + + return () => { + clearInterval(interval); + }; + }, [enabled, pollMs, executeFetch]); + + const refetch = useCallback(async () => { + await executeFetch(true); + }, [executeFetch]); + + return { + data, + error, + loading, + refreshing, + refetch, + updatedAt, + }; +} diff --git a/packages/stats/src/client/data/view-models.ts b/packages/stats/src/client/data/view-models.ts new file mode 100644 index 000000000..dfd7210d0 --- /dev/null +++ b/packages/stats/src/client/data/view-models.ts @@ -0,0 +1,178 @@ +import { rangeMeta } from "../components/range-meta"; +import type { + BehaviorOverallStats, + BehaviorTimeSeriesPoint, + CostTimeSeriesPoint, + FolderStats, + ModelPerformancePoint, + TimeRange, +} from "../types"; + +export interface CostSummaryView { + totalCost: number; + avgDailyCost: number; + topModelName: string; + topModelCost: number; +} + +export interface ModelPerformanceDataPoint { + timestamp: number; + avgTtftSeconds: number | null; + avgTokensPerSecond: number | null; + requests: number; +} + +export interface ModelPerformanceSeries { + label: string; + data: ModelPerformanceDataPoint[]; +} + +export interface BehaviorSummaryView { + totalMessages: number; + totalYelling: number; + totalProfanity: number; + totalAnguish: number; + totalFrustration: number; + highestFrictionModel: { + model: string; + provider: string; + score: number; + } | null; +} + +export interface FolderRowView extends FolderStats { + costPercentage: number; + requestsPercentage: number; +} + +export function buildCostSummary(costSeries: CostTimeSeriesPoint[]): CostSummaryView { + const totalCost = costSeries.reduce((sum, p) => sum + p.cost, 0); + const dayBuckets = new Set(costSeries.map(p => p.timestamp)).size; + const avgDailyCost = dayBuckets > 0 ? totalCost / dayBuckets : 0; + + const modelTotals = new Map(); + for (const point of costSeries) { + modelTotals.set(point.model, (modelTotals.get(point.model) ?? 0) + point.cost); + } + + let topModelName = ""; + let topModelCost = 0; + for (const [model, cost] of modelTotals) { + if (cost > topModelCost) { + topModelName = model; + topModelCost = cost; + } + } + + return { + totalCost, + avgDailyCost, + topModelName, + topModelCost, + }; +} + +export function buildModelPerformanceLookup( + points: ModelPerformancePoint[], + range: TimeRange, +): Map { + if (points.length === 0) return new Map(); + + const meta = rangeMeta(range); + const bucketMs = meta.bucketMs; + const bucketCount = meta.bucketCount; + + const buckets = + bucketCount > 0 + ? (() => { + const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0); + const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / bucketMs) * bucketMs; + const start = anchor - (bucketCount - 1) * bucketMs; + return Array.from({ length: bucketCount }, (_, index) => start + index * bucketMs); + })() + : Array.from(new Set(points.map(p => p.timestamp))).sort((a, b) => a - b); + const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index])); + const seriesByKey = new Map(); + + for (const point of points) { + const key = `${point.model}::${point.provider}`; + let series = seriesByKey.get(key); + if (!series) { + series = { + label: `${point.model} (${point.provider})`, + data: buckets.map(timestamp => ({ + timestamp, + avgTtftSeconds: null, + avgTokensPerSecond: null, + requests: 0, + })), + }; + seriesByKey.set(key, series); + } + + const index = bucketIndex.get(point.timestamp); + if (index === undefined) continue; + + series.data[index] = { + timestamp: point.timestamp, + avgTtftSeconds: point.avgTtft !== null ? point.avgTtft / 1000 : null, + avgTokensPerSecond: point.avgTokensPerSecond, + requests: point.requests, + }; + } + + return seriesByKey; +} + +export function buildBehaviorSummary( + overall: BehaviorOverallStats, + series: BehaviorTimeSeriesPoint[], +): BehaviorSummaryView { + const totalFrustration = overall.totalNegation + overall.totalRepetition + overall.totalBlame; + + const totals = new Map(); + for (const point of series) { + const key = `${point.model}::${point.provider}`; + const existing = totals.get(key); + const score = point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame; + if (existing) { + existing.score += score; + } else { + totals.set(key, { model: point.model, provider: point.provider, score }); + } + } + + let highestFrictionModel: { model: string; provider: string; score: number } | null = null; + for (const entry of totals.values()) { + if (!highestFrictionModel || entry.score > highestFrictionModel.score) { + highestFrictionModel = entry; + } + } + + return { + totalMessages: overall.totalMessages, + totalYelling: overall.totalYelling, + totalProfanity: overall.totalProfanity, + totalAnguish: overall.totalAnguish, + totalFrustration, + highestFrictionModel, + }; +} + +export function buildFolderRows(folders: FolderStats[]): FolderRowView[] { + const sorted = [...folders].sort((a, b) => { + if (b.totalCost !== a.totalCost) { + return b.totalCost - a.totalCost; + } + return b.totalRequests - a.totalRequests; + }); + + const maxCost = sorted.reduce((max, f) => Math.max(max, f.totalCost), 0); + const maxRequests = sorted.reduce((max, f) => Math.max(max, f.totalRequests), 0); + + return sorted.map(f => ({ + ...f, + costPercentage: maxCost > 0 ? (f.totalCost / maxCost) * 100 : 0, + requestsPercentage: maxRequests > 0 ? (f.totalRequests / maxRequests) * 100 : 0, + })); +} diff --git a/packages/stats/src/client/index.tsx b/packages/stats/src/client/index.tsx index a9bf06917..1d33a8816 100644 --- a/packages/stats/src/client/index.tsx +++ b/packages/stats/src/client/index.tsx @@ -1,4 +1,8 @@ import "./styles.css"; +// Side-effect import: registers all Chart.js scales/elements/plugins exactly +// once before any chart renders. Without it the first chart throws +// "category is not a registered scale" and unmounts the whole app. +import "./data/charts"; import { createRoot } from "react-dom/client"; import App from "./App"; diff --git a/packages/stats/src/client/routes/BehaviorRoute.tsx b/packages/stats/src/client/routes/BehaviorRoute.tsx new file mode 100644 index 000000000..459ba21b0 --- /dev/null +++ b/packages/stats/src/client/routes/BehaviorRoute.tsx @@ -0,0 +1,623 @@ +import { format } from "date-fns"; +import { useMemo, useState } from "react"; +import { Bar, Line } from "react-chartjs-2"; +import { getBehaviorDashboardStats } from "../api"; +import { + barDatasetStyle, + buildAggregateTimeSeries, + buildSharedPlugins, + buildSharedScales, + buildTopNByModelSeries, + CHART_THEMES, + lineDatasetStyle, + MODEL_COLORS, + styleDatasets, +} from "../components/chart-shared"; +import { + DetailChartEmpty, + detailChartPlugins, + detailChartScalesSingleAxis, + ExpandableModelRow, + lineSeriesStyle, + MiniSparkline, + ModelNameCell, + ModelTableBody, + ModelTableHeader, + ModelTableShell, + TABLE_CHART_THEMES, + type TableChartTheme, + TrendEmpty, +} from "../components/models-table-shared"; +import { formatInteger } from "../data/formatters"; +import { useResource } from "../data/useResource"; +import { buildBehaviorSummary } from "../data/view-models"; +import type { BehaviorModelStats, BehaviorOverallStats, BehaviorTimeSeriesPoint, TimeRange } from "../types"; +import { AsyncBoundary, Panel, SegmentedControl } from "../ui"; +import { useSystemTheme } from "../useSystemTheme"; + +export interface BehaviorRouteProps { + active: boolean; + range: TimeRange; + refreshTrigger: number; +} + +export function BehaviorRoute({ active, range, refreshTrigger }: BehaviorRouteProps) { + const { + data: stats, + error, + loading, + } = useResource(["behavior", range, refreshTrigger], signal => getBehaviorDashboardStats(range, signal), { + pollMs: 30000, + enabled: active, + }); + + return ( +
+ + {stats && ( + <> + + + + + )} + +
+ ); +} + +function perMsg(total: number, messages: number): string | undefined { + if (messages <= 0) return undefined; + return `${(total / messages).toFixed(2)} / msg`; +} + +function BehaviorSummaryPanel({ + overall, + behaviorSeries, +}: { + overall: BehaviorOverallStats; + behaviorSeries: BehaviorTimeSeriesPoint[]; +}) { + const summary = useMemo(() => buildBehaviorSummary(overall, behaviorSeries), [overall, behaviorSeries]); + const messages = overall.totalMessages; + + const cards = [ + { + label: "User Messages", + value: formatInteger(overall.totalMessages), + sub: messages > 0 ? "in range" : undefined, + }, + { + label: "Yelling (CAPS)", + value: formatInteger(overall.totalYelling), + sub: perMsg(overall.totalYelling, messages), + }, + { + label: "Profanity Hits", + value: formatInteger(overall.totalProfanity), + sub: perMsg(overall.totalProfanity, messages), + }, + { + label: "Anguish Signals", + value: formatInteger(overall.totalAnguish), + sub: perMsg(overall.totalAnguish, messages), + }, + { + label: "Friction Signals", + value: formatInteger(summary.totalFrustration), + sub: perMsg(summary.totalFrustration, messages), + }, + { + label: "Highest Friction Model", + value: summary.highestFrictionModel?.model ?? "—", + sub: summary.highestFrictionModel ? `${formatInteger(summary.highestFrictionModel.score)} hits` : undefined, + }, + ]; + + return ( +
+ {cards.map(card => ( + +

{card.label}

+

+ {card.value} +

+ {card.sub &&

{card.sub}

} +
+ ))} +
+ ); +} + +const METRIC_OPTIONS = [ + { value: "yelling", label: "CAPS", title: "Yelling (CAPS)" }, + { value: "profanity", label: "Profanity", title: "Profanity" }, + { value: "anguish", label: "Anguish", title: "Anguish (!!!, nooo, dude, ..)" }, + { value: "negation", label: "Negation", title: "Negation (no/nope/wrong)" }, + { + value: "repetition", + label: "Repetition", + title: "Repetition (i meant, still doesnt)", + }, + { value: "blame", label: "Blame", title: "Blame (you didnt, stop X-ing)" }, + { + value: "frustration", + label: "Frustration", + title: "Frustration (neg + rep + blame)", + }, + { value: "total", label: "All", title: "All signals combined" }, +] as const; + +type Metric = (typeof METRIC_OPTIONS)[number]["value"]; + +function formatRateAxis(value: number): string { + if (!Number.isFinite(value)) return "-"; + if (value === 0) return "0%"; + if (Math.abs(value) < 1) return `${value.toFixed(1)}%`; + return `${value.toFixed(0)}%`; +} + +function pointHits(point: BehaviorTimeSeriesPoint, metric: Metric): number { + if (metric === "frustration") { + return point.negation + point.repetition + point.blame; + } + if (metric === "total") { + return point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame; + } + return point[metric]; +} + +function ratePercent(hits: number, messages: number): number { + if (messages <= 0) return 0; + return (hits / messages) * 100; +} + +interface DailyBucket { + hits: number; + messages: number; +} + +function BehaviorChartPanel({ behaviorSeries }: { behaviorSeries: BehaviorTimeSeriesPoint[] }) { + const [byModel, setByModel] = useState(false); + const [metric, setMetric] = useState("total"); + const theme = useSystemTheme(); + const chartTheme = CHART_THEMES[theme]; + + const chartData = useMemo(() => { + if (byModel) { + return buildTopNByModelSeries(behaviorSeries, { + rankWeight: point => point.messages, + initBucket: () => ({ hits: 0, messages: 0 }), + accumulate: (bucket, point) => { + bucket.hits += pointHits(point, metric); + bucket.messages += point.messages; + }, + bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages), + }); + } + const metricLabel = METRIC_OPTIONS.find(m => m.value === metric)?.title ?? "Hits"; + return buildAggregateTimeSeries(behaviorSeries, metricLabel, { + initBucket: () => ({ hits: 0, messages: 0 }), + accumulate: (bucket, point) => { + bucket.hits += pointHits(point, metric); + bucket.messages += point.messages; + }, + bucketToValue: bucket => ratePercent(bucket.hits, bucket.messages), + }); + }, [behaviorSeries, byModel, metric]); + + const sharedPlugins = useMemo(() => { + return buildSharedPlugins({ + chartTheme, + showLegend: byModel, + defaultLabel: "Hits", + formatValue: formatRateAxis, + }); + }, [chartTheme, byModel]); + + const { sharedScaleBase, yScale } = useMemo(() => { + return buildSharedScales({ chartTheme, formatY: formatRateAxis }); + }, [chartTheme]); + + const metricLabel = useMemo(() => { + return METRIC_OPTIONS.find(m => m.value === metric)?.title ?? ""; + }, [metric]); + + const lineData = useMemo(() => { + if (!byModel) return null; + return { + labels: chartData.labels, + datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), + }; + }, [chartData, byModel]); + + const lineOptions = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index" as const, intersect: false }, + plugins: sharedPlugins, + scales: { x: sharedScaleBase, y: yScale }, + }; + }, [sharedPlugins, sharedScaleBase, yScale]); + + const barData = useMemo(() => { + if (byModel) return null; + return { + labels: chartData.labels, + datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), + }; + }, [chartData, byModel]); + + const barOptions = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index" as const, intersect: false }, + plugins: sharedPlugins, + scales: { + x: { ...sharedScaleBase, stacked: true }, + y: { ...yScale, stacked: true }, + }, + layout: { padding: { top: 8 } }, + }; + }, [sharedPlugins, sharedScaleBase, yScale]); + + const byModelOptions = [ + { value: false, label: "All Models" }, + { value: true, label: "By Model" }, + ]; + + return ( + + ({ + value: o.value, + label: o.label, + title: o.title, + }))} + value={metric} + onChange={setMetric} + /> + +
+ } + > +
+ {chartData.labels.length === 0 ? ( +
+ No friction signal data available +
+ ) : byModel && lineData ? ( + + ) : barData ? ( + + ) : null} +
+ + ); +} + +const TABLE_GRID_TEMPLATE = "2fr 0.9fr 0.8fr 0.8fr 0.8fr 0.9fr 0.8fr 140px 40px"; + +function totalHitRate(model: BehaviorModelStats): number { + if (model.totalMessages === 0) return 0; + const hits = + model.totalYelling + + model.totalProfanity + + model.totalAnguish + + model.totalNegation + + model.totalRepetition + + model.totalBlame; + return hits / model.totalMessages; +} + +function formatRate(total: number, messages: number): string { + if (messages === 0) return "-"; + const pct = (total / messages) * 100; + if (pct === 0) return "0%"; + if (pct < 1) return `${pct.toFixed(1)}%`; + return `${pct.toFixed(0)}%`; +} + +function BehaviorModelsTable({ + models, + behaviorSeries, +}: { + models: BehaviorModelStats[]; + behaviorSeries: BehaviorTimeSeriesPoint[]; +}) { + const [expandedKey, setExpandedKey] = useState(null); + const theme = useSystemTheme(); + const chartTheme = TABLE_CHART_THEMES[theme]; + + const trendByKey = useMemo(() => buildTrendLookup(behaviorSeries), [behaviorSeries]); + + const sortedModels = useMemo(() => { + return [...models].sort((a, b) => { + if (b.totalMessages !== a.totalMessages) { + return b.totalMessages - a.totalMessages; + } + return totalHitRate(b) - totalHitRate(a); + }); + }, [models]); + + return ( + + + + + {sortedModels.map((model, index) => { + const key = `${model.model}::${model.provider}`; + const trend = trendByKey.get(key)?.data ?? []; + const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; + const isExpanded = expandedKey === key; + const totalFrustration = model.totalNegation + model.totalRepetition + model.totalBlame; + const totalHits = model.totalYelling + model.totalProfanity + model.totalAnguish + totalFrustration; + + return ( + setExpandedKey(isExpanded ? null : key)} + cells={[ + , +
+ {formatInteger(model.totalMessages)} +
, +
+ {formatRate(model.totalYelling, model.totalMessages)} +
, +
+ {formatRate(model.totalProfanity, model.totalMessages)} +
, +
+ {formatRate(model.totalAnguish, model.totalMessages)} +
, +
+ {formatRate(totalFrustration, model.totalMessages)} +
, +
+ {formatRate(totalHits, model.totalMessages)} +
, + ]} + trendCell={ + trend.length === 0 ? ( + + ) : ( + d.timestamp)} + values={trend.map(d => d.total)} + color={trendColor} + /> + ) + } + expandedContent={ +
+
+ + + + + + + +
+
+ {trend.length === 0 ? ( + + ) : ( + + )} +
+
+ } + /> + ); + })} + {sortedModels.length === 0 ? ( +
+ No user behavior recorded for this range yet. +
+ ) : null} +
+
+ ); +} + +function DetailRow({ + label, + total, + messages, + valueClass, + mode = "rate", +}: { + label: string; + total: number; + messages: number; + valueClass: string; + mode?: "rate" | "average"; +}) { + const perMsgLabel = mode === "rate" ? "% of msgs" : "Per msg"; + const perMsgValue = useMemo(() => { + if (messages === 0) return "-"; + return mode === "rate" ? formatRate(total, messages) : (total / messages).toFixed(0); + }, [total, messages, mode]); + + return ( +
+
{label}
+
+
+ Total + {formatInteger(total)} +
+
+ {perMsgLabel} + {perMsgValue} +
+
+
+ ); +} + +const SERIES_COLORS = { + yelling: "#ed4abf", // brand pink + profanity: "#ff6b7d", // rose + anguish: "#9b4dff", // brand violet + frustration: "#5ad8e6", // brand cyan +} as const; + +function BreakdownChart({ data, chartTheme }: { data: DailyPoint[]; chartTheme: TableChartTheme }) { + const chartData = useMemo(() => { + return { + labels: data.map(d => format(new Date(d.timestamp), "MMM d")), + datasets: [ + { + label: "CAPS", + data: data.map(d => d.yelling), + ...lineSeriesStyle(SERIES_COLORS.yelling), + }, + { + label: "Profanity", + data: data.map(d => d.profanity), + ...lineSeriesStyle(SERIES_COLORS.profanity), + }, + { + label: "Anguish", + data: data.map(d => d.anguish), + ...lineSeriesStyle(SERIES_COLORS.anguish), + }, + { + label: "Frustration", + data: data.map(d => d.frustration), + ...lineSeriesStyle(SERIES_COLORS.frustration), + }, + ], + }; + }, [data]); + + const options = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + plugins: detailChartPlugins(chartTheme), + scales: detailChartScalesSingleAxis(chartTheme), + }; + }, [chartTheme]); + + return ; +} + +interface DailyPoint { + timestamp: number; + yelling: number; + profanity: number; + anguish: number; + frustration: number; + total: number; +} + +interface ModelTrendSeries { + data: DailyPoint[]; +} + +function buildTrendLookup(points: BehaviorTimeSeriesPoint[]): Map { + if (points.length === 0) return new Map(); + + const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b); + const byKey = new Map>(); + + for (const point of points) { + const key = `${point.model}::${point.provider}`; + let dayMap = byKey.get(key); + if (!dayMap) { + dayMap = new Map(); + byKey.set(key, dayMap); + } + const existing = dayMap.get(point.timestamp) ?? { + timestamp: point.timestamp, + yelling: 0, + profanity: 0, + anguish: 0, + frustration: 0, + total: 0, + }; + existing.yelling += point.yelling; + existing.profanity += point.profanity; + existing.anguish += point.anguish; + existing.frustration += point.negation + point.repetition + point.blame; + existing.total = existing.yelling + existing.profanity + existing.anguish + existing.frustration; + dayMap.set(point.timestamp, existing); + } + + const out = new Map(); + for (const [key, dayMap] of byKey) { + const data = allDays.map( + ts => + dayMap.get(ts) ?? { + timestamp: ts, + yelling: 0, + profanity: 0, + anguish: 0, + frustration: 0, + total: 0, + }, + ); + out.set(key, { data }); + } + return out; +} diff --git a/packages/stats/src/client/routes/CostsRoute.tsx b/packages/stats/src/client/routes/CostsRoute.tsx new file mode 100644 index 000000000..ee841e871 --- /dev/null +++ b/packages/stats/src/client/routes/CostsRoute.tsx @@ -0,0 +1,234 @@ +import type { Plugin } from "chart.js"; +import { useMemo, useState } from "react"; +import { Bar, Line } from "react-chartjs-2"; +import { getCostDashboardStats } from "../api"; +import { + barDatasetStyle, + buildAggregateTimeSeries, + buildSharedPlugins, + buildSharedScales, + buildTopNByModelSeries, + CHART_THEMES, + lineDatasetStyle, + MODEL_COLORS, + styleDatasets, +} from "../components/chart-shared"; +import { formatCost } from "../data/formatters"; +import { useResource } from "../data/useResource"; +import { buildCostSummary } from "../data/view-models"; +import type { CostTimeSeriesPoint, TimeRange } from "../types"; +import { AsyncBoundary, Panel, SegmentedControl } from "../ui"; +import { useSystemTheme } from "../useSystemTheme"; + +export interface CostsRouteProps { + active: boolean; + range: TimeRange; + refreshTrigger: number; +} + +export function CostsRoute({ active, range, refreshTrigger }: CostsRouteProps) { + const { + data: costStats, + error, + loading, + } = useResource(["costs", range, refreshTrigger], signal => getCostDashboardStats(range, signal), { + pollMs: 30000, + enabled: active, + }); + + return ( +
+ + {costStats && ( + <> + + + + )} + +
+ ); +} + +function CostOverviewPanel({ costSeries }: { costSeries: CostTimeSeriesPoint[] }) { + const summary = useMemo(() => buildCostSummary(costSeries), [costSeries]); + + const cards = [ + { label: "Total Cost", value: formatCost(summary.totalCost) }, + { label: "Average / Day", value: formatCost(summary.avgDailyCost) }, + { + label: "Top Model", + value: summary.topModelName || "—", + sub: summary.topModelName ? formatCost(summary.topModelCost) : undefined, + }, + ]; + + return ( +
+ {cards.map(card => ( + +

{card.label}

+

+ {card.value} +

+ {card.sub &&

Total spent: {card.sub}

} +
+ ))} +
+ ); +} + +const BAR_LABEL_COLORS = { + dark: "rgba(248, 250, 252, 0.7)", + light: "rgba(15, 23, 42, 0.6)", +} as const; + +// Inline Chart.js plugin to draw cost value above bars +function makeBarLabelPlugin(color: string): Plugin<"bar"> { + return { + id: "costBarLabels", + afterDatasetsDraw(chart) { + const { ctx } = chart; + const dataset = chart.data.datasets[0]; + if (!dataset) return; + const meta = chart.getDatasetMeta(0); + ctx.save(); + ctx.font = "11px system-ui, sans-serif"; + ctx.fillStyle = color; + ctx.textAlign = "center"; + ctx.textBaseline = "bottom"; + for (const bar of meta.data) { + // Accessing Chart.js internal parsed coordinates via unknown cast + const value = (bar as unknown as { $context: { parsed: { y: number } } }).$context.parsed.y; + if (!value) continue; + const label = `$${Math.round(value)}`; + // Accessing internal getProps for positioning via unknown cast + const { x, y } = bar.getProps(["x", "y"], true) as { + x: number; + y: number; + }; + ctx.fillText(label, x, y - 3); + } + ctx.restore(); + }, + }; +} + +function CostTrendPanel({ costSeries }: { costSeries: CostTimeSeriesPoint[] }) { + const [byModel, setByModel] = useState(false); + const theme = useSystemTheme(); + const chartTheme = CHART_THEMES[theme]; + + const chartData = useMemo(() => { + if (byModel) { + return buildTopNByModelSeries(costSeries, { + rankWeight: point => point.cost, + initBucket: () => ({ total: 0 }), + accumulate: (bucket, point) => { + bucket.total += point.cost; + }, + bucketToValue: bucket => bucket.total, + }); + } + return buildAggregateTimeSeries(costSeries, "Cost", { + initBucket: () => ({ total: 0 }), + accumulate: (bucket, point) => { + bucket.total += point.cost; + }, + bucketToValue: bucket => bucket.total, + }); + }, [costSeries, byModel]); + + const sharedPlugins = useMemo(() => { + return buildSharedPlugins({ + chartTheme, + showLegend: byModel, + defaultLabel: "Cost", + formatValue: v => `$${v.toFixed(2)}`, + footer: items => { + if (!byModel || items.length < 2) return undefined; + const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0); + return `Total: $${total.toFixed(2)}`; + }, + }); + }, [chartTheme, byModel]); + + const { sharedScaleBase, yScale } = useMemo(() => { + return buildSharedScales({ + chartTheme, + formatY: v => `$${Math.round(v)}`, + }); + }, [chartTheme]); + + const barLabelPlugin = useMemo(() => { + return makeBarLabelPlugin(BAR_LABEL_COLORS[theme]); + }, [theme]); + + const lineData = useMemo(() => { + if (!byModel) return null; + return { + labels: chartData.labels, + datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), + }; + }, [chartData, byModel]); + + const lineOptions = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index" as const, intersect: false }, + plugins: sharedPlugins, + scales: { x: sharedScaleBase, y: yScale }, + }; + }, [sharedPlugins, sharedScaleBase, yScale]); + + const barData = useMemo(() => { + if (byModel) return null; + return { + labels: chartData.labels, + datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])), + }; + }, [chartData, byModel]); + + const barOptions = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { mode: "index" as const, intersect: false }, + plugins: { + ...sharedPlugins, + costBarLabels: {}, + }, + scales: { + x: { ...sharedScaleBase, stacked: true }, + y: { ...yScale, stacked: true }, + }, + layout: { padding: { top: 24 } }, + }; + }, [sharedPlugins, sharedScaleBase, yScale]); + + const toggleOptions = [ + { value: false, label: "All Models" }, + { value: true, label: "By Model" }, + ]; + + return ( + } + > +
+ {chartData.labels.length === 0 ? ( +
+ No cost data available +
+ ) : byModel && lineData ? ( + + ) : barData ? ( + + ) : null} +
+
+ ); +} diff --git a/packages/stats/src/client/routes/ErrorsRoute.tsx b/packages/stats/src/client/routes/ErrorsRoute.tsx new file mode 100644 index 000000000..eb469bcca --- /dev/null +++ b/packages/stats/src/client/routes/ErrorsRoute.tsx @@ -0,0 +1,118 @@ +import { useMemo } from "react"; +import { getRecentErrors } from "../api"; +import { formatCost, formatInteger, formatRelativeTime } from "../data/formatters"; +import { useResource } from "../data/useResource"; +import type { MessageStats, TimeRange } from "../types"; +import { AsyncBoundary, DataTable, Panel, StatusPill } from "../ui"; + +export interface ErrorsRouteProps { + active: boolean; + range: TimeRange; + refreshTrigger: number; + onRequestClick: (id: number) => void; +} + +export function ErrorsRoute({ active, refreshTrigger, onRequestClick }: ErrorsRouteProps) { + const { + data: recentErrors, + error, + loading, + } = useResource(["recent-errors-dense", refreshTrigger], signal => getRecentErrors(50, signal), { + pollMs: 30000, + enabled: active, + }); + + const columns = useMemo( + () => [ + { + key: "model", + header: "Model", + render: (item: MessageStats) => ( +
+
{item.model}
+
{item.provider}
+
+ ), + }, + { + key: "timestamp", + header: "Time", + render: (item: MessageStats) => formatRelativeTime(item.timestamp), + }, + { + key: "errorMessage", + header: "Error Message", + render: (item: MessageStats) => ( +
+ {item.errorMessage || "Unknown error"} +
+ ), + }, + { + key: "tokens", + header: "Tokens", + numeric: true, + render: (item: MessageStats) => formatInteger(item.usage.totalTokens), + }, + { + key: "cost", + header: "Cost", + numeric: true, + render: (item: MessageStats) => formatCost(item.usage.cost.total, 4), + }, + ], + [], + ); + + const renderMobileCard = (item: MessageStats, onClick?: () => void) => ( +
+
+
+
{item.model}
+
{item.provider}
+
+ Failed +
+
+
+
Time
+
{formatRelativeTime(item.timestamp)}
+
+
+
Cost
+
{formatCost(item.usage.cost.total, 4)}
+
+
+
Tokens
+
{formatInteger(item.usage.totalTokens)}
+
+
+ {item.errorMessage &&
{item.errorMessage}
} +
+ ); + + return ( +
+ + + item.id || `${item.sessionFile}-${item.entryId}`} + onRowClick={item => item.id && onRequestClick(item.id)} + renderMobileCard={renderMobileCard} + emptyText="No recent failures in the local stats database" + /> + + +
+ ); +} diff --git a/packages/stats/src/client/routes/ModelsRoute.tsx b/packages/stats/src/client/routes/ModelsRoute.tsx new file mode 100644 index 000000000..26d9869c1 --- /dev/null +++ b/packages/stats/src/client/routes/ModelsRoute.tsx @@ -0,0 +1,430 @@ +import { useMemo, useState } from "react"; +import { Line } from "react-chartjs-2"; +import { getModelDashboardStats } from "../api"; +import { CHART_THEMES, MODEL_COLORS } from "../components/chart-shared"; +import { + DetailChartEmpty, + detailChartPlugins, + detailChartScalesDualAxis, + ExpandableModelRow, + lineSeriesStyle, + MiniSparkline, + ModelNameCell, + ModelTableBody, + ModelTableHeader, + ModelTableShell, + TABLE_CHART_THEMES, + type TableChartTheme, + TrendEmpty, +} from "../components/models-table-shared"; +import { formatRangeTick, rangeMeta } from "../components/range-meta"; +import { useResource } from "../data/useResource"; +import { buildModelPerformanceLookup } from "../data/view-models"; +import type { ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, TimeRange } from "../types"; +import { AsyncBoundary, Panel } from "../ui"; +import { useSystemTheme } from "../useSystemTheme"; + +export interface ModelsRouteProps { + active: boolean; + range: TimeRange; + refreshTrigger: number; +} + +export function ModelsRoute({ active, range, refreshTrigger }: ModelsRouteProps) { + const { + data: modelStats, + error, + loading, + } = useResource(["models", range, refreshTrigger], signal => getModelDashboardStats(range, signal), { + pollMs: 30000, + enabled: active, + }); + + return ( +
+ + {modelStats && ( + <> + + + + )} + +
+ ); +} + +function ModelShareChart({ modelSeries, timeRange }: { modelSeries: ModelTimeSeriesPoint[]; timeRange: TimeRange }) { + const theme = useSystemTheme(); + const chartTheme = CHART_THEMES[theme]; + const meta = rangeMeta(timeRange); + + const chartData = useMemo(() => buildModelPreferenceSeries(modelSeries), [modelSeries]); + + const data = useMemo(() => { + return { + labels: chartData.data.map(d => formatRangeTick(d.timestamp, timeRange)), + datasets: chartData.series.map((seriesName, index) => ({ + label: seriesName, + data: chartData.data.map(d => d[seriesName] ?? 0), + borderColor: MODEL_COLORS[index % MODEL_COLORS.length], + backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`, + fill: true, + tension: 0.4, + pointRadius: 0, + pointHoverRadius: 4, + borderWidth: 2, + })), + }; + }, [chartData, timeRange]); + + const options = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: "index" as const, + intersect: false, + }, + plugins: { + legend: { + position: "top" as const, + align: "start" as const, + labels: { + color: chartTheme.legendLabel, + usePointStyle: true, + padding: 16, + font: { size: 12 }, + boxWidth: 8, + }, + }, + tooltip: { + backgroundColor: chartTheme.tooltipBackground, + titleColor: chartTheme.tooltipTitle, + bodyColor: chartTheme.tooltipBody, + borderColor: chartTheme.tooltipBorder, + borderWidth: 1, + padding: 12, + cornerRadius: 8, + callbacks: { + label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => { + const label = context.dataset.label ?? ""; + const value = context.parsed.y; + return `${label}: ${(value ?? 0).toFixed(1)}%`; + }, + }, + }, + }, + scales: { + x: { + grid: { + color: chartTheme.grid, + drawBorder: false, + }, + ticks: { + color: chartTheme.tick, + font: { size: 11 }, + }, + }, + y: { + grid: { + color: chartTheme.grid, + drawBorder: false, + }, + ticks: { + color: chartTheme.tick, + font: { size: 11 }, + callback: (value: number | string) => `${value}%`, + }, + min: 0, + max: 100, + }, + }, + }; + }, [chartTheme]); + + return ( + +
+ {chartData.data.length === 0 ? ( +
No data available
+ ) : ( + + )} +
+
+ ); +} + +function buildModelPreferenceSeries( + points: ModelTimeSeriesPoint[], + topN = 5, +): { + data: Array>; + series: string[]; +} { + if (points.length === 0) return { data: [], series: [] }; + + const totals = new Map(); + for (const point of points) { + const key = `${point.model}::${point.provider}`; + const existing = totals.get(key); + if (existing) { + existing.total += point.requests; + } else { + totals.set(key, { + model: point.model, + provider: point.provider, + total: point.requests, + }); + } + } + + const sorted = [...totals.entries()].map(([key, value]) => ({ key, ...value })).sort((a, b) => b.total - a.total); + const topEntries = sorted.slice(0, topN); + const topKeys = new Set(topEntries.map(entry => entry.key)); + + const topModelCounts = new Map(); + for (const entry of topEntries) { + topModelCounts.set(entry.model, (topModelCounts.get(entry.model) ?? 0) + 1); + } + + const labelByKey = new Map(); + for (const entry of topEntries) { + const showProvider = (topModelCounts.get(entry.model) ?? 0) > 1; + labelByKey.set(entry.key, showProvider ? `${entry.model} (${entry.provider})` : entry.model); + } + + const dataMap = new Map>(); + + for (const point of points) { + const key = `${point.model}::${point.provider}`; + const bucket = dataMap.get(point.timestamp) ?? { + timestamp: point.timestamp, + total: 0, + }; + bucket.total += point.requests; + const seriesLabel = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other"; + bucket[seriesLabel] = (bucket[seriesLabel] ?? 0) + point.requests; + dataMap.set(point.timestamp, bucket); + } + + const series = topEntries.map(entry => labelByKey.get(entry.key) ?? entry.model); + if ([...dataMap.values()].some(row => (row.Other ?? 0) > 0)) { + series.push("Other"); + } + + const data = [...dataMap.values()] + .sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0)) + .map(row => { + const total = row.total ?? 0; + for (const key of series) { + row[key] = total > 0 ? ((row[key] ?? 0) / total) * 100 : 0; + } + return row; + }); + + return { data, series }; +} + +const GRID_TEMPLATE = "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px"; + +function ModelsTable({ + models, + performanceSeries, + timeRange, +}: { + models: ModelStats[]; + performanceSeries: ModelPerformancePoint[]; + timeRange: TimeRange; +}) { + const [expandedKey, setExpandedKey] = useState(null); + const meta = rangeMeta(timeRange); + + const performanceSeriesByKey = useMemo( + () => buildModelPerformanceLookup(performanceSeries, timeRange), + [performanceSeries, timeRange], + ); + + const theme = useSystemTheme(); + const chartTheme = TABLE_CHART_THEMES[theme]; + + const sortedModels = useMemo(() => { + return [...models].sort( + (a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens), + ); + }, [models]); + + return ( + + + + + {sortedModels.map((model, index) => { + const key = `${model.model}::${model.provider}`; + const performance = performanceSeriesByKey.get(key); + const trendData = performance?.data ?? []; + const trendColor = MODEL_COLORS[index % MODEL_COLORS.length]; + const isExpanded = expandedKey === key; + const errorRate = model.errorRate * 100; + + return ( + setExpandedKey(isExpanded ? null : key)} + cells={[ + , +
+ {model.totalRequests.toLocaleString()} +
, +
+ ${model.totalCost.toFixed(2)} +
, +
+ {(model.totalInputTokens + model.totalOutputTokens).toLocaleString()} +
, +
+ {model.avgTokensPerSecond?.toFixed(1) ?? "-"} +
, +
+ {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"} +
, + ]} + trendCell={ + trendData.length === 0 ? ( + + ) : ( + d.timestamp)} + values={trendData.map(d => d.avgTokensPerSecond ?? 0)} + color={trendColor} + /> + ) + } + expandedContent={ +
+
+
+
Quality
+
+
+ Error rate + 5 ? "text-[var(--accent-red)]" : "text-[var(--accent-green)]" + } + > + {errorRate.toFixed(1)}% + +
+
+ Cache rate + + {(model.cacheRate * 100).toFixed(1)}% + +
+
+
+
+
Latency
+
+
+ Avg duration + + {model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"} + +
+
+ Avg TTFT + + {model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"} + +
+
+
+
+
+ {trendData.length === 0 ? ( + + ) : ( + + )} +
+
+ } + /> + ); + })} +
+
+ ); +} + +function PerformanceChart({ + data, + color, + chartTheme, + timeRange, +}: { + data: Array<{ + timestamp: number; + avgTtftSeconds: number | null; + avgTokensPerSecond: number | null; + }>; + color: string; + chartTheme: TableChartTheme; + timeRange: TimeRange; +}) { + const chartData = useMemo(() => { + return { + labels: data.map(d => formatRangeTick(d.timestamp, timeRange)), + datasets: [ + { + label: "TTFT", + data: data.map(d => d.avgTtftSeconds ?? null), + ...lineSeriesStyle("#5ad8e6"), + yAxisID: "y" as const, + }, + { + label: "Tokens/s", + data: data.map(d => d.avgTokensPerSecond ?? null), + ...lineSeriesStyle(color), + yAxisID: "y1" as const, + }, + ], + }; + }, [data, color, timeRange]); + + const options = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + plugins: detailChartPlugins(chartTheme), + scales: detailChartScalesDualAxis(chartTheme), + }; + }, [chartTheme]); + + return ; +} diff --git a/packages/stats/src/client/routes/OverviewRoute.tsx b/packages/stats/src/client/routes/OverviewRoute.tsx new file mode 100644 index 000000000..a449b8449 --- /dev/null +++ b/packages/stats/src/client/routes/OverviewRoute.tsx @@ -0,0 +1,332 @@ +import { format } from "date-fns"; +import { useMemo } from "react"; +import { Line } from "react-chartjs-2"; +import { getOverviewStats, getRecentRequests } from "../api"; +import { CHART_THEMES } from "../components/chart-shared"; +import { formatCost, formatDurationMs, formatInteger, formatRelativeTime } from "../data/formatters"; +import { useResource } from "../data/useResource"; +import type { MessageStats, TimeRange } from "../types"; +import { AsyncBoundary, DataTable, MetricCluster, Panel, Skeleton, StatusPill } from "../ui"; +import { useSystemTheme } from "../useSystemTheme"; + +export interface OverviewRouteProps { + active: boolean; + range: TimeRange; + refreshTrigger: number; + onRequestClick: (id: number) => void; +} + +export function OverviewRoute({ active, range, refreshTrigger, onRequestClick }: OverviewRouteProps) { + const { + data: overview, + error: overviewError, + loading: overviewLoading, + } = useResource(["overview", range, refreshTrigger], signal => getOverviewStats(range, signal), { + pollMs: 30000, + enabled: active, + }); + + const { + data: recentRequests, + error: requestsError, + loading: requestsLoading, + } = useResource(["recent-requests", refreshTrigger], signal => getRecentRequests(50, signal), { + pollMs: 30000, + enabled: active, + }); + + const theme = useSystemTheme(); + const chartTheme = CHART_THEMES[theme]; + + const chartData = useMemo(() => { + if (!overview?.timeSeries) return { labels: [], datasets: [] }; + const labels = overview.timeSeries.map(pt => + format(new Date(pt.timestamp), range === "1h" || range === "24h" ? "HH:mm" : "MMM d"), + ); + // Show point markers when the series is sparse (e.g. a quiet 1h window) + // so a 1-2 point line is still visible instead of an empty plot. + const pointRadius = overview.timeSeries.length <= 2 ? 3 : 0; + return { + labels, + datasets: [ + { + label: "Requests", + data: overview.timeSeries.map(pt => pt.requests), + borderColor: "#5ad8e6", + backgroundColor: "rgba(90, 216, 230, 0.12)", + tension: 0.2, + borderWidth: 2, + pointRadius, + pointHoverRadius: 4, + fill: true, + }, + { + label: "Errors", + data: overview.timeSeries.map(pt => pt.errors), + borderColor: "#ff6b7d", + backgroundColor: "rgba(255, 107, 125, 0.12)", + tension: 0.2, + borderWidth: 2, + pointRadius, + pointHoverRadius: 4, + fill: true, + }, + ], + }; + }, [overview?.timeSeries, range]); + + const chartOptions = useMemo(() => { + return { + responsive: true, + maintainAspectRatio: false, + interaction: { + mode: "index" as const, + intersect: false, + }, + plugins: { + legend: { + display: true, + position: "top" as const, + align: "end" as const, + labels: { + color: chartTheme.legendLabel, + boxWidth: 8, + usePointStyle: true, + font: { size: 11 }, + }, + }, + tooltip: { + backgroundColor: chartTheme.tooltipBackground, + titleColor: chartTheme.tooltipTitle, + bodyColor: chartTheme.tooltipBody, + borderColor: chartTheme.tooltipBorder, + borderWidth: 1, + cornerRadius: 8, + padding: 10, + }, + }, + scales: { + x: { + grid: { + color: chartTheme.grid, + drawBorder: false, + }, + ticks: { + color: chartTheme.tick, + font: { size: 10 }, + }, + }, + y: { + grid: { + color: chartTheme.grid, + drawBorder: false, + }, + ticks: { + color: chartTheme.tick, + font: { size: 10 }, + }, + min: 0, + }, + }, + }; + }, [chartTheme]); + + const columns = useMemo( + () => [ + { + key: "model", + header: "Model", + render: (item: MessageStats) => ( +
+
{item.model}
+
{item.provider}
+
+ ), + }, + { + key: "timestamp", + header: "Time", + render: (item: MessageStats) => formatRelativeTime(item.timestamp), + }, + { + key: "tokens", + header: "Tokens", + numeric: true, + render: (item: MessageStats) => formatInteger(item.usage.totalTokens), + }, + { + key: "cost", + header: "Cost", + numeric: true, + render: (item: MessageStats) => formatCost(item.usage.cost.total, 4), + }, + { + key: "duration", + header: "Duration", + numeric: true, + render: (item: MessageStats) => formatDurationMs(item.duration), + }, + { + key: "status", + header: "Status", + className: "stats-text-center", + render: (item: MessageStats) => ( + + {item.errorMessage ? "Failed" : "Success"} + + ), + }, + ], + [], + ); + + const renderMobileCard = (item: MessageStats, onClick?: () => void) => ( +
+
+
+
{item.model}
+
{item.provider}
+
+ + {item.errorMessage ? "Failed" : "Success"} + +
+
+
+
Time
+
{formatRelativeTime(item.timestamp)}
+
+
+
Cost
+
{formatCost(item.usage.cost.total, 4)}
+
+
+
Tokens
+
{formatInteger(item.usage.totalTokens)}
+
+
+
Duration
+
{formatDurationMs(item.duration)}
+
+
+ {item.errorMessage &&
{item.errorMessage}
} +
+ ); + + const previewRequests = useMemo(() => { + if (!recentRequests) return []; + return recentRequests.slice(0, 10); + }, [recentRequests]); + + return ( +
+ + {overview && } + + +
+
+ + +
+ {overview?.timeSeries && overview.timeSeries.length > 0 ? ( + + ) : ( +
+ No time-series data available +
+ )} +
+
+
+
+ +
+ + + {Array.from({ length: 5 }).map((_, i) => ( +
+ +
+ + +
+
+ ))} +
+ } + > +
+ {previewRequests.map(req => { + const isError = !!req.errorMessage; + return ( +
req.id && onRequestClick(req.id)} + > +
+
+
+
+ {req.model} +
+
+ {formatRelativeTime(req.timestamp)} +
+
+
+
{req.provider}
+
+ {req.duration ? formatDurationMs(req.duration) : ""}{" "} + {req.usage?.cost?.total ? `· ${formatCost(req.usage.cost.total, 4)}` : ""} +
+
+ {isError && ( +
{req.errorMessage}
+ )} +
+
+ ); + })} + {previewRequests.length === 0 && ( +
No recent requests found
+ )} +
+ + +
+
+ + + View All Requests + + } + > + + item.id || `${item.sessionFile}-${item.entryId}`} + onRowClick={item => item.id && onRequestClick(item.id)} + renderMobileCard={renderMobileCard} + emptyText="No recent requests found" + /> + + +
+ ); +} diff --git a/packages/stats/src/client/routes/ProjectsRoute.tsx b/packages/stats/src/client/routes/ProjectsRoute.tsx new file mode 100644 index 000000000..fe4e78caf --- /dev/null +++ b/packages/stats/src/client/routes/ProjectsRoute.tsx @@ -0,0 +1,163 @@ +import { useMemo } from "react"; +import { getFolderStats } from "../api"; +import { formatCost, formatDurationMs, formatInteger, formatPercent } from "../data/formatters"; +import { useResource } from "../data/useResource"; +import { buildFolderRows, type FolderRowView } from "../data/view-models"; +import type { TimeRange } from "../types"; +import { AsyncBoundary, DataTable, Panel, StatusPill } from "../ui"; + +export interface ProjectsRouteProps { + active: boolean; + range: TimeRange; + refreshTrigger: number; +} + +export function ProjectsRoute({ active, range, refreshTrigger }: ProjectsRouteProps) { + const { + data: foldersData, + error, + loading, + } = useResource(["projects", range, refreshTrigger], signal => getFolderStats(range, signal), { + pollMs: 30000, + enabled: active, + }); + + const folderRows = useMemo(() => { + if (!foldersData) return []; + return buildFolderRows(foldersData); + }, [foldersData]); + + const columns = useMemo( + () => [ + { + key: "folder", + header: "Project/Folder", + render: (item: FolderRowView) => ( +
+ {item.folder || "(root)"} +
+ ), + }, + { + key: "totalRequests", + header: "Requests", + numeric: true, + render: (item: FolderRowView) => ( +
+
{formatInteger(item.totalRequests)}
+
+
+
+
+ ), + }, + { + key: "totalCost", + header: "Cost", + numeric: true, + render: (item: FolderRowView) => ( +
+
{formatCost(item.totalCost)}
+
+
+
+
+ ), + }, + { + key: "totalTokens", + header: "Tokens", + numeric: true, + render: (item: FolderRowView) => ( +
{formatInteger(item.totalInputTokens + item.totalOutputTokens)}
+ ), + }, + { + key: "cacheRate", + header: "Cache Rate", + numeric: true, + render: (item: FolderRowView) => ( + {formatPercent(item.cacheRate)} + ), + }, + { + key: "errorRate", + header: "Error Rate", + numeric: true, + render: (item: FolderRowView) => ( + 0.1 ? "danger" : item.errorRate > 0 ? "warning" : "success"}> + {formatPercent(item.errorRate)} + + ), + }, + { + key: "avgDuration", + header: "Avg Duration", + numeric: true, + render: (item: FolderRowView) => formatDurationMs(item.avgDuration), + }, + ], + [], + ); + + const renderMobileCard = (item: FolderRowView) => ( +
+
+
{item.folder || "(root)"}
+ 0.1 ? "danger" : item.errorRate > 0 ? "warning" : "success"}> + {formatPercent(item.errorRate)} Err + +
+
+
+
Requests
+
{formatInteger(item.totalRequests)}
+
+
+
Cost
+
{formatCost(item.totalCost)}
+
+
+
Cache
+
{formatPercent(item.cacheRate)}
+
+
+
Duration
+
{formatDurationMs(item.avgDuration)}
+
+
+
+ ); + + return ( +
+ + + item.folder} + renderMobileCard={renderMobileCard} + emptyText="No project folders recorded for this range." + /> + + +
+ ); +} diff --git a/packages/stats/src/client/routes/RequestsRoute.tsx b/packages/stats/src/client/routes/RequestsRoute.tsx new file mode 100644 index 000000000..1499bdcf4 --- /dev/null +++ b/packages/stats/src/client/routes/RequestsRoute.tsx @@ -0,0 +1,123 @@ +import { useMemo } from "react"; +import { getRecentRequests } from "../api"; +import { formatCost, formatDurationMs, formatInteger, formatRelativeTime } from "../data/formatters"; +import { useResource } from "../data/useResource"; +import type { MessageStats, TimeRange } from "../types"; +import { AsyncBoundary, DataTable, Panel, StatusPill } from "../ui"; + +export interface RequestsRouteProps { + active: boolean; + range: TimeRange; + refreshTrigger: number; + onRequestClick: (id: number) => void; +} + +export function RequestsRoute({ active, refreshTrigger, onRequestClick }: RequestsRouteProps) { + const { + data: recentRequests, + error, + loading, + } = useResource(["recent-requests-dense", refreshTrigger], signal => getRecentRequests(50, signal), { + pollMs: 30000, + enabled: active, + }); + + const columns = useMemo( + () => [ + { + key: "model", + header: "Model", + render: (item: MessageStats) => ( +
+
{item.model}
+
{item.provider}
+
+ ), + }, + { + key: "timestamp", + header: "Time", + render: (item: MessageStats) => formatRelativeTime(item.timestamp), + }, + { + key: "tokens", + header: "Tokens", + numeric: true, + render: (item: MessageStats) => formatInteger(item.usage.totalTokens), + }, + { + key: "cost", + header: "Cost", + numeric: true, + render: (item: MessageStats) => formatCost(item.usage.cost.total, 4), + }, + { + key: "duration", + header: "Duration", + numeric: true, + render: (item: MessageStats) => formatDurationMs(item.duration), + }, + { + key: "status", + header: "Status", + className: "stats-text-center", + render: (item: MessageStats) => ( + + {item.errorMessage ? "Failed" : "Success"} + + ), + }, + ], + [], + ); + + const renderMobileCard = (item: MessageStats, onClick?: () => void) => ( +
+
+
+
{item.model}
+
{item.provider}
+
+ + {item.errorMessage ? "Failed" : "Success"} + +
+
+
+
Time
+
{formatRelativeTime(item.timestamp)}
+
+
+
Cost
+
{formatCost(item.usage.cost.total, 4)}
+
+
+
Tokens
+
{formatInteger(item.usage.totalTokens)}
+
+
+
Duration
+
{formatDurationMs(item.duration)}
+
+
+ {item.errorMessage &&
{item.errorMessage}
} +
+ ); + + return ( +
+ + + item.id || `${item.sessionFile}-${item.entryId}`} + onRowClick={item => item.id && onRequestClick(item.id)} + renderMobileCard={renderMobileCard} + emptyText="No recent requests found" + /> + + +
+ ); +} diff --git a/packages/stats/src/client/routes/index.ts b/packages/stats/src/client/routes/index.ts new file mode 100644 index 000000000..4c3b3e88b --- /dev/null +++ b/packages/stats/src/client/routes/index.ts @@ -0,0 +1,7 @@ +export * from "./BehaviorRoute"; +export * from "./CostsRoute"; +export * from "./ErrorsRoute"; +export * from "./ModelsRoute"; +export * from "./OverviewRoute"; +export * from "./ProjectsRoute"; +export * from "./RequestsRoute"; diff --git a/packages/stats/src/client/styles.css b/packages/stats/src/client/styles.css index 57c90c791..0cfd21d13 100644 --- a/packages/stats/src/client/styles.css +++ b/packages/stats/src/client/styles.css @@ -1,64 +1,92 @@ @import "tailwindcss/index.css"; + +/* ========================================================================== + OMP Theme Variables & Color Scheme + ========================================================================== */ :root { - color-scheme: light; - --bg-page: #f8fafc; - --bg-surface: #ffffff; - --bg-elevated: #f1f5f9; - --bg-hover: rgba(15, 23, 42, 0.04); - --bg-active: rgba(15, 23, 42, 0.08); + color-scheme: dark; + /* OMP brand palette — packages/collab-web/src/styles/tokens.css: + deep-purple surfaces (hue 307), pink accent #ed4abf, cyan focus ring #5ad8e6. */ + --page: oklch(0.16 0.02 307); + --surface: oklch(0.19 0.022 307); + --surface-2: oklch(0.235 0.026 307); + --border: oklch(1 0 0 / 9%); + --border-strong: oklch(1 0 0 / 13%); + --text: oklch(0.92 0.01 307); + --muted: oklch(0.71 0.016 307); + --dim: oklch(0.53 0.018 307); + --accent: oklch(0.674 0.23 341); /* brand pink #ed4abf */ + --accent-fg: oklch(0.2 0.07 341); /* ink on accent fills */ + --accent-muted: oklch(0.674 0.23 341 / 18%); + --link: oklch(0.817 0.112 205); /* brand cyan #5ad8e6 */ + --success: oklch(0.76 0.14 150); + --danger: oklch(0.66 0.19 25); + --warning: oklch(0.79 0.14 85); - --border-subtle: rgba(15, 23, 42, 0.08); - --border-default: rgba(15, 23, 42, 0.14); + /* Bridge the view components' token vocabulary to the OMP base tokens so + muted/secondary text and semantic accents resolve instead of silently + falling back to inherited text color. Re-resolve per theme via var(). */ + --text-primary: var(--text); + --text-secondary: var(--muted); + --text-muted: var(--dim); + --border-subtle: var(--border); + --accent-red: var(--danger); + --accent-green: var(--success); + --accent-cyan: var(--link); + --bg-hover: var(--surface-2); + --bg-elevated: var(--surface-2); - --text-primary: #0f172a; - --text-secondary: #334155; - --text-muted: #64748b; - - --accent-pink: #ec4899; - --accent-pink-glow: rgba(236, 72, 153, 0.26); - --accent-cyan: #0891b2; - --accent-cyan-glow: rgba(8, 145, 178, 0.24); - --accent-violet: #8b5cf6; - --accent-green: #16a34a; - --accent-amber: #d97706; - --accent-red: #dc2626; - - --tab-active-highlight: inset 0 1px 0 rgba(15, 23, 42, 0.08); - - --radius-sm: 6px; - --radius-md: 10px; - --radius-lg: 14px; + --radius-sm: 3px; + --radius-md: 6px; + --radius-lg: 8px; } -@media (prefers-color-scheme: dark) { - :root { - color-scheme: dark; - --bg-page: #0a0a0f; - --bg-surface: #111118; - --bg-elevated: #16161e; - --bg-hover: rgba(255, 255, 255, 0.03); - --bg-active: rgba(255, 255, 255, 0.06); - - --border-subtle: rgba(255, 255, 255, 0.06); - --border-default: rgba(255, 255, 255, 0.1); - - --text-primary: #f8fafc; - --text-secondary: #94a3b8; - --text-muted: #64748b; - - --accent-pink: #ec4899; - --accent-pink-glow: rgba(236, 72, 153, 0.3); - --accent-cyan: #22d3ee; - --accent-cyan-glow: rgba(34, 211, 238, 0.3); - --accent-violet: #a78bfa; - --accent-green: #4ade80; - --accent-amber: #fbbf24; - --accent-red: #f87171; - - --tab-active-highlight: inset 0 1px 0 rgba(255, 255, 255, 0.05); +/* System-default fallback before JS sets data-theme; an explicit data-theme + (set by the toggle / anti-flash script) opts out so the override wins. */ +@media (prefers-color-scheme: light) { + :root:not([data-theme]) { + color-scheme: light; + --page: oklch(0.985 0.004 307); + --surface: oklch(1 0 0); + --surface-2: oklch(0.965 0.006 307); + --border: oklch(0 0 0 / 10%); + --border-strong: oklch(0 0 0 / 15%); + --text: oklch(0.26 0.03 307); + --muted: oklch(0.46 0.03 307); + --dim: oklch(0.58 0.025 307); + --accent: oklch(0.62 0.23 341); + --accent-fg: oklch(0.99 0.01 341); + --accent-muted: oklch(0.62 0.23 341 / 14%); + --link: oklch(0.58 0.13 230); + --success: oklch(0.55 0.13 150); + --danger: oklch(0.55 0.19 25); + --warning: oklch(0.6 0.13 85); } } +/* Explicit theme override (toggle or persisted preference). */ +[data-theme="light"] { + color-scheme: light; + --page: oklch(0.985 0.004 307); + --surface: oklch(1 0 0); + --surface-2: oklch(0.965 0.006 307); + --border: oklch(0 0 0 / 10%); + --border-strong: oklch(0 0 0 / 15%); + --text: oklch(0.26 0.03 307); + --muted: oklch(0.46 0.03 307); + --dim: oklch(0.58 0.025 307); + --accent: oklch(0.62 0.23 341); + --accent-fg: oklch(0.99 0.01 341); + --accent-muted: oklch(0.62 0.23 341 / 14%); + --link: oklch(0.58 0.13 230); + --success: oklch(0.55 0.13 150); + --danger: oklch(0.55 0.19 25); + --warning: oklch(0.6 0.13 85); +} + +/* ========================================================================== + Base Styles + ========================================================================== */ @layer base { * { box-sizing: border-box; @@ -70,237 +98,1226 @@ } body { - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; - background: var(--bg-page); - color: var(--text-primary); + font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: var(--page); + color: var(--text); margin: 0; min-height: 100vh; } - /* Custom scrollbar */ - ::-webkit-scrollbar { - width: 8px; - height: 8px; - } - - ::-webkit-scrollbar-track { - background: transparent; - } - - ::-webkit-scrollbar-thumb { - background: var(--border-default); - border-radius: 4px; - } - - ::-webkit-scrollbar-thumb:hover { - background: var(--text-muted); + /* Accessible focus ring style */ + button:focus-visible, + a:focus-visible, + [tabindex]:focus-visible { + outline: 2px solid var(--link); + outline-offset: 2px; } } -@layer components { - .surface { - background: var(--bg-surface); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); - } +/* ========================================================================== + Layout Components + ========================================================================== */ +.stats-app-container { + display: flex; + min-height: 100vh; + background: var(--page); + color: var(--text); +} - .surface-elevated { - background: var(--bg-elevated); - border: 1px solid var(--border-default); - border-radius: var(--radius-lg); - } +/* Navigation Rail (Sidebar) */ +.stats-nav-rail { + width: 240px; + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + flex-shrink: 0; +} - .btn { +.stats-desktop-nav { + display: flex; +} + +@media (max-width: 1023px) { + .stats-desktop-nav { + display: none; + } +} + +.stats-nav-rail-header { + padding: 20px 24px; + border-bottom: 1px solid var(--border); + height: 56px; + display: flex; + align-items: center; +} + +.stats-logo-container { + display: flex; + flex-direction: column; +} + +.stats-logo-text { + font-size: 14px; + font-weight: 700; + letter-spacing: 0.1em; + color: var(--text); +} + +.stats-logo-subtext { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); +} + +.stats-nav-rail-menu { + flex: 1; + padding: 16px 12px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.stats-nav-rail-item { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 16px; + font-size: 13px; + font-weight: 500; + color: var(--muted); + background: transparent; + border: none; + border-radius: var(--radius-md); + cursor: pointer; + text-align: left; + transition: all 0.15s ease; +} + +.stats-nav-rail-item:hover { + color: var(--text); + background: var(--surface-2); +} + +.stats-nav-rail-item[data-active="true"] { + color: var(--accent); + background: var(--accent-muted); + position: relative; +} + +.stats-nav-rail-item[data-active="true"]::before { + content: ""; + position: absolute; + left: 0; + top: 50%; + height: 18px; + width: 3px; + border-radius: 0 3px 3px 0; + background: var(--accent); + transform: translateY(-50%); +} + +.stats-nav-rail-item-icon { + flex-shrink: 0; +} + +.stats-nav-rail-footer { + padding: 16px 24px; + border-top: 1px solid var(--border); +} + +.stats-version-tag { + font-size: 11px; + color: var(--dim); +} + +/* Main Content Area */ +.stats-main-pane { + flex: 1; + display: flex; + flex-direction: column; + min-width: 0; +} + +.stats-top-bar { + height: 56px; + background: var(--surface); + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 24px; +} + +@media (max-width: 767px) { + .stats-top-bar { + padding: 0 16px; + flex-wrap: wrap; + height: auto; + min-height: 56px; + padding-top: 8px; + padding-bottom: 8px; + gap: 12px; + } +} + +.stats-top-bar-left { + display: flex; + align-items: center; + gap: 12px; +} + +.stats-mobile-menu-btn { + display: none; + background: transparent; + border: none; + color: var(--text); + padding: 4px; + cursor: pointer; +} + +@media (max-width: 1023px) { + .stats-mobile-menu-btn { display: inline-flex; align-items: center; justify-content: center; + } +} + +.stats-page-title { + font-size: 16px; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.stats-top-bar-right { + display: flex; + align-items: center; + gap: 16px; +} + +@media (max-width: 767px) { + .stats-top-bar-right { + width: 100%; + justify-content: space-between; gap: 8px; - padding: 8px 16px; - font-size: 14px; - font-weight: 500; - border-radius: var(--radius-md); - border: none; - cursor: pointer; - transition: all 0.15s ease; + } +} + +.stats-top-bar-meta { + display: flex; + align-items: center; +} + +.stats-last-updated { + font-size: 11px; + color: var(--dim); + font-variant-numeric: tabular-nums; +} + +.stats-content-area { + flex: 1; + overflow-y: auto; +} + +.stats-content-inner { + max-width: 1560px; + margin: 0 auto; + padding: 24px; + display: flex; + flex-direction: column; + gap: 24px; +} + +@media (max-width: 767px) { + .stats-content-inner { + padding: 16px; + gap: 16px; + } +} + +/* Mobile Nav Drawer */ +.stats-mobile-drawer-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: none; /* No decorative blurs */ + z-index: 100; +} + +.stats-mobile-drawer { + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 260px; + background: var(--surface); + border-right: 1px solid var(--border); + display: flex; + flex-direction: column; + z-index: 101; + animation: slideIn 0.2s ease-out; +} + +@keyframes slideIn { + from { transform: translateX(-100%); } + to { transform: translateX(0); } +} + +.stats-mobile-drawer-header { + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 16px 0 24px; + border-bottom: 1px solid var(--border); +} + +.stats-mobile-nav { + display: flex; + height: calc(100% - 56px); +} + +/* ========================================================================== + UI Primitives & Styles + ========================================================================== */ + +/* Button */ +.stats-button { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 8px 14px; + font-size: 13px; + font-weight: 500; + border-radius: var(--radius-md); + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s ease; + font-family: inherit; +} + +.stats-button:active:not(:disabled) { + transform: translateY(1px); +} + +.stats-button-primary { + background: var(--accent); + color: var(--accent-fg); +} + +.stats-button-primary:hover:not(:disabled) { + opacity: 0.9; +} + +.stats-button-primary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.stats-button-secondary { + background: var(--surface-2); + color: var(--text); + border-color: var(--border); +} + +.stats-button-secondary:hover:not(:disabled) { + background: var(--border); +} + +.stats-button-secondary:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* RangeControl */ +.stats-range-control { + display: flex; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 2px; +} + +.stats-range-control-btn { + padding: 4px 10px; + font-size: 12px; + font-weight: 500; + color: var(--muted); + background: transparent; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.1s ease; +} + +.stats-range-control-btn:hover { + color: var(--text); +} + +.stats-range-control-btn[data-active="true"] { + color: var(--text); + background: var(--surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); +} + +/* Sync Button & Container */ +.stats-sync-container { + display: flex; + align-items: center; + gap: 12px; +} + +.stats-sync-status-msg { + font-size: 11px; + font-variant-numeric: tabular-nums; + max-width: 200px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.stats-sync-status-msg[data-type="success"] { + color: var(--success); +} + +.stats-sync-status-msg[data-type="error"] { + color: var(--danger); +} + +.stats-sync-icon { + flex-shrink: 0; +} + +.stats-spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Panel */ +.stats-panel { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; +} + +.stats-panel-header { + padding: 16px 20px; + border-bottom: 1px solid var(--border); + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} + +@media (max-width: 480px) { + .stats-panel-header { + flex-direction: column; + align-items: flex-start; + gap: 8px; + } +} + +.stats-panel-header-titles { + display: flex; + flex-direction: column; +} + +.stats-panel-title { + font-size: 14px; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.stats-panel-subtitle { + font-size: 12px; + color: var(--muted); + margin: 2px 0 0 0; +} + +.stats-panel-actions { + display: flex; + align-items: center; + gap: 12px; +} + +.stats-panel-body { + padding: 20px; +} + +/* Metric Cluster (Overview Dashboard KPI Grid) */ +.stats-metric-cluster { + display: flex; + flex-direction: column; + gap: 16px; +} + +.stats-metric-primary-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; +} + +@media (max-width: 1023px) { + .stats-metric-primary-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +@media (max-width: 480px) { + .stats-metric-primary-grid { + grid-template-columns: 1fr; + } +} + +.stats-metric-secondary-grid { + display: grid; + grid-template-columns: repeat(6, 1fr); + gap: 12px; +} + +@media (max-width: 1023px) { + .stats-metric-secondary-grid { + grid-template-columns: repeat(3, 1fr); + } +} + +@media (max-width: 600px) { + .stats-metric-secondary-grid { + grid-template-columns: repeat(2, 1fr); + } +} + +.stats-metric-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; + display: flex; + flex-direction: column; + justify-content: space-between; +} + +.stats-metric-card.primary { + padding: 20px 18px; +} + +.stats-metric-card.primary .stats-metric-label { + color: var(--accent); +} + +.stats-metric-card.secondary { + padding: 12px 14px; +} + +.stats-metric-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + margin-bottom: 8px; +} + +.stats-metric-value { + font-size: 20px; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.stats-metric-card.secondary .stats-metric-value { + font-size: 15px; + font-weight: 600; +} + +/* Data Table Component */ +.stats-table-wrapper { + width: 100%; +} + +.stats-table-container { + width: 100%; + overflow-x: auto; +} + +.stats-table { + width: 100%; + border-collapse: collapse; + font-size: 13px; + font-variant-numeric: tabular-nums; +} + +.stats-table-th { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + padding: 12px 16px; + border-bottom: 2px solid var(--border); +} + +.stats-table-td { + padding: 10px 16px; + border-bottom: 1px solid var(--border); + color: var(--text); +} + +.stats-table-tr { + background: transparent; +} + +.stats-table-tr-clickable { + cursor: pointer; + transition: background 0.12s ease, box-shadow 0.12s ease; +} + +.stats-table-tr-clickable:hover { + background: var(--surface-2); + box-shadow: inset 2px 0 0 var(--accent); +} + +.stats-text-left { + text-align: left; +} + +.stats-text-right { + text-align: right; +} + +.stats-table-empty { + padding: 40px; + text-align: center; + color: var(--muted); + font-size: 13px; + border: 1px dashed var(--border); + border-radius: var(--radius-lg); +} + +/* Project progress bars */ +.stats-progress-bar-track { + background: var(--surface-2); + border-radius: 999px; + overflow: hidden; +} + +.stats-progress-bar-fill { + height: 100%; + border-radius: inherit; + transition: width 0.18s ease; +} + +.stats-progress-bar-fill[data-variant="link"] { + background: var(--link); +} + +.stats-progress-bar-fill[data-variant="success"] { + background: var(--success); +} + +.stats-table-mobile-only { + display: none; +} + +@media (max-width: 767px) { + .stats-table-desktop-only { + display: none; + } + .stats-table-mobile-only { + display: block; + } +} + +.stats-table-mobile-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.stats-table-mobile-card-wrapper { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px; +} + +/* Status Pill */ +.stats-status-pill { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 2px 8px; + font-size: 11px; + font-weight: 600; + border-radius: 100px; + line-height: 1.2; +} + +.stats-status-pill[data-variant="success"] { + background: color-mix(in srgb, var(--success) 18%, transparent); + color: var(--success); +} + +.stats-status-pill[data-variant="danger"] { + background: color-mix(in srgb, var(--danger) 18%, transparent); + color: var(--danger); +} + +.stats-status-pill[data-variant="warning"] { + background: color-mix(in srgb, var(--warning) 18%, transparent); + color: var(--warning); +} + +.stats-status-pill[data-variant="info"] { + background: color-mix(in srgb, var(--link) 18%, transparent); + color: var(--link); +} + +.stats-status-pill[data-variant="default"] { + background: var(--surface-2); + color: var(--muted); +} + +/* Segmented Control */ +.stats-segmented-control { + display: flex; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 2px; + gap: 2px; +} + +.stats-segmented-control-btn { + padding: 6px 12px; + font-size: 12px; + font-weight: 500; + color: var(--muted); + background: transparent; + border: none; + border-radius: var(--radius-sm); + cursor: pointer; + transition: all 0.1s ease; + white-space: nowrap; +} + +.stats-segmented-control-btn:hover { + color: var(--text); +} + +.stats-segmented-control-btn[data-active="true"] { + color: var(--text); + background: var(--surface); + box-shadow: 0 1px 2px rgba(0, 0, 0, 0.18); +} + +/* Drawer Detail Component */ +.stats-drawer-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 100; + display: flex; + justify-content: flex-end; +} + +.stats-drawer { + width: 600px; + max-width: 100%; + height: 100%; + background: var(--surface); + border-left: 1px solid var(--border); + display: flex; + flex-direction: column; + animation: drawerSlide 0.25s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: -4px 0 24px rgba(0, 0, 0, 0.25); +} + +@keyframes drawerSlide { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +.stats-drawer-header { + height: 56px; + border-bottom: 1px solid var(--border); + padding: 0 24px; + display: flex; + align-items: center; + justify-content: space-between; + background: var(--surface); + flex-shrink: 0; +} + +.stats-drawer-header-left { + display: flex; + align-items: baseline; + gap: 12px; +} + +.stats-drawer-title { + font-size: 15px; + font-weight: 600; + color: var(--text); + margin: 0; +} + +.stats-drawer-id { + font-size: 11px; + color: var(--muted); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; +} + +.stats-drawer-close-btn { + background: transparent; + border: none; + color: var(--muted); + cursor: pointer; + padding: 4px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: var(--radius-sm); + transition: all 0.1s ease; +} + +.stats-drawer-close-btn:hover { + color: var(--text); + background: var(--surface-2); +} + +.stats-drawer-body { + flex: 1; + overflow-y: auto; + padding: 24px; +} + +.stats-drawer-content { + display: flex; + flex-direction: column; + gap: 24px; +} + +.stats-drawer-status-card { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px; +} + +.stats-drawer-status-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 16px; +} + +.stats-drawer-model { + font-size: 16px; + font-weight: 700; + color: var(--text); +} + +.stats-drawer-provider { + font-size: 12px; + color: var(--muted); + margin-top: 2px; +} + +.stats-drawer-error-block { + margin-top: 14px; + border-top: 1px solid var(--border); + padding-top: 12px; +} + +.stats-drawer-error-label { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--danger); + margin-bottom: 4px; +} + +.stats-drawer-error-text { + font-size: 12px; + color: var(--text); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + white-space: pre-wrap; + word-break: break-all; +} + +.stats-drawer-metrics-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 12px; +} + +.stats-drawer-metric-card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 14px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.stats-drawer-metric-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--muted); + margin-bottom: 6px; +} + +.stats-drawer-metric-icon { + color: var(--dim); +} + +.stats-drawer-metric-value { + font-size: 16px; + font-weight: 700; + color: var(--text); + font-variant-numeric: tabular-nums; +} + +.stats-drawer-metric-sub { + font-size: 11px; + color: var(--muted); + margin-top: 2px; + font-variant-numeric: tabular-nums; +} + +.stats-drawer-json-blocks { + display: flex; + flex-direction: column; + gap: 16px; +} + +/* Collapsible JSON Block */ +.stats-json-block { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); + overflow: hidden; +} + +.stats-json-block-header { + padding: 10px 16px; + background: var(--surface-2); + border-bottom: 1px solid var(--border); + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + user-select: none; + font-size: 12px; + font-weight: 600; +} + +.stats-json-block-header:hover { + background: var(--border); +} + +.stats-json-block-title { + color: var(--text); +} + +.stats-json-block-toggle-indicator { + color: var(--muted); +} + +.stats-json-block-content-wrapper { + padding: 16px; + background: var(--page); + overflow-x: auto; +} + +.stats-json-block-content { + margin: 0; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 12px; + color: var(--text); + line-height: 1.5; +} + +/* Skeleton Loading Element */ +.stats-skeleton { + background: var(--surface-2); + position: relative; + overflow: hidden; + border-radius: var(--radius-sm); +} + +.stats-skeleton[data-variant="text"] { + height: 14px; + margin-top: 4px; + margin-bottom: 4px; +} + +.stats-skeleton[data-variant="circle"] { + border-radius: 50%; +} + +/* Subtle Shimmer effect, respecting media queries for reduced motion */ +.stats-skeleton::after { + content: ""; + position: absolute; + inset: 0; + transform: translateX(-100%); + background: linear-gradient( + 90deg, + transparent 0%, + rgba(255, 255, 255, 0.05) 50%, + transparent 100% + ); + animation: shimmer 1.5s infinite; +} + +@media (prefers-color-scheme: light) { + .stats-skeleton::after { + background: linear-gradient( + 90deg, + transparent 0%, + rgba(0, 0, 0, 0.03) 50%, + transparent 100% + ); + } +} + +@keyframes shimmer { + 100% { transform: translateX(100%); } +} + +@media (prefers-reduced-motion: reduce) { + .stats-skeleton::after { + animation: none; + } +} + +.stats-boundary-skeleton { + padding: 20px; + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--surface); +} + +/* Empty State / Error State */ +.stats-empty-state, +.stats-error-state { + padding: 48px 24px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + text-align: center; + background: var(--surface); + border: 1px dashed var(--border); + border-radius: var(--radius-lg); +} + +.stats-empty-state-message { + font-size: 13px; + color: var(--muted); + margin: 0; +} + +.stats-error-state-content { + max-width: 360px; +} + +.stats-error-state-title { + font-size: 14px; + font-weight: 600; + color: var(--text); + margin: 0 0 8px 0; +} + +.stats-error-state-message { + font-size: 12px; + color: var(--danger); + margin: 0 0 16px 0; + word-break: break-word; +} + +.stats-error-state-btn { + margin: 0 auto; +} + +/* ========================================================================== + Token-vocabulary bridge utility classes (used across route components) + ========================================================================== */ +.stats-text-primary { color: var(--text); } +.stats-text-secondary { color: var(--muted); } +.stats-text-muted { color: var(--dim); } +.stats-text-xs { font-size: 12px; } +.stats-font-medium { font-weight: 500; } +.stats-font-semibold { font-weight: 600; } + +/* ========================================================================== + Owned scrollbars — thin, brand-tinted, app-wide (ComPress convention) + ========================================================================== */ +* { + scrollbar-width: thin; + scrollbar-color: var(--border-strong) transparent; +} + +*::-webkit-scrollbar { + width: 10px; + height: 10px; +} + +*::-webkit-scrollbar-track { + background: transparent; +} + +*::-webkit-scrollbar-thumb { + background: var(--border-strong); + border: 2px solid transparent; + background-clip: padding-box; + border-radius: 999px; +} + +*::-webkit-scrollbar-thumb:hover { + background: var(--dim); + border: 2px solid transparent; + background-clip: padding-box; +} + +*::-webkit-scrollbar-corner { + background: transparent; +} + +/* ========================================================================== + Comprehensive reduced-motion fallback for all interaction motion + ========================================================================== */ +@media (prefers-reduced-motion: reduce) { + .stats-button, + .stats-table-tr-clickable, + .stats-nav-rail-item, + .stats-range-control-btn, + .stats-segmented-control-btn { + transition: none; } - .btn-primary { - background: linear-gradient(135deg, var(--accent-pink) 0%, #db2777 100%); - color: white; - box-shadow: 0 0 20px var(--accent-pink-glow); - } - - .btn-primary:hover { - transform: translateY(-1px); - box-shadow: 0 4px 24px var(--accent-pink-glow); - } - - .btn-primary:disabled { - opacity: 0.6; - cursor: not-allowed; + .stats-button:active:not(:disabled) { transform: none; } - .btn-secondary { - background: var(--bg-elevated); - color: var(--text-primary); - border: 1px solid var(--border-default); - } - - .btn-secondary:hover { - background: var(--bg-hover); - border-color: var(--border-default); - } - - .tab-btn { - padding: 6px 16px; - font-size: 14px; - font-weight: 500; - color: var(--text-muted); - background: transparent; - border: none; - border-radius: var(--radius-md); - cursor: pointer; - transition: all 0.15s ease; - } - - .tab-btn:hover { - color: var(--text-primary); - background: var(--bg-hover); - } - - .tab-btn.active { - color: var(--text-primary); - background: var(--bg-elevated); - box-shadow: var(--tab-active-highlight); - } - - .stat-card { - background: var(--bg-surface); - border: 1px solid var(--border-subtle); - border-radius: var(--radius-lg); - padding: 20px; - transition: all 0.2s ease; - } - - .stat-card:hover { - border-color: var(--border-default); - background: var(--bg-elevated); - } - - .badge { - display: inline-flex; - align-items: center; - padding: 3px 10px; - font-size: 12px; - font-weight: 500; - border-radius: 100px; - } - - .badge-success { - background: rgba(74, 222, 128, 0.15); - color: var(--accent-green); - } - - .badge-error { - background: rgba(248, 113, 113, 0.15); - color: var(--accent-red); - } - - .badge-info { - background: rgba(34, 211, 238, 0.15); - color: var(--accent-cyan); - } - - .badge-warning { - background: rgba(251, 191, 36, 0.15); - color: var(--accent-amber); - } - - .table-header { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; - color: var(--text-muted); - } - - .table-row { - transition: background 0.15s ease; - } - - .table-row:hover { - background: var(--bg-hover); - } - - .gradient-text { - background: linear-gradient(135deg, var(--accent-pink) 0%, var(--accent-cyan) 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - } - - .gradient-border { - position: relative; - } - - .gradient-border::before { - content: ''; - position: absolute; - inset: 0; - padding: 1px; - border-radius: inherit; - background: linear-gradient(135deg, var(--accent-pink), var(--accent-cyan)); - -webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); - mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0); - -webkit-mask-composite: xor; - mask-composite: exclude; - pointer-events: none; - } - - .glow-pink { - box-shadow: 0 0 30px var(--accent-pink-glow); - } - - .glow-cyan { - box-shadow: 0 0 30px var(--accent-cyan-glow); + .stats-mobile-drawer, + .stats-drawer { + animation: none; } } -@layer utilities { - .text-balance { - text-wrap: balance; - } +/* ========================================================================== + Brand mark on the OMP wordmark (both rail + mobile drawer headers) + ========================================================================== */ +.stats-logo-container { + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + column-gap: 10px; +} - .animate-fade-in { - animation: fadeIn 0.3s ease-out; - } +.stats-logo-container::before { + content: ""; + grid-row: 1 / 3; + width: 16px; + height: 16px; + border-radius: 4px; + background: linear-gradient(135deg, #ed4abf 0%, #9b4dff 50%, #5ad8e6 100%); /* OMP brand gradient mark */ +} - .animate-slide-up { - animation: slideUp 0.3s ease-out; - } +.stats-logo-text, +.stats-logo-subtext { + grid-column: 2; +} - @keyframes fadeIn { - from { opacity: 0; } - to { opacity: 1; } - } +/* ========================================================================== + JSON block copy affordance + ========================================================================== */ +.stats-json-actions { + display: flex; + align-items: center; + gap: 12px; +} - @keyframes slideUp { - from { - opacity: 0; - transform: translateY(10px); - } - to { - opacity: 1; - transform: translateY(0); - } - } +.stats-json-copy-btn { + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 11px; + font-weight: 600; + color: var(--muted); + background: transparent; + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 3px 8px; + cursor: pointer; + font-family: inherit; + transition: color 0.12s ease, background 0.12s ease; +} - .spin { - animation: spin 1s linear infinite; - } +.stats-json-copy-btn:hover { + color: var(--text); + background: var(--surface-2); +} - @keyframes spin { - to { transform: rotate(360deg); } +/* ========================================================================== + Empty-state icon + ========================================================================== */ +.stats-empty-state-icon { + color: var(--dim); + margin-bottom: 12px; +} + +@media (prefers-reduced-motion: reduce) { + .stats-json-copy-btn { + transition: none; + } +} + +/* ========================================================================== + Theme toggle (system / light / dark) + ========================================================================== */ +.stats-theme-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + flex-shrink: 0; + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--surface); + color: var(--muted); + cursor: pointer; + transition: color 0.14s ease, background 0.14s ease, border-color 0.14s ease; +} + +.stats-theme-toggle:hover { + color: var(--text); + border-color: var(--border-strong); +} + +@media (prefers-reduced-motion: reduce) { + .stats-theme-toggle { + transition: none; } } diff --git a/packages/stats/src/client/ui/AsyncBoundary.tsx b/packages/stats/src/client/ui/AsyncBoundary.tsx new file mode 100644 index 000000000..2afa816af --- /dev/null +++ b/packages/stats/src/client/ui/AsyncBoundary.tsx @@ -0,0 +1,54 @@ +import type React from "react"; +import { EmptyState } from "./EmptyState"; +import { ErrorState } from "./ErrorState"; +import { Skeleton } from "./Skeleton"; + +export interface AsyncBoundaryProps { + loading: boolean; + error: Error | null; + data: unknown | null; + empty?: boolean; + emptyText?: string; + fallback?: React.ReactNode; + onRetry?: () => void; + children: React.ReactNode; +} + +export function AsyncBoundary({ + loading, + error, + data, + empty = false, + emptyText = "No data available", + fallback, + onRetry, + children, +}: AsyncBoundaryProps) { + // If there's an error and no stale data, render ErrorState + if (error && data === null) { + return ; + } + + // If it's loading and there is no stale data, render Loading state / Skeleton + if (loading && data === null) { + if (fallback) { + return <>{fallback}; + } + return ( +
+ + + + +
+ ); + } + + // If there is data but it's empty, render EmptyState + if (!loading && (empty || data === null)) { + return ; + } + + // Render children (stale data is kept visible even if loading is true in background) + return <>{children}; +} diff --git a/packages/stats/src/client/ui/DataTable.tsx b/packages/stats/src/client/ui/DataTable.tsx new file mode 100644 index 000000000..51a8b8d0b --- /dev/null +++ b/packages/stats/src/client/ui/DataTable.tsx @@ -0,0 +1,122 @@ +import type React from "react"; + +export interface DataTableColumn { + key: string; + header: React.ReactNode; + render?: (item: T) => React.ReactNode; + className?: string; + numeric?: boolean; +} + +export interface DataTableProps { + columns: DataTableColumn[]; + data: T[]; + keyExtractor: (item: T) => string | number; + onRowClick?: (item: T) => void; + renderMobileCard?: (item: T, onClick?: () => void) => React.ReactNode; + emptyText?: string; +} + +export function DataTable({ + columns, + data, + keyExtractor, + onRowClick, + renderMobileCard, + emptyText = "No data available", +}: DataTableProps) { + const handleKeyDown = (e: React.KeyboardEvent, item: T) => { + if (onRowClick && (e.key === "Enter" || e.key === " ")) { + e.preventDefault(); + onRowClick(item); + } + }; + + if (data.length === 0) { + return
{emptyText}
; + } + + return ( +
+ {/* Mobile layout */} + {renderMobileCard && ( +
+
+ {data.map(item => { + const key = String(keyExtractor(item)); + const onClick = onRowClick ? () => onRowClick(item) : undefined; + return ( +
+ {renderMobileCard(item, onClick)} +
+ ); + })} +
+
+ )} + + {/* Desktop layout */} +
+ + + + {columns.map(col => { + const headerClasses = [ + "stats-table-th", + col.numeric ? "stats-text-right" : "stats-text-left", + col.className || "", + ] + .filter(Boolean) + .join(" "); + + return ( + + ); + })} + + + + {data.map(item => { + const key = String(keyExtractor(item)); + const isClickable = typeof onRowClick === "function"; + const rowClasses = ["stats-table-tr", isClickable ? "stats-table-tr-clickable" : ""] + .filter(Boolean) + .join(" "); + + return ( + onRowClick!(item) : undefined} + onKeyDown={isClickable ? e => handleKeyDown(e, item) : undefined} + tabIndex={isClickable ? 0 : undefined} + role={isClickable ? "button" : undefined} + > + {columns.map(col => { + const cellClasses = [ + "stats-table-td", + col.numeric ? "stats-text-right" : "stats-text-left", + col.className || "", + ] + .filter(Boolean) + .join(" "); + + return ( + + ); + })} + + ); + })} + +
+ {col.header} +
+ {col.render + ? col.render(item) + : ((item as Record)[col.key] as React.ReactNode)} +
+
+
+ ); +} diff --git a/packages/stats/src/client/ui/EmptyState.tsx b/packages/stats/src/client/ui/EmptyState.tsx new file mode 100644 index 000000000..02987e0d8 --- /dev/null +++ b/packages/stats/src/client/ui/EmptyState.tsx @@ -0,0 +1,16 @@ +import { Inbox, type LucideIcon } from "lucide-react"; + +export interface EmptyStateProps { + message?: string; + icon?: LucideIcon; + className?: string; +} + +export function EmptyState({ message = "No data available", icon: Icon = Inbox, className = "" }: EmptyStateProps) { + return ( +
+
+ ); +} diff --git a/packages/stats/src/client/ui/ErrorState.tsx b/packages/stats/src/client/ui/ErrorState.tsx new file mode 100644 index 000000000..3d47206db --- /dev/null +++ b/packages/stats/src/client/ui/ErrorState.tsx @@ -0,0 +1,25 @@ +export interface ErrorStateProps { + error?: Error | null; + onRetry?: () => void; + className?: string; +} + +export function ErrorState({ error, onRetry, className = "" }: ErrorStateProps) { + return ( +
+
+

Failed to load data

+ {error &&

{error.message}

} + {onRetry && ( + + )} +
+
+ ); +} diff --git a/packages/stats/src/client/ui/JsonBlock.tsx b/packages/stats/src/client/ui/JsonBlock.tsx new file mode 100644 index 000000000..430b1d8b0 --- /dev/null +++ b/packages/stats/src/client/ui/JsonBlock.tsx @@ -0,0 +1,75 @@ +import { Check, Copy } from "lucide-react"; +import type React from "react"; +import { useEffect, useRef, useState } from "react"; + +export interface JsonBlockProps { + data: unknown; + title?: string; + initialCollapsed?: boolean; +} + +export function JsonBlock({ data, title, initialCollapsed = false }: JsonBlockProps) { + const [collapsed, setCollapsed] = useState(initialCollapsed); + const [copied, setCopied] = useState(false); + const copyResetRef = useRef(0); + const jsonStr = JSON.stringify(data, null, 2); + + // Clear the pending "Copied" reset if the block unmounts (e.g. drawer close). + useEffect(() => () => window.clearTimeout(copyResetRef.current), []); + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" || e.key === " ") { + e.preventDefault(); + setCollapsed(!collapsed); + } + }; + + const handleCopy = async (e: React.MouseEvent) => { + // Don't toggle the collapse state when copying. + e.stopPropagation(); + try { + await navigator.clipboard.writeText(jsonStr); + setCopied(true); + window.clearTimeout(copyResetRef.current); + copyResetRef.current = window.setTimeout(() => setCopied(false), 1500); + } catch { + // Clipboard API unavailable (e.g. insecure context); silently no-op. + } + }; + + return ( +
+
setCollapsed(!collapsed)} + onKeyDown={handleKeyDown} + tabIndex={0} + role="button" + aria-expanded={!collapsed} + > + {title || "JSON"} +
+ + + {collapsed ? "▶ Show" : "▼ Hide"} + +
+
+ {!collapsed && ( +
+
+						{jsonStr}
+					
+
+ )} +
+ ); +} diff --git a/packages/stats/src/client/ui/MetricCluster.tsx b/packages/stats/src/client/ui/MetricCluster.tsx new file mode 100644 index 000000000..5b4190d93 --- /dev/null +++ b/packages/stats/src/client/ui/MetricCluster.tsx @@ -0,0 +1,67 @@ +import { + formatCompact, + formatCost, + formatDurationMs, + formatInteger, + formatPercent, + formatTokensPerSecond, +} from "../data/formatters"; +import type { AggregatedStats } from "../types"; + +export interface MetricClusterProps { + stats: AggregatedStats; +} + +export function MetricCluster({ stats }: MetricClusterProps) { + return ( +
+
+
+
Total Cost
+
+ {formatCost(stats.totalCost, stats.totalCost > 0 && stats.totalCost < 0.01 ? 4 : 2)} +
+
+
+
Requests
+
{formatInteger(stats.totalRequests)}
+
+
+
Cache Rate
+
{formatPercent(stats.cacheRate)}
+
+
+
Error Rate
+
{formatPercent(stats.errorRate)}
+
+
+ +
+
+
Input Tokens
+
{formatCompact(stats.totalInputTokens)}
+
+
+
Output Tokens
+
{formatCompact(stats.totalOutputTokens)}
+
+
+
Premium Requests
+
{formatInteger(stats.totalPremiumRequests)}
+
+
+
Tokens/s
+
{formatTokensPerSecond(stats.avgTokensPerSecond)}
+
+
+
Avg Latency
+
{formatDurationMs(stats.avgDuration)}
+
+
+
Avg TTFT
+
{formatDurationMs(stats.avgTtft)}
+
+
+
+ ); +} diff --git a/packages/stats/src/client/ui/Panel.tsx b/packages/stats/src/client/ui/Panel.tsx new file mode 100644 index 000000000..e23dc35c2 --- /dev/null +++ b/packages/stats/src/client/ui/Panel.tsx @@ -0,0 +1,24 @@ +import type React from "react"; + +export interface PanelProps extends Omit, "title"> { + title?: React.ReactNode; + subtitle?: React.ReactNode; + actions?: React.ReactNode; +} + +export function Panel({ title, subtitle, actions, children, className = "", ...props }: PanelProps) { + return ( +
+ {(title || subtitle || actions) && ( +
+
+ {title &&

{title}

} + {subtitle &&

{subtitle}

} +
+ {actions &&
{actions}
} +
+ )} +
{children}
+
+ ); +} diff --git a/packages/stats/src/client/ui/RequestDrawer.tsx b/packages/stats/src/client/ui/RequestDrawer.tsx new file mode 100644 index 000000000..d960c314d --- /dev/null +++ b/packages/stats/src/client/ui/RequestDrawer.tsx @@ -0,0 +1,208 @@ +import { Clock, Coins, Gauge, Hash, Star, X, Zap } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import { getRequestDetails } from "../api"; +import { formatCost, formatDurationMs, formatInteger } from "../data/formatters"; +import type { RequestDetails } from "../types"; +import { JsonBlock } from "./JsonBlock"; +import { Skeleton } from "./Skeleton"; +import { StatusPill } from "./StatusPill"; + +export interface RequestDrawerProps { + id: number | null; + onClose: () => void; +} + +export function RequestDrawer({ id, onClose }: RequestDrawerProps) { + const [details, setDetails] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const previousActiveElement = useRef(null); + const closeButtonRef = useRef(null); + + useEffect(() => { + if (id === null) { + setDetails(null); + return; + } + + previousActiveElement.current = document.activeElement as HTMLElement | null; + setLoading(true); + setError(null); + setDetails(null); + + const controller = new AbortController(); + getRequestDetails(id, controller.signal) + .then(data => { + if (controller.signal.aborted) return; + setDetails(data); + // Focus the close button for accessibility + setTimeout(() => closeButtonRef.current?.focus(), 50); + }) + .catch(err => { + if (controller.signal.aborted) return; + setError(err instanceof Error ? err : new Error(String(err))); + }) + .finally(() => { + if (!controller.signal.aborted) setLoading(false); + }); + + return () => controller.abort(); + }, [id]); + + useEffect(() => { + if (id === null) return; + + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === "Escape") { + onClose(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + if (previousActiveElement.current) { + previousActiveElement.current.focus(); + } + }; + }, [id, onClose]); + + if (id === null) return null; + + const handleOverlayClick = (e: React.MouseEvent) => { + if (e.target === e.currentTarget) { + onClose(); + } + }; + + return ( +
+
+ {/* Drawer Header */} +
+
+

Request Details

+ {details && ID: {id}} +
+ +
+ +
+ {loading && ( +
+ + + + +
+ )} + + {error && ( +
+

Failed to load request details

+

{error.message}

+
+ )} + + {details && ( +
+ {/* Status Card */} +
+
+
+
{details.model}
+
{details.provider}
+
+ + {details.errorMessage ? "Error" : "Success"} + +
+ {details.errorMessage && ( +
+
Error Message
+
{details.errorMessage}
+
+ )} +
+ + {/* Metrics Grid */} +
+
+
+ + Cost +
+
{formatCost(details.usage.cost.total, 4)}
+
+ +
+
+ + Premium +
+
+ {formatInteger(details.usage.premiumRequests ?? 0)} +
+
+ +
+
+ + Total Tokens +
+
{formatInteger(details.usage.totalTokens)}
+
+ {formatInteger(details.usage.input)} in · {formatInteger(details.usage.output)} out +
+
+ +
+
+ + Duration +
+
{formatDurationMs(details.duration)}
+
+ +
+
+ + TTFT +
+
{formatDurationMs(details.ttft)}
+
+ + {details.duration && details.usage.output > 0 && ( +
+
+ + Throughput +
+
+ {((details.usage.output * 1000) / details.duration).toFixed(1)} +
+
tokens/second
+
+ )} +
+ + {/* JSON blocks */} +
+ + +
+
+ )} +
+
+
+ ); +} diff --git a/packages/stats/src/client/ui/SegmentedControl.tsx b/packages/stats/src/client/ui/SegmentedControl.tsx new file mode 100644 index 000000000..69f421e26 --- /dev/null +++ b/packages/stats/src/client/ui/SegmentedControl.tsx @@ -0,0 +1,36 @@ +export interface SegmentedControlOption { + value: T; + label: string; + title?: string; +} + +export interface SegmentedControlProps { + options: SegmentedControlOption[]; + value: T; + onChange: (value: T) => void; + className?: string; +} + +export function SegmentedControl({ options, value, onChange, className = "" }: SegmentedControlProps) { + return ( +
+ {options.map(opt => { + const isActive = opt.value === value; + return ( + + ); + })} +
+ ); +} diff --git a/packages/stats/src/client/ui/Skeleton.tsx b/packages/stats/src/client/ui/Skeleton.tsx new file mode 100644 index 000000000..4b1564d0f --- /dev/null +++ b/packages/stats/src/client/ui/Skeleton.tsx @@ -0,0 +1,17 @@ +import type React from "react"; + +export interface SkeletonProps { + variant?: "text" | "rect" | "circle"; + width?: string | number; + height?: string | number; + className?: string; +} + +export function Skeleton({ variant = "text", width, height, className = "" }: SkeletonProps) { + const style: React.CSSProperties = { + width: width !== undefined ? (typeof width === "number" ? `${width}px` : width) : undefined, + height: height !== undefined ? (typeof height === "number" ? `${height}px` : height) : undefined, + }; + + return
; +} diff --git a/packages/stats/src/client/ui/StatusPill.tsx b/packages/stats/src/client/ui/StatusPill.tsx new file mode 100644 index 000000000..ed8a73fb7 --- /dev/null +++ b/packages/stats/src/client/ui/StatusPill.tsx @@ -0,0 +1,15 @@ +import type React from "react"; + +export interface StatusPillProps { + variant: "success" | "danger" | "warning" | "info" | "default"; + children: React.ReactNode; + className?: string; +} + +export function StatusPill({ variant, children, className = "" }: StatusPillProps) { + return ( + + {children} + + ); +} diff --git a/packages/stats/src/client/ui/index.ts b/packages/stats/src/client/ui/index.ts new file mode 100644 index 000000000..75acf2685 --- /dev/null +++ b/packages/stats/src/client/ui/index.ts @@ -0,0 +1,11 @@ +export * from "./AsyncBoundary"; +export * from "./DataTable"; +export * from "./EmptyState"; +export * from "./ErrorState"; +export * from "./JsonBlock"; +export * from "./MetricCluster"; +export * from "./Panel"; +export * from "./RequestDrawer"; +export * from "./SegmentedControl"; +export * from "./Skeleton"; +export * from "./StatusPill"; diff --git a/packages/stats/src/client/useSystemTheme.ts b/packages/stats/src/client/useSystemTheme.ts index 4fc5170a1..1aa0becd7 100644 --- a/packages/stats/src/client/useSystemTheme.ts +++ b/packages/stats/src/client/useSystemTheme.ts @@ -1,31 +1,87 @@ -import { useEffect, useState } from "react"; +import { useSyncExternalStore } from "react"; export type SystemTheme = "light" | "dark"; +export type ThemePreference = "system" | "light" | "dark"; +const STORAGE_KEY = "omp-stats-theme"; const DARK_SCHEME_QUERY = "(prefers-color-scheme: dark)"; -function getSystemTheme(): SystemTheme { - if (typeof window === "undefined") { - return "light"; - } +function readStoredPreference(): ThemePreference { + if (typeof localStorage === "undefined") return "system"; + const stored = localStorage.getItem(STORAGE_KEY); + return stored === "light" || stored === "dark" || stored === "system" ? stored : "system"; +} +function getSystemTheme(): SystemTheme { + if (typeof window === "undefined") return "dark"; return window.matchMedia(DARK_SCHEME_QUERY).matches ? "dark" : "light"; } -export function useSystemTheme(): SystemTheme { - const [theme, setTheme] = useState(() => getSystemTheme()); +// Module-level store shared by the toggle (writer) and every chart (reader) so +// an explicit override and the system default resolve through one source. +let preference: ThemePreference = readStoredPreference(); +let resolved: SystemTheme = preference === "system" ? getSystemTheme() : preference; +const listeners = new Set<() => void>(); - useEffect(() => { - const media = window.matchMedia(DARK_SCHEME_QUERY); - const applyTheme = () => { - setTheme(media.matches ? "dark" : "light"); - }; - - applyTheme(); - - media.addEventListener("change", applyTheme); - return () => media.removeEventListener("change", applyTheme); - }, []); - - return theme; +function emit(): void { + for (const listener of listeners) listener(); +} + +function applyResolvedTheme(): void { + resolved = preference === "system" ? getSystemTheme() : preference; + if (typeof document !== "undefined") { + document.documentElement.dataset.theme = resolved; + document.documentElement.style.colorScheme = resolved; + } +} + +if (typeof window !== "undefined") { + applyResolvedTheme(); + window.matchMedia(DARK_SCHEME_QUERY).addEventListener("change", () => { + // System changes only move the needle while following the system. + if (preference === "system") { + applyResolvedTheme(); + emit(); + } + }); +} + +export function setThemePreference(next: ThemePreference): void { + preference = next; + if (typeof localStorage !== "undefined") localStorage.setItem(STORAGE_KEY, next); + applyResolvedTheme(); + emit(); +} + +function subscribe(callback: () => void): () => void { + listeners.add(callback); + return () => listeners.delete(callback); +} + +/** Reader for the active resolved theme. Reflects system default and overrides. */ +export function useSystemTheme(): SystemTheme { + return useSyncExternalStore( + subscribe, + () => resolved, + () => "dark" as SystemTheme, + ); +} + +/** Reader + writer for the theme preference (powers the toggle). */ +export function useThemePreference(): { + preference: ThemePreference; + resolved: SystemTheme; + setPreference: (next: ThemePreference) => void; +} { + const pref = useSyncExternalStore( + subscribe, + () => preference, + () => "system" as ThemePreference, + ); + const res = useSyncExternalStore( + subscribe, + () => resolved, + () => "dark" as SystemTheme, + ); + return { preference: pref, resolved: res, setPreference: setThemePreference }; } diff --git a/packages/stats/test/client-view-models.test.ts b/packages/stats/test/client-view-models.test.ts new file mode 100644 index 000000000..4507a1238 --- /dev/null +++ b/packages/stats/test/client-view-models.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "bun:test"; +import { buildModelPerformanceLookup } from "../src/client/data/view-models"; +import type { ModelPerformancePoint } from "../src/shared-types"; + +const DAY = 24 * 60 * 60 * 1000; + +describe("client view models", () => { + it("keeps sparse all-time model performance buckets instead of dropping old points", () => { + const points: ModelPerformancePoint[] = [ + { + timestamp: DAY, + model: "gpt-5.5", + provider: "openai-codex", + requests: 1, + avgTtft: 250, + avgTokensPerSecond: 40, + }, + { + timestamp: DAY * 10, + model: "gpt-5.5", + provider: "openai-codex", + requests: 2, + avgTtft: 500, + avgTokensPerSecond: 60, + }, + ]; + + const series = buildModelPerformanceLookup(points, "all").get("gpt-5.5::openai-codex"); + + expect(series?.data.map(point => point.timestamp)).toEqual([DAY, DAY * 10]); + expect(series?.data.map(point => point.requests)).toEqual([1, 2]); + expect(series?.data.map(point => point.avgTtftSeconds)).toEqual([0.25, 0.5]); + }); +}); diff --git a/packages/stats/test/priority-premium-requests.test.ts b/packages/stats/test/priority-premium-requests.test.ts index a3b340a6d..0e1a4fb39 100644 --- a/packages/stats/test/priority-premium-requests.test.ts +++ b/packages/stats/test/priority-premium-requests.test.ts @@ -77,14 +77,13 @@ function assistantEntry(opts: { } describe("priority service-tier premium-request backfill", () => { - it("derives premium_requests from service_tier_change entries for OpenAI traffic", async () => { + it("derives premium_requests from service_tier_change entries for providers that honor priority", async () => { await writeSession("--tmp--proj", "01.jsonl", { lines: [ { type: "session", version: 1, id: "s1", timestamp: new Date().toISOString(), cwd: "/tmp/proj" }, { type: "service_tier_change", id: "stc1", timestamp: new Date().toISOString(), serviceTier: "priority" }, assistantEntry({ id: "a1", provider: "openai" }), assistantEntry({ id: "a2", provider: "openai-codex" }), - // Provider that doesn't honor service_tier — stays at zero. assistantEntry({ id: "a3", provider: "anthropic" }), { type: "service_tier_change", id: "stc2", timestamp: new Date().toISOString(), serviceTier: null }, assistantEntry({ id: "a4", provider: "openai" }), @@ -95,7 +94,7 @@ describe("priority service-tier premium-request backfill", () => { const overall = await getOverallStats(); expect(overall.totalRequests).toBe(4); - expect(overall.totalPremiumRequests).toBe(2); + expect(overall.totalPremiumRequests).toBe(3); }); it("preserves an existing non-zero premiumRequests value (Copilot multiplier) even under priority tier", async () => {