feat(stats): redesign the omp stats dashboard

Rewrite the local `omp stats` web UI on the OMP brand palette with a left nav, focused per-section views, a new Projects view, a system-aware light/dark toggle, and flicker-free navigation. Same data, endpoints, and command; the only server change is a 1h time-range bucketing fix in the aggregator.
This commit is contained in:
lycaon
2026-06-16 22:58:46 -06:00
parent e24f789de4
commit 525f4baa64
56 changed files with 5180 additions and 2435 deletions
+13
View File
@@ -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
+11
View File
@@ -71,6 +71,17 @@ const indexHtml = `<!DOCTYPE html>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Usage Statistics</title>
<script>
(function () {
try {
var stored = localStorage.getItem("omp-stats-theme");
var system = matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
var theme = stored === "light" || stored === "dark" ? stored : system;
document.documentElement.dataset.theme = theme;
document.documentElement.style.colorScheme = theme;
} catch (e) {}
})();
</script>
<link rel="stylesheet" href="styles.css">
</head>
<body>
+4 -3
View File
@@ -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<TimeRange, Omit<TimeRangeConfig, "cutoff">> = {
"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": {
+87 -205
View File
@@ -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<OverviewStats | null>(null);
const [modelStats, setModelStats] = useState<ModelDashboardStats | null>(null);
const [costStats, setCostStats] = useState<CostDashboardStats | null>(null);
const [behaviorStats, setBehaviorStats] = useState<BehaviorDashboardStats | null>(null);
const [recentRequests, setRecentRequests] = useState<MessageStats[]>([]);
const [recentErrors, setRecentErrors] = useState<MessageStats[]>([]);
const [selectedRequest, setSelectedRequest] = useState<number | null>(null);
const [syncing, setSyncing] = useState(false);
const [activeTab, setActiveTab] = useState<Tab>("overview");
const [timeRange, setTimeRange] = useState<TimeRange>("24h");
const { section, setSection, range, setRange } = useHashRoute();
const [refreshTrigger, setRefreshTrigger] = useState(0);
const [selectedRequestId, setSelectedRequestId] = useState<number | null>(null);
const [updatedAt, setUpdatedAt] = useState<number | null>(() => 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<Set<DashboardSection>>(new Set());
mountedRef.current.add(active);
const renderRoute = (target: DashboardSection) => {
const isActive = target === active;
switch (target) {
case "overview":
return (
<OverviewRoute
active={isActive}
range={range}
refreshTrigger={refreshTrigger}
onRequestClick={setSelectedRequestId}
/>
);
case "requests":
return (
<RequestsRoute
active={isActive}
range={range}
refreshTrigger={refreshTrigger}
onRequestClick={setSelectedRequestId}
/>
);
case "errors":
return (
<ErrorsRoute
active={isActive}
range={range}
refreshTrigger={refreshTrigger}
onRequestClick={setSelectedRequestId}
/>
);
case "models":
return <ModelsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
case "costs":
return <CostsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
case "behavior":
return <BehaviorRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
case "projects":
return <ProjectsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
}
};
useEffect(() => {
loadRecentLists();
const interval = setInterval(loadRecentLists, 30000);
return () => clearInterval(interval);
}, [loadRecentLists]);
useEffect(() => {
loadActiveTabStats();
const interval = setInterval(loadActiveTabStats, 30000);
return () => clearInterval(interval);
}, [loadActiveTabStats]);
return (
<div className="min-h-screen">
<div className="max-w-[1600px] mx-auto px-6 py-6">
<Header
activeTab={activeTab}
onTabChange={setActiveTab}
onSync={handleSync}
syncing={syncing}
timeRange={timeRange}
onTimeRangeChange={setTimeRange}
/>
{activeTab === "overview" && (
<div className="space-y-6 animate-fade-in">
{overviewStats ? (
<StatsGrid stats={overviewStats.overall} />
) : (
<LoadingState label="Loading overview..." />
)}
<div className="grid lg:grid-cols-2 gap-6">
<RequestList
title="Recent Requests"
requests={recentRequests.slice(0, 10)}
onSelect={r => r.id && setSelectedRequest(r.id)}
/>
<RequestList
title="Recent Errors"
requests={recentErrors.slice(0, 10)}
onSelect={r => r.id && setSelectedRequest(r.id)}
/>
</div>
<>
<AppLayout
activeSection={active}
onSectionChange={setSection}
range={range}
onRangeChange={setRange}
updatedAt={updatedAt}
onSyncComplete={handleSyncComplete}
>
{[...mountedRef.current].map(target => (
<div key={target} hidden={target !== active}>
{renderRoute(target)}
</div>
)}
))}
</AppLayout>
{activeTab === "requests" && (
<div className="h-[calc(100vh-140px)] animate-fade-in">
<RequestList
title="All Recent Requests"
requests={recentRequests}
onSelect={r => r.id && setSelectedRequest(r.id)}
/>
</div>
)}
{activeTab === "errors" && (
<div className="h-[calc(100vh-140px)] animate-fade-in">
<RequestList
title="Failed Requests"
requests={recentErrors}
onSelect={r => r.id && setSelectedRequest(r.id)}
/>
</div>
)}
{activeTab === "models" && (
<div className="space-y-6 animate-fade-in">
{modelStats ? (
<>
<ChartsContainer modelSeries={modelStats.modelSeries} timeRange={timeRange} />
<ModelsTable
models={modelStats.byModel}
performanceSeries={modelStats.modelPerformanceSeries}
timeRange={timeRange}
/>
</>
) : (
<LoadingState label="Loading models..." />
)}
</div>
)}
{activeTab === "costs" && (
<div className="space-y-6 animate-fade-in">
{costStats ? (
<>
<CostSummary costSeries={costStats.costSeries} />
<CostChart costSeries={costStats.costSeries} />
</>
) : (
<LoadingState label="Loading costs..." />
)}
</div>
)}
{activeTab === "behavior" && (
<div className="space-y-6 animate-fade-in">
{behaviorStats ? (
<>
<BehaviorSummary
overall={behaviorStats.overall}
behaviorSeries={behaviorStats.behaviorSeries}
/>
<BehaviorChart behaviorSeries={behaviorStats.behaviorSeries} />
<BehaviorModelsTable
models={behaviorStats.byModel}
behaviorSeries={behaviorStats.behaviorSeries}
/>
</>
) : (
<LoadingState label="Loading behavior..." />
)}
</div>
)}
{selectedRequest !== null && (
<RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />
)}
</div>
</div>
);
}
function LoadingState({ label }: { label: string }) {
return (
<div className="min-h-[180px] flex items-center justify-center">
<div className="flex items-center gap-3 text-[var(--text-muted)]">
<div className="w-5 h-5 border-2 border-[var(--border-default)] border-t-[var(--accent-cyan)] rounded-full spin" />
<span className="text-sm">{label}</span>
</div>
</div>
<RequestDrawer id={selectedRequestId} onClose={closeDrawer} />
</>
);
}
+55 -37
View File
@@ -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<DashboardStats> {
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<DashboardStats>;
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<OverviewStats> {
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<OverviewStats>;
async function fetchJson<T>(endpoint: string, options?: RequestInit): Promise<T> {
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<T>;
}
export async function getModelDashboardStats(range = "24h"): Promise<ModelDashboardStats> {
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<ModelDashboardStats>;
export async function getOverviewStats(range: TimeRange = "24h", signal?: AbortSignal): Promise<OverviewStats> {
return fetchJson<OverviewStats>(`${API_BASE}/stats/overview?range=${encodeURIComponent(range)}`, {
signal,
});
}
export async function getCostDashboardStats(range = "24h"): Promise<CostDashboardStats> {
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<CostDashboardStats>;
export async function getModelDashboardStats(
range: TimeRange = "24h",
signal?: AbortSignal,
): Promise<ModelDashboardStats> {
return fetchJson<ModelDashboardStats>(`${API_BASE}/stats/model-dashboard?range=${encodeURIComponent(range)}`, {
signal,
});
}
export async function getRecentRequests(limit = 50): Promise<MessageStats[]> {
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<MessageStats[]>;
export async function getCostDashboardStats(
range: TimeRange = "24h",
signal?: AbortSignal,
): Promise<CostDashboardStats> {
return fetchJson<CostDashboardStats>(`${API_BASE}/stats/costs?range=${encodeURIComponent(range)}`, { signal });
}
export async function getRecentErrors(limit = 50): Promise<MessageStats[]> {
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<MessageStats[]>;
export async function getRecentRequests(limit = 50, signal?: AbortSignal): Promise<MessageStats[]> {
return fetchJson<MessageStats[]>(`${API_BASE}/stats/recent?limit=${limit}`, { signal });
}
export async function getRequestDetails(id: number): Promise<RequestDetails> {
const res = await fetch(`${API_BASE}/request/${id}`);
if (!res.ok) throw new Error("Failed to fetch request details");
return res.json() as Promise<RequestDetails>;
export async function getRecentErrors(limit = 50, signal?: AbortSignal): Promise<MessageStats[]> {
return fetchJson<MessageStats[]>(`${API_BASE}/stats/errors?limit=${limit}`, { signal });
}
export async function sync(): Promise<any> {
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<RequestDetails> {
return fetchJson<RequestDetails>(`${API_BASE}/request/${id}`, { signal });
}
export async function getBehaviorDashboardStats(range = "24h"): Promise<BehaviorDashboardStats> {
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<BehaviorDashboardStats>;
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<BehaviorDashboardStats> {
return fetchJson<BehaviorDashboardStats>(`${API_BASE}/stats/behavior?range=${encodeURIComponent(range)}`, {
signal,
});
}
export async function getFolderStats(range: TimeRange = "24h", signal?: AbortSignal): Promise<FolderStats[]> {
return fetchJson<FolderStats[]>(`${API_BASE}/stats/folders?range=${encodeURIComponent(range)}`, { signal });
}
@@ -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 (
<div className="stats-app-container">
{/* Desktop Rail */}
<NavRail activeSection={activeSection} onSectionChange={handleSectionChange} className="stats-desktop-nav" />
{/* Mobile Nav Drawer */}
{menuOpen && (
<div className="stats-mobile-drawer-overlay" onClick={() => setMenuOpen(false)} role="presentation">
<div
className="stats-mobile-drawer"
onClick={e => e.stopPropagation()}
role="dialog"
aria-modal="true"
aria-label="Navigation menu"
>
<div className="stats-mobile-drawer-header">
<div className="stats-logo-container">
<span className="stats-logo-text">OH MY PI</span>
<span className="stats-logo-subtext">Observability</span>
</div>
<button
type="button"
onClick={() => setMenuOpen(false)}
className="stats-drawer-close-btn"
aria-label="Close navigation menu"
>
<X size={18} />
</button>
</div>
<NavRail
activeSection={activeSection}
onSectionChange={handleSectionChange}
className="stats-mobile-nav"
/>
</div>
</div>
)}
{/* Main Layout Pane */}
<div className="stats-main-pane">
<TopBar
activeSection={activeSection}
range={range}
onRangeChange={onRangeChange}
updatedAt={updatedAt}
onSyncStart={onSyncStart}
onSyncComplete={onSyncComplete}
onMenuToggle={() => setMenuOpen(true)}
/>
<main className="stats-content-area">
<div className="stats-content-inner">{children}</div>
</main>
</div>
</div>
);
}
+44
View File
@@ -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 (
<aside className={`stats-nav-rail ${className}`}>
<div className="stats-nav-rail-header">
<div className="stats-logo-container">
<span className="stats-logo-text">OH MY PI</span>
<span className="stats-logo-subtext">Observability</span>
</div>
</div>
<nav className="stats-nav-rail-menu">
{routes.map(route => {
const isActive = route.id === activeSection;
const Icon = route.icon;
return (
<button
key={route.id}
type="button"
onClick={() => onSectionChange(route.id)}
className="stats-nav-rail-item"
data-active={isActive ? "true" : "false"}
aria-current={isActive ? "page" : undefined}
>
<Icon size={16} className="stats-nav-rail-item-icon" />
<span className="stats-nav-rail-item-label">{route.label}</span>
</button>
);
})}
</nav>
<div className="stats-nav-rail-footer">
<span className="stats-version-tag">OMP Stats v1.0.0</span>
</div>
</aside>
);
}
@@ -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 (
<div className={`stats-range-control ${className}`} role="radiogroup" aria-label="Select time range">
{RANGE_OPTIONS.map(opt => {
const isActive = opt.value === value;
return (
<button
key={opt.value}
type="button"
role="radio"
aria-checked={isActive}
data-active={isActive ? "true" : "false"}
className="stats-range-control-btn"
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
);
})}
</div>
);
}
@@ -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 (
<div className={`stats-sync-container ${className}`}>
{status && (
<span className="stats-sync-status-msg" data-type={status.type}>
{status.message}
</span>
)}
<button
type="button"
onClick={handleSync}
disabled={syncing}
className="stats-button stats-button-primary stats-sync-btn"
aria-busy={syncing}
>
<RefreshCw size={14} className={`stats-sync-icon ${syncing ? "stats-spin" : ""}`} />
{syncing ? "Syncing..." : "Sync DB"}
</button>
</div>
);
}
@@ -0,0 +1,37 @@
import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react";
import { type ThemePreference, useThemePreference } from "../useSystemTheme";
const NEXT_PREFERENCE: Record<ThemePreference, ThemePreference> = {
system: "light",
light: "dark",
dark: "system",
};
const PREFERENCE_ICON: Record<ThemePreference, LucideIcon> = {
system: Monitor,
light: Sun,
dark: Moon,
};
const PREFERENCE_LABEL: Record<ThemePreference, string> = {
system: "System theme",
light: "Light theme",
dark: "Dark theme",
};
export function ThemeToggle() {
const { preference, setPreference } = useThemePreference();
const Icon = PREFERENCE_ICON[preference];
return (
<button
type="button"
className="stats-theme-toggle"
onClick={() => setPreference(NEXT_PREFERENCE[preference])}
aria-label={`${PREFERENCE_LABEL[preference]} (click to switch)`}
title={`${PREFERENCE_LABEL[preference]} — click to switch`}
>
<Icon size={16} />
</button>
);
}
+73
View File
@@ -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 (
<header className={`stats-top-bar ${className}`}>
<div className="stats-top-bar-left">
{onMenuToggle && (
<button
type="button"
onClick={onMenuToggle}
className="stats-mobile-menu-btn"
aria-label="Open navigation menu"
>
<Menu size={20} />
</button>
)}
<h1 className="stats-page-title">{title}</h1>
</div>
<div className="stats-top-bar-right">
<div className="stats-top-bar-meta">
<span
className="stats-last-updated"
title={updatedAt ? new Date(updatedAt).toLocaleString() : undefined}
>
{formatLastUpdated(updatedAt)}
</span>
</div>
<RangeControl value={range} onChange={onRangeChange} />
<ThemeToggle />
<SyncButton onSyncStart={onSyncStart} onSyncComplete={onSyncComplete} />
</div>
</header>
);
}
+50
View File
@@ -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,
},
];
@@ -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<BehaviorTimeSeriesPoint, DailyBucket>(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<BehaviorTimeSeriesPoint, DailyBucket>(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<Metric>("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 = (
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
{METRIC_OPTIONS.map(opt => (
<button
key={opt.value}
type="button"
onClick={() => setMetric(opt.value)}
className={`tab-btn text-xs ${metric === opt.value ? "active" : ""}`}
>
{opt.label}
</button>
))}
</div>
);
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 = <Line data={lineData} options={lineOptions} />;
} 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 = <Bar data={barData} options={barOptions} />;
}
return (
<ChartFrame
title="User Tantrums"
subtitle={`${metricLabel} as % of user messages per day`}
empty={chartData.labels.length === 0}
emptyMessage="No behavioral data yet. Sync to scan your sessions."
controls={metricTabs}
byModel={byModel}
onByModelChange={setByModel}
>
{chartNode}
</ChartFrame>
);
}
@@ -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<string | null>(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 (
<ModelTableShell
title="Behavior by Model"
subtitle="How often each model elicited a tantrum — rates are per user message"
>
<ModelTableHeader
gridTemplate={GRID_TEMPLATE}
columns={[
{ label: "Model" },
{ label: "Messages", align: "right" },
{ label: "CAPS %", align: "right" },
{ label: "Profanity %", align: "right" },
{ label: "Anguish %", align: "right" },
{ label: "Frustration %", align: "right" },
{ label: "Hits %", align: "right" },
{ label: "Trend", align: "center" },
]}
/>
<ModelTableBody>
{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 (
<ExpandableModelRow
key={key}
gridTemplate={GRID_TEMPLATE}
isExpanded={isExpanded}
onToggle={() => setExpandedKey(isExpanded ? null : key)}
cells={[
<ModelNameCell key="name" model={model.model} provider={model.provider} />,
<div key="messages" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatInt(model.totalMessages)}
</div>,
<div key="caps" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatRate(model.totalYelling, model.totalMessages)}
</div>,
<div key="profanity" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatRate(model.totalProfanity, model.totalMessages)}
</div>,
<div key="anguish" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatRate(model.totalAnguish, model.totalMessages)}
</div>,
<div key="frustration" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatRate(totalFrustration, model.totalMessages)}
</div>,
<div key="hits" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatRate(totalHits, model.totalMessages)}
</div>,
]}
trendCell={
trend.length === 0 ? (
<TrendEmpty />
) : (
<MiniSparkline
timestamps={trend.map(d => d.timestamp)}
values={trend.map(d => d.total)}
color={trendColor}
/>
)
}
expandedContent={
<div className="grid gap-4" style={{ gridTemplateColumns: "220px 1fr" }}>
<div className="space-y-4 text-sm">
<DetailRow
label="Yelling (CAPS)"
total={model.totalYelling}
messages={model.totalMessages}
valueClass="text-[var(--accent-amber,#fbbf24)]"
/>
<DetailRow
label="Profanity"
total={model.totalProfanity}
messages={model.totalMessages}
valueClass="text-[var(--accent-red,#f87171)]"
/>
<DetailRow
label="Anguish (!!!, nooo, dude, ..)"
total={model.totalAnguish}
messages={model.totalMessages}
valueClass="text-[var(--accent-violet,#a78bfa)]"
/>
<DetailRow
label="Negation (no/nope/wrong)"
total={model.totalNegation}
messages={model.totalMessages}
valueClass="text-[var(--accent-cyan,#22d3ee)]"
/>
<DetailRow
label="Repetition (i meant, still doesnt)"
total={model.totalRepetition}
messages={model.totalMessages}
valueClass="text-[var(--accent-cyan,#22d3ee)]"
/>
<DetailRow
label="Blame (you didnt, stop X-ing)"
total={model.totalBlame}
messages={model.totalMessages}
valueClass="text-[var(--accent-cyan,#22d3ee)]"
/>
<DetailRow
label="Avg chars / msg"
total={model.totalChars}
messages={model.totalMessages}
valueClass="text-[var(--text-secondary)]"
mode="average"
/>
</div>
<div className="h-[200px]">
{trend.length === 0 ? (
<DetailChartEmpty />
) : (
<BreakdownChart data={trend} chartTheme={chartTheme} />
)}
</div>
</div>
}
/>
);
})}
{sortedModels.length === 0 ? (
<div className="border-t border-[var(--border-subtle)] px-5 py-8 text-center text-[var(--text-muted)] text-sm">
No user behavior recorded for this range yet.
</div>
) : null}
</ModelTableBody>
</ModelTableShell>
);
}
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 (
<div>
<div className="text-[var(--text-primary)] font-medium mb-2">{label}</div>
<div className="space-y-1 text-[var(--text-secondary)]">
<div className="flex items-center justify-between">
<span>Total</span>
<span className={`font-mono ${valueClass}`}>{formatInt(total)}</span>
</div>
<div className="flex items-center justify-between">
<span>{perMsgLabel}</span>
<span className="font-mono">{perMsgValue}</span>
</div>
</div>
</div>
);
}
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 <Line data={chartData} options={options} />;
}
/**
* 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<string, ModelTrendSeries> {
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<string, Map<number, DailyPoint>>();
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<string, ModelTrendSeries>();
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;
}
@@ -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<string, { model: string; provider: string; score: number }>();
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 (
<div className="grid grid-cols-2 sm:grid-cols-6 gap-4">
{cards.map(card => (
<div key={card.label} className="surface px-4 py-3">
<p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
<p className="text-lg font-semibold text-[var(--text-primary)] truncate" title={card.value}>
{card.value}
</p>
{card.sub && <p className="text-xs text-[var(--text-muted)] mt-0.5">{card.sub}</p>}
</div>
))}
</div>
);
}
@@ -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 (
<div className="surface overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Model Preference</h3>
<p className="text-xs text-[var(--text-muted)] mt-1">Share of requests over {meta.windowLabel}</p>
</div>
<div className="p-5 min-h-[320px]">
{chartData.data.length === 0 ? (
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
No data available
</div>
) : (
<div className="h-[280px]">
<Line data={data} options={options} />
</div>
)}
</div>
</div>
);
}
function buildModelPreferenceSeries(
points: ModelTimeSeriesPoint[],
topN = 5,
): {
data: Array<Record<string, number>>;
series: string[];
} {
if (points.length === 0) return { data: [], series: [] };
const totals = new Map<string, { model: string; provider: string; total: number }>();
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<string, number>();
for (const entry of topEntries) {
topModelCounts.set(entry.model, (topModelCounts.get(entry.model) ?? 0) + 1);
}
const labelByKey = new Map<string, string>();
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<number, Record<string, number>>();
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 };
}
@@ -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<CostTimeSeriesPoint, { total: number }>(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<CostTimeSeriesPoint, { total: number }>(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 = <Line data={lineData} options={lineOptions} />;
} 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 = <Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />;
}
return (
<ChartFrame
title="Daily Cost"
subtitle="API spending over time"
empty={chartData.labels.length === 0}
emptyMessage="No cost data available"
byModel={byModel}
onByModelChange={setByModel}
>
{chartNode}
</ChartFrame>
);
}
@@ -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<string, number>();
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 (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
{cards.map(card => (
<div key={card.label} className="surface px-4 py-3">
<p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
<p className="text-lg font-semibold text-[var(--text-primary)] truncate" title={card.value}>
{card.value}
</p>
{card.sub && <p className="text-xs text-[var(--text-muted)] mt-0.5">{card.sub}</p>}
</div>
))}
</div>
);
}
@@ -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 (
<header className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 mb-8 border-b border-[var(--border-subtle)]">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-gradient-to-br from-[var(--accent-pink)] to-[var(--accent-cyan)] flex items-center justify-center shadow-lg">
<Activity className="w-5 h-5 text-white" />
</div>
<div>
<h1 className="text-xl font-semibold text-[var(--text-primary)]">AI Usage</h1>
<p className="text-sm text-[var(--text-muted)]">Statistics & Analytics</p>
</div>
</div>
<div className="flex items-center gap-3">
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-md)] p-1 border border-[var(--border-subtle)]">
{tabs.map(tab => (
<button
key={tab}
type="button"
onClick={() => onTabChange(tab)}
className={`tab-btn capitalize ${activeTab === tab ? "active" : ""}`}
>
{tab}
</button>
))}
</div>
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-md)] p-1 border border-[var(--border-subtle)]">
{timeRanges.map(range => (
<button
key={range.value}
type="button"
onClick={() => onTimeRangeChange(range.value)}
className={`tab-btn ${timeRange === range.value ? "active" : ""}`}
title={range.value === "all" ? "All time" : `Last ${range.label}`}
>
{range.label}
</button>
))}
</div>
<button type="button" onClick={onSync} disabled={syncing} className="btn btn-primary">
<RefreshCw size={16} className={syncing ? "spin" : ""} />
{syncing ? "Syncing..." : "Sync"}
</button>
</div>
</header>
);
}
@@ -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<string | null>(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 (
<ModelTableShell title="Model Statistics">
<ModelTableHeader
gridTemplate={GRID_TEMPLATE}
columns={[
{ label: "Model" },
{ label: "Requests", align: "right" },
{ label: "Cost", align: "right" },
{ label: "Tokens", align: "right" },
{ label: "Tokens/s", align: "right" },
{ label: "TTFT", align: "right" },
{ label: meta.trendLabel, align: "center" },
]}
/>
<ModelTableBody>
{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 (
<ExpandableModelRow
key={key}
gridTemplate={GRID_TEMPLATE}
isExpanded={isExpanded}
onToggle={() => setExpandedKey(isExpanded ? null : key)}
cells={[
<ModelNameCell key="name" model={model.model} provider={model.provider} />,
<div key="requests" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{model.totalRequests.toLocaleString()}
</div>,
<div key="cost" className="text-right text-[var(--text-secondary)] font-mono text-sm">
${model.totalCost.toFixed(2)}
</div>,
<div key="tokens" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{(model.totalInputTokens + model.totalOutputTokens).toLocaleString()}
</div>,
<div key="tps" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{model.avgTokensPerSecond?.toFixed(1) ?? "-"}
</div>,
<div key="ttft" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
</div>,
]}
trendCell={
trendData.length === 0 ? (
<TrendEmpty />
) : (
<MiniSparkline
timestamps={trendData.map(d => d.timestamp)}
values={trendData.map(d => d.avgTokensPerSecond ?? 0)}
color={trendColor}
/>
)
}
expandedContent={
<div className="grid gap-4" style={{ gridTemplateColumns: "200px 1fr" }}>
<div className="space-y-4 text-sm">
<div>
<div className="text-[var(--text-primary)] font-medium mb-2">Quality</div>
<div className="space-y-1 text-[var(--text-secondary)]">
<div className="flex items-center justify-between">
<span>Error rate</span>
<span
className={
errorRate > 5 ? "text-[var(--accent-red)]" : "text-[var(--accent-green)]"
}
>
{errorRate.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between">
<span>Cache rate</span>
<span className="text-[var(--accent-cyan)]">
{(model.cacheRate * 100).toFixed(1)}%
</span>
</div>
</div>
</div>
<div>
<div className="text-[var(--text-primary)] font-medium mb-2">Latency</div>
<div className="space-y-1 text-[var(--text-secondary)]">
<div className="flex items-center justify-between">
<span>Avg duration</span>
<span className="font-mono">
{model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Avg TTFT</span>
<span className="font-mono">
{model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
</span>
</div>
</div>
</div>
</div>
<div className="h-[200px]">
{trendData.length === 0 ? (
<DetailChartEmpty />
) : (
<PerformanceChart data={trendData} color={trendColor} chartTheme={chartTheme} />
)}
</div>
</div>
}
/>
);
})}
</ModelTableBody>
</ModelTableShell>
);
}
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 <Line data={chartData} options={options} />;
}
function buildModelPerformanceLookup(
points: ModelPerformancePoint[],
bucketCount: number,
bucketMs: number,
): Map<string, ModelPerformanceSeries> {
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<string, ModelPerformanceSeries>();
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;
}
@@ -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<RequestDetails | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getRequestDetails(id)
.then(setDetails)
.catch(console.error)
.finally(() => setLoading(false));
}, [id]);
if (!details && loading) {
return (
<div className="fixed inset-0 bg-[var(--bg-overlay)] flex justify-center items-center z-[100]">
<div className="surface px-8 py-6">
<div className="flex items-center gap-3 text-[var(--text-secondary)]">
<div className="w-5 h-5 border-2 border-[var(--border-default)] border-t-[var(--accent-cyan)] rounded-full spin" />
<span>Loading...</span>
</div>
</div>
</div>
);
}
if (!details) return null;
return (
<div
role="presentation"
className="fixed inset-0 bg-[var(--bg-overlay)] backdrop-blur-sm flex justify-end z-[100] animate-fade-in"
onClick={onClose}
>
<div
role="dialog"
aria-modal="true"
className="w-[600px] max-w-full bg-[var(--bg-page)] h-full overflow-y-auto border-l border-[var(--border-subtle)] animate-slide-up"
onClick={e => e.stopPropagation()}
>
{/* Header */}
<div className="sticky top-0 bg-[var(--bg-page)]/95 backdrop-blur border-b border-[var(--border-subtle)] px-6 py-4 flex justify-between items-center z-10">
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-[var(--radius-sm)] bg-gradient-to-br from-[var(--accent-pink)]/20 to-[var(--accent-cyan)]/20 flex items-center justify-center">
<FileJson size={16} className="text-[var(--accent-cyan)]" />
</div>
<h2 className="text-lg font-semibold text-[var(--text-primary)]">Request Details</h2>
</div>
<button
type="button"
onClick={onClose}
className="p-2 rounded-[var(--radius-sm)] text-[var(--text-muted)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)] transition-colors"
>
<X size={20} />
</button>
</div>
<div className="p-6 space-y-6">
{/* Model Info */}
<div className="surface p-5">
<div className="flex items-center justify-between mb-4">
<div>
<div className="text-2xl font-bold text-[var(--text-primary)]">{details.model}</div>
<div className="text-sm text-[var(--text-muted)]">{details.provider}</div>
</div>
{details.errorMessage ? (
<span className="badge badge-error">Error</span>
) : (
<span className="badge badge-success">Success</span>
)}
</div>
</div>
{/* Stats Grid */}
<div className="grid grid-cols-2 gap-4">
<div className="surface p-4">
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-2">
<Coins size={14} />
<span className="text-xs uppercase tracking-wide">Cost</span>
</div>
<div className="text-xl font-semibold text-[var(--text-primary)]">
${details.usage.cost.total.toFixed(4)}
</div>
</div>
<div className="surface p-4">
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-2">
<Star size={14} />
<span className="text-xs uppercase tracking-wide">Premium Reqs</span>
</div>
<div className="text-xl font-semibold text-[var(--text-primary)]">
{(details.usage.premiumRequests ?? 0).toLocaleString()}
</div>
</div>
<div className="surface p-4">
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-2">
<Hash size={14} />
<span className="text-xs uppercase tracking-wide">Tokens</span>
</div>
<div className="text-xl font-semibold text-[var(--text-primary)]">
{details.usage.totalTokens.toLocaleString()}
</div>
<div className="text-xs text-[var(--text-muted)] mt-1">
{details.usage.input.toLocaleString()} in · {details.usage.output.toLocaleString()} out
</div>
</div>
<div className="surface p-4">
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-2">
<Clock size={14} />
<span className="text-xs uppercase tracking-wide">Duration</span>
</div>
<div className="text-xl font-semibold text-[var(--text-primary)]">
{details.duration ? `${(details.duration / 1000).toFixed(2)}s` : "-"}
</div>
</div>
<div className="surface p-4">
<div className="flex items-center gap-2 text-[var(--text-muted)] mb-2">
<Zap size={14} />
<span className="text-xs uppercase tracking-wide">TTFT</span>
</div>
<div className="text-xl font-semibold text-[var(--text-primary)]">
{details.ttft ? `${(details.ttft / 1000).toFixed(2)}s` : "-"}
</div>
</div>
</div>
{/* Tokens/Sec */}
{details.duration && details.usage.output > 0 && (
<div className="surface p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[var(--text-muted)]">
<Gauge size={14} />
<span className="text-xs uppercase tracking-wide">Throughput</span>
</div>
<span className="text-2xl font-bold gradient-text">
{((details.usage.output * 1000) / details.duration).toFixed(1)}
</span>
</div>
<div className="text-xs text-[var(--text-muted)] mt-1 text-right">tokens/second</div>
</div>
)}
{/* Output */}
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-3">Output</h3>
<pre className="surface bg-[var(--bg-elevated)] p-4 rounded-[var(--radius-md)] text-sm font-mono text-[var(--text-secondary)] overflow-x-auto">
{JSON.stringify(details.output, null, 2)}
</pre>
</div>
{/* Raw Metadata */}
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)] mb-3">Raw Metadata</h3>
<pre className="surface bg-[var(--bg-elevated)] p-4 rounded-[var(--radius-md)] text-xs font-mono text-[var(--text-muted)] overflow-x-auto">
{JSON.stringify(details, null, 2)}
</pre>
</div>
</div>
</div>
</div>
);
}
@@ -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 (
<div className="surface overflow-hidden flex flex-col h-full">
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
</div>
<div className="overflow-auto flex-1">
<table className="w-full">
<thead className="bg-[var(--bg-elevated)] sticky top-0 z-10">
<tr>
<th className="text-left py-3 px-4 table-header">Model</th>
<th className="text-left py-3 px-4 table-header">Time</th>
<th className="text-right py-3 px-4 table-header">Tokens</th>
<th className="text-right py-3 px-4 table-header">Cost</th>
<th className="text-right py-3 px-4 table-header">Duration</th>
<th className="text-center py-3 px-4 table-header">Status</th>
</tr>
</thead>
<tbody>
{requests.map(req => (
<tr
key={`${req.sessionFile}-${req.entryId}`}
onClick={() => onSelect(req)}
className="table-row cursor-pointer border-b border-[var(--border-subtle)] last:border-b-0"
>
<td className="py-3 px-4">
<div className="font-medium text-[var(--text-primary)] text-sm">{req.model}</div>
<div className="text-xs text-[var(--text-muted)]">{req.provider}</div>
</td>
<td className="py-3 px-4 text-sm text-[var(--text-secondary)]">
{formatDistanceToNow(req.timestamp, { addSuffix: true })}
</td>
<td className="py-3 px-4 text-right text-sm text-[var(--text-secondary)] font-mono">
{req.usage.totalTokens.toLocaleString()}
</td>
<td className="py-3 px-4 text-right text-sm text-[var(--text-secondary)] font-mono">
${req.usage.cost.total.toFixed(4)}
</td>
<td className="py-3 px-4 text-right text-sm text-[var(--text-secondary)] font-mono">
{req.duration ? `${(req.duration / 1000).toFixed(1)}s` : "-"}
</td>
<td className="py-3 px-4 text-center">
{req.errorMessage ? (
<XCircle size={16} className="text-[var(--accent-red)] mx-auto" />
) : (
<CheckCircle2 size={16} className="text-[var(--accent-green)] mx-auto" />
)}
</td>
</tr>
))}
{requests.length === 0 && (
<tr>
<td colSpan={6} className="py-12 text-center text-[var(--text-muted)] text-sm">
No requests found
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
);
}
@@ -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 (
<div className="grid grid-cols-2 lg:grid-cols-3 xl:grid-cols-9 gap-4 mb-8">
{statConfig.map(stat => {
const Icon = stat.icon;
return (
<div key={stat.key} className="stat-card group">
<div className="flex items-center justify-between mb-3">
<span className="text-sm font-medium text-[var(--text-secondary)]">{stat.title}</span>
<div
className="p-2 rounded-[var(--radius-sm)] transition-colors"
style={{ backgroundColor: `${stat.color}15` }}
>
<Icon
size={18}
style={{ color: stat.color }}
className="transition-transform group-hover:scale-110"
/>
</div>
</div>
<div className="text-2xl font-bold text-[var(--text-primary)] mb-1">{stat.getValue(stats)}</div>
<div className="text-xs text-[var(--text-muted)] truncate">{stat.getDetail(stats)}</div>
</div>
);
})}
</div>
);
}
@@ -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<T extends ModelKeyedPoint, B>(
})),
};
}
/** All Models / By Model segmented toggle — identical UI in every time chart. */
function ByModelToggle({ byModel, onChange }: { byModel: boolean; onChange: (v: boolean) => void }) {
return (
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
<button
type="button"
onClick={() => onChange(false)}
className={`tab-btn text-xs ${!byModel ? "active" : ""}`}
>
All Models
</button>
<button type="button" onClick={() => onChange(true)} className={`tab-btn text-xs ${byModel ? "active" : ""}`}>
By Model
</button>
</div>
);
}
/**
* 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 (
<div className="surface overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
<div>
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
<p className="text-xs text-[var(--text-muted)] mt-1">{subtitle}</p>
</div>
<div className="flex items-center gap-2 flex-wrap">
{controls}
<ByModelToggle byModel={byModel} onChange={onByModelChange} />
</div>
</div>
<div className="p-5 min-h-[320px]">
{empty ? (
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
{emptyMessage}
</div>
) : (
<div className="h-[280px]">{children}</div>
)}
</div>
</div>
);
}
@@ -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 (
<div className="surface overflow-hidden">
<div className="stats-panel overflow-hidden">
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
{subtitle ? <p className="text-xs text-[var(--text-muted)] mt-1">{subtitle}</p> : null}
@@ -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<TimeRange, RangeMeta> = {
"1h": {
windowLabel: "the last hour",
trendLabel: "1h Trend",
bucketMs: HOUR_MS,
bucketCount: 1,
bucketMs: FIVE_MIN_MS,
bucketCount: 12,
tickFormat: "HH:mm",
},
"24h": {
+14
View File
@@ -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);
@@ -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 });
}
@@ -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,
};
}
@@ -0,0 +1,154 @@
import { useCallback, useEffect, useRef, useState } from "react";
export interface ResourceResult<T> {
data: T | null;
error: Error | null;
loading: boolean;
refreshing: boolean;
refetch: () => Promise<void>;
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<string, { data: unknown; updatedAt: number }>();
const RESOURCE_CACHE_LIMIT = 64;
export function useResource<T>(
key: readonly unknown[],
fetcher: (signal: AbortSignal) => Promise<T>,
options?: ResourceOptions,
): ResourceResult<T> {
const keyString = JSON.stringify(key);
const [data, setData] = useState<T | null>(() => (resourceCache.get(keyString)?.data as T | undefined) ?? null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(() => !resourceCache.has(keyString));
const [refreshing, setRefreshing] = useState(false);
const [updatedAt, setUpdatedAt] = useState<number | null>(() => 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<AbortController | null>(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,
};
}
@@ -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<string, number>();
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<string, ModelPerformanceSeries> {
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<string, ModelPerformanceSeries>();
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<string, { model: string; provider: string; score: number }>();
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,
}));
}
+4
View File
@@ -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";
@@ -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 (
<div className="stats-route-container space-y-6">
<AsyncBoundary loading={loading} error={error} data={stats}>
{stats && (
<>
<BehaviorSummaryPanel overall={stats.overall} behaviorSeries={stats.behaviorSeries} />
<BehaviorChartPanel behaviorSeries={stats.behaviorSeries} />
<BehaviorModelsTable models={stats.byModel} behaviorSeries={stats.behaviorSeries} />
</>
)}
</AsyncBoundary>
</div>
);
}
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 (
<div className="grid grid-cols-2 md:grid-cols-6 gap-4">
{cards.map(card => (
<Panel key={card.label} className="stats-behavior-summary-card py-3 px-4">
<p className="text-xs stats-text-muted mb-1 font-medium truncate">{card.label}</p>
<p className="text-lg font-bold stats-text-primary truncate" title={card.value}>
{card.value}
</p>
{card.sub && <p className="text-xs stats-text-muted mt-0.5">{card.sub}</p>}
</Panel>
))}
</div>
);
}
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<Metric>("total");
const theme = useSystemTheme();
const chartTheme = CHART_THEMES[theme];
const chartData = useMemo(() => {
if (byModel) {
return buildTopNByModelSeries<BehaviorTimeSeriesPoint, DailyBucket>(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<BehaviorTimeSeriesPoint, DailyBucket>(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 (
<Panel
title="User Friction Signals"
subtitle={`${metricLabel} as % of user messages per day`}
actions={
<div className="flex items-center gap-3 flex-wrap">
<SegmentedControl
options={METRIC_OPTIONS.map(o => ({
value: o.value,
label: o.label,
title: o.title,
}))}
value={metric}
onChange={setMetric}
/>
<SegmentedControl options={byModelOptions} value={byModel} onChange={setByModel} />
</div>
}
>
<div className="h-[300px]">
{chartData.labels.length === 0 ? (
<div className="h-full flex items-center justify-center text-stats-muted text-sm">
No friction signal data available
</div>
) : byModel && lineData ? (
<Line data={lineData} options={lineOptions} />
) : barData ? (
<Bar data={barData} options={barOptions} />
) : null}
</div>
</Panel>
);
}
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<string | null>(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 (
<ModelTableShell title="Behavior Signals by Model" subtitle="Rates are per user message">
<ModelTableHeader
gridTemplate={TABLE_GRID_TEMPLATE}
columns={[
{ label: "Model" },
{ label: "Messages", align: "right" },
{ label: "CAPS %", align: "right" },
{ label: "Profanity %", align: "right" },
{ label: "Anguish %", align: "right" },
{ label: "Frustration %", align: "right" },
{ label: "Hits %", align: "right" },
{ label: "Trend", align: "center" },
]}
/>
<ModelTableBody>
{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 (
<ExpandableModelRow
key={key}
gridTemplate={TABLE_GRID_TEMPLATE}
isExpanded={isExpanded}
onToggle={() => setExpandedKey(isExpanded ? null : key)}
cells={[
<ModelNameCell key="name" model={model.model} provider={model.provider} />,
<div key="messages" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatInteger(model.totalMessages)}
</div>,
<div key="caps" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatRate(model.totalYelling, model.totalMessages)}
</div>,
<div key="profanity" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatRate(model.totalProfanity, model.totalMessages)}
</div>,
<div key="anguish" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatRate(model.totalAnguish, model.totalMessages)}
</div>,
<div key="frustration" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatRate(totalFrustration, model.totalMessages)}
</div>,
<div key="hits" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{formatRate(totalHits, model.totalMessages)}
</div>,
]}
trendCell={
trend.length === 0 ? (
<TrendEmpty />
) : (
<MiniSparkline
timestamps={trend.map(d => d.timestamp)}
values={trend.map(d => d.total)}
color={trendColor}
/>
)
}
expandedContent={
<div className="grid gap-4" style={{ gridTemplateColumns: "220px 1fr" }}>
<div className="space-y-4 text-sm">
<DetailRow
label="Yelling (CAPS)"
total={model.totalYelling}
messages={model.totalMessages}
valueClass="text-[#ed4abf]"
/>
<DetailRow
label="Profanity"
total={model.totalProfanity}
messages={model.totalMessages}
valueClass="text-[#ff6b7d]"
/>
<DetailRow
label="Anguish (!!!, nooo, dude, ..)"
total={model.totalAnguish}
messages={model.totalMessages}
valueClass="text-[#9b4dff]"
/>
<DetailRow
label="Negation (no/nope/wrong)"
total={model.totalNegation}
messages={model.totalMessages}
valueClass="text-[#5ad8e6]"
/>
<DetailRow
label="Repetition (i meant, still doesnt)"
total={model.totalRepetition}
messages={model.totalMessages}
valueClass="text-[#5ad8e6]"
/>
<DetailRow
label="Blame (you didnt, stop X-ing)"
total={model.totalBlame}
messages={model.totalMessages}
valueClass="text-[#5ad8e6]"
/>
<DetailRow
label="Avg chars / msg"
total={model.totalChars}
messages={model.totalMessages}
valueClass="stats-text-secondary"
mode="average"
/>
</div>
<div className="h-[200px]">
{trend.length === 0 ? (
<DetailChartEmpty />
) : (
<BreakdownChart data={trend} chartTheme={chartTheme} />
)}
</div>
</div>
}
/>
);
})}
{sortedModels.length === 0 ? (
<div className="border-t border-[var(--border-subtle)] px-5 py-8 text-center text-[var(--text-muted)] text-sm">
No user behavior recorded for this range yet.
</div>
) : null}
</ModelTableBody>
</ModelTableShell>
);
}
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 (
<div>
<div className="text-[var(--text-primary)] font-medium mb-1">{label}</div>
<div className="space-y-0.5 text-[var(--text-secondary)]">
<div className="flex items-center justify-between">
<span className="stats-text-muted text-xs">Total</span>
<span className={`font-mono text-xs ${valueClass}`}>{formatInteger(total)}</span>
</div>
<div className="flex items-center justify-between">
<span className="stats-text-muted text-xs">{perMsgLabel}</span>
<span className="font-mono text-xs stats-text-primary">{perMsgValue}</span>
</div>
</div>
</div>
);
}
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 <Line data={chartData} options={options} />;
}
interface DailyPoint {
timestamp: number;
yelling: number;
profanity: number;
anguish: number;
frustration: number;
total: number;
}
interface ModelTrendSeries {
data: DailyPoint[];
}
function buildTrendLookup(points: BehaviorTimeSeriesPoint[]): Map<string, ModelTrendSeries> {
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<string, Map<number, DailyPoint>>();
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<string, ModelTrendSeries>();
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;
}
@@ -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 (
<div className="stats-route-container space-y-6">
<AsyncBoundary loading={loading} error={error} data={costStats}>
{costStats && (
<>
<CostOverviewPanel costSeries={costStats.costSeries} />
<CostTrendPanel costSeries={costStats.costSeries} />
</>
)}
</AsyncBoundary>
</div>
);
}
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 (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{cards.map(card => (
<Panel key={card.label} className="stats-cost-overview-card py-4 px-5">
<p className="text-xs stats-text-muted mb-1 font-medium uppercase tracking-wider">{card.label}</p>
<p className="text-2xl font-bold stats-text-primary truncate" title={card.value}>
{card.value}
</p>
{card.sub && <p className="text-xs stats-text-muted mt-1 font-medium">Total spent: {card.sub}</p>}
</Panel>
))}
</div>
);
}
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<CostTimeSeriesPoint, { total: number }>(costSeries, {
rankWeight: point => point.cost,
initBucket: () => ({ total: 0 }),
accumulate: (bucket, point) => {
bucket.total += point.cost;
},
bucketToValue: bucket => bucket.total,
});
}
return buildAggregateTimeSeries<CostTimeSeriesPoint, { total: number }>(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 (
<Panel
title="Daily Cost"
subtitle="API spending over time"
actions={<SegmentedControl options={toggleOptions} value={byModel} onChange={setByModel} />}
>
<div className="h-[300px]">
{chartData.labels.length === 0 ? (
<div className="h-full flex items-center justify-center text-stats-muted text-sm">
No cost data available
</div>
) : byModel && lineData ? (
<Line data={lineData} options={lineOptions} />
) : barData ? (
<Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />
) : null}
</div>
</Panel>
);
}
@@ -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) => (
<div>
<div className="stats-font-medium stats-text-primary">{item.model}</div>
<div className="stats-text-xs stats-text-muted">{item.provider}</div>
</div>
),
},
{
key: "timestamp",
header: "Time",
render: (item: MessageStats) => formatRelativeTime(item.timestamp),
},
{
key: "errorMessage",
header: "Error Message",
render: (item: MessageStats) => (
<div
className="stats-text-xs stats-text-danger stats-truncate stats-max-w-md stats-font-mono"
title={item.errorMessage || ""}
>
{item.errorMessage || "Unknown error"}
</div>
),
},
{
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) => (
<div className="stats-mobile-card stats-border-danger" onClick={onClick}>
<div className="stats-mobile-card-header">
<div>
<div className="stats-font-semibold stats-text-primary">{item.model}</div>
<div className="stats-text-xs stats-text-muted">{item.provider}</div>
</div>
<StatusPill variant="danger">Failed</StatusPill>
</div>
<div className="stats-mobile-card-grid">
<div>
<div className="stats-mobile-card-label">Time</div>
<div className="stats-mobile-card-value">{formatRelativeTime(item.timestamp)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Cost</div>
<div className="stats-mobile-card-value">{formatCost(item.usage.cost.total, 4)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Tokens</div>
<div className="stats-mobile-card-value">{formatInteger(item.usage.totalTokens)}</div>
</div>
</div>
{item.errorMessage && <div className="stats-mobile-card-error mt-2 stats-font-mono">{item.errorMessage}</div>}
</div>
);
return (
<div className="stats-route-container">
<Panel title="Recent Errors" subtitle="Up to 50 most recent failed requests in the stats database">
<AsyncBoundary
loading={loading}
error={error}
data={recentErrors}
emptyText="No recent failures in the local stats database"
>
<DataTable
columns={columns}
data={recentErrors || []}
keyExtractor={item => item.id || `${item.sessionFile}-${item.entryId}`}
onRowClick={item => item.id && onRequestClick(item.id)}
renderMobileCard={renderMobileCard}
emptyText="No recent failures in the local stats database"
/>
</AsyncBoundary>
</Panel>
</div>
);
}
@@ -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 (
<div className="stats-route-container space-y-6">
<AsyncBoundary loading={loading} error={error} data={modelStats}>
{modelStats && (
<>
<ModelShareChart modelSeries={modelStats.modelSeries} timeRange={range} />
<ModelsTable
models={modelStats.byModel}
performanceSeries={modelStats.modelPerformanceSeries}
timeRange={range}
/>
</>
)}
</AsyncBoundary>
</div>
);
}
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 (
<Panel title="Model Preference" subtitle={`Share of requests over ${meta.windowLabel}`}>
<div className="h-[280px]">
{chartData.data.length === 0 ? (
<div className="h-full flex items-center justify-center text-stats-muted text-sm">No data available</div>
) : (
<Line data={data} options={options} />
)}
</div>
</Panel>
);
}
function buildModelPreferenceSeries(
points: ModelTimeSeriesPoint[],
topN = 5,
): {
data: Array<Record<string, number>>;
series: string[];
} {
if (points.length === 0) return { data: [], series: [] };
const totals = new Map<string, { model: string; provider: string; total: number }>();
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<string, number>();
for (const entry of topEntries) {
topModelCounts.set(entry.model, (topModelCounts.get(entry.model) ?? 0) + 1);
}
const labelByKey = new Map<string, string>();
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<number, Record<string, number>>();
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<string | null>(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 (
<ModelTableShell title="Model Statistics">
<ModelTableHeader
gridTemplate={GRID_TEMPLATE}
columns={[
{ label: "Model" },
{ label: "Requests", align: "right" },
{ label: "Cost", align: "right" },
{ label: "Tokens", align: "right" },
{ label: "Tokens/s", align: "right" },
{ label: "TTFT", align: "right" },
{ label: meta.trendLabel, align: "center" },
]}
/>
<ModelTableBody>
{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 (
<ExpandableModelRow
key={key}
gridTemplate={GRID_TEMPLATE}
isExpanded={isExpanded}
onToggle={() => setExpandedKey(isExpanded ? null : key)}
cells={[
<ModelNameCell key="name" model={model.model} provider={model.provider} />,
<div key="requests" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{model.totalRequests.toLocaleString()}
</div>,
<div key="cost" className="text-right text-[var(--text-secondary)] font-mono text-sm">
${model.totalCost.toFixed(2)}
</div>,
<div key="tokens" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{(model.totalInputTokens + model.totalOutputTokens).toLocaleString()}
</div>,
<div key="tps" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{model.avgTokensPerSecond?.toFixed(1) ?? "-"}
</div>,
<div key="ttft" className="text-right text-[var(--text-secondary)] font-mono text-sm">
{model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
</div>,
]}
trendCell={
trendData.length === 0 ? (
<TrendEmpty />
) : (
<MiniSparkline
timestamps={trendData.map(d => d.timestamp)}
values={trendData.map(d => d.avgTokensPerSecond ?? 0)}
color={trendColor}
/>
)
}
expandedContent={
<div className="grid gap-4" style={{ gridTemplateColumns: "200px 1fr" }}>
<div className="space-y-4 text-sm">
<div>
<div className="text-[var(--text-primary)] font-medium mb-2">Quality</div>
<div className="space-y-1 text-[var(--text-secondary)]">
<div className="flex items-center justify-between">
<span>Error rate</span>
<span
className={
errorRate > 5 ? "text-[var(--accent-red)]" : "text-[var(--accent-green)]"
}
>
{errorRate.toFixed(1)}%
</span>
</div>
<div className="flex items-center justify-between">
<span>Cache rate</span>
<span className="text-[var(--accent-cyan)]">
{(model.cacheRate * 100).toFixed(1)}%
</span>
</div>
</div>
</div>
<div>
<div className="text-[var(--text-primary)] font-medium mb-2">Latency</div>
<div className="space-y-1 text-[var(--text-secondary)]">
<div className="flex items-center justify-between">
<span>Avg duration</span>
<span className="font-mono">
{model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Avg TTFT</span>
<span className="font-mono">
{model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
</span>
</div>
</div>
</div>
</div>
<div className="h-[200px]">
{trendData.length === 0 ? (
<DetailChartEmpty />
) : (
<PerformanceChart
data={trendData}
color={trendColor}
chartTheme={chartTheme}
timeRange={timeRange}
/>
)}
</div>
</div>
}
/>
);
})}
</ModelTableBody>
</ModelTableShell>
);
}
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 <Line data={chartData} options={options} />;
}
@@ -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) => (
<div>
<div className="stats-font-medium stats-text-primary">{item.model}</div>
<div className="stats-text-xs stats-text-muted">{item.provider}</div>
</div>
),
},
{
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) => (
<StatusPill variant={item.errorMessage ? "danger" : "success"}>
{item.errorMessage ? "Failed" : "Success"}
</StatusPill>
),
},
],
[],
);
const renderMobileCard = (item: MessageStats, onClick?: () => void) => (
<div className="stats-mobile-card" onClick={onClick}>
<div className="stats-mobile-card-header">
<div>
<div className="stats-font-semibold stats-text-primary">{item.model}</div>
<div className="stats-text-xs stats-text-muted">{item.provider}</div>
</div>
<StatusPill variant={item.errorMessage ? "danger" : "success"}>
{item.errorMessage ? "Failed" : "Success"}
</StatusPill>
</div>
<div className="stats-mobile-card-grid">
<div>
<div className="stats-mobile-card-label">Time</div>
<div className="stats-mobile-card-value">{formatRelativeTime(item.timestamp)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Cost</div>
<div className="stats-mobile-card-value">{formatCost(item.usage.cost.total, 4)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Tokens</div>
<div className="stats-mobile-card-value">{formatInteger(item.usage.totalTokens)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Duration</div>
<div className="stats-mobile-card-value">{formatDurationMs(item.duration)}</div>
</div>
</div>
{item.errorMessage && <div className="stats-mobile-card-error truncate mt-2">{item.errorMessage}</div>}
</div>
);
const previewRequests = useMemo(() => {
if (!recentRequests) return [];
return recentRequests.slice(0, 10);
}, [recentRequests]);
return (
<div className="stats-route-container space-y-6">
<AsyncBoundary loading={overviewLoading} error={overviewError} data={overview}>
{overview && <MetricCluster stats={overview.overall} />}
</AsyncBoundary>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-2">
<Panel title="System Throughput" subtitle="Request volume and errors over time">
<AsyncBoundary loading={overviewLoading} error={overviewError} data={overview}>
<div className="h-[280px]">
{overview?.timeSeries && overview.timeSeries.length > 0 ? (
<Line data={chartData} options={chartOptions} />
) : (
<div className="h-full flex items-center justify-center text-stats-muted text-sm">
No time-series data available
</div>
)}
</div>
</AsyncBoundary>
</Panel>
</div>
<div>
<Panel title="Operational Feed" subtitle="Real-time request log">
<AsyncBoundary
loading={requestsLoading}
error={requestsError}
data={recentRequests}
fallback={
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-3">
<Skeleton variant="circle" width={10} height={10} />
<div className="flex-1">
<Skeleton variant="text" width="60%" height={16} />
<Skeleton variant="text" width="40%" height={12} />
</div>
</div>
))}
</div>
}
>
<div className="stats-feed-ledger overflow-y-auto max-h-[280px] pr-2">
{previewRequests.map(req => {
const isError = !!req.errorMessage;
return (
<div
key={req.id || `${req.sessionFile}-${req.entryId}`}
className="stats-feed-item flex items-start gap-3 p-2 rounded hover:bg-stats-surface-2 cursor-pointer transition-colors"
onClick={() => req.id && onRequestClick(req.id)}
>
<div
className={`w-2 h-2 mt-1.5 rounded-full flex-shrink-0 ${
isError ? "bg-stats-danger" : "bg-stats-success"
}`}
/>
<div className="flex-1 min-w-0">
<div className="flex justify-between items-baseline gap-2">
<div className="stats-font-medium stats-text-primary text-sm truncate">
{req.model}
</div>
<div className="stats-text-xs stats-text-muted whitespace-nowrap">
{formatRelativeTime(req.timestamp)}
</div>
</div>
<div className="flex justify-between items-center text-xs stats-text-muted mt-0.5">
<div>{req.provider}</div>
<div>
{req.duration ? formatDurationMs(req.duration) : ""}{" "}
{req.usage?.cost?.total ? `· ${formatCost(req.usage.cost.total, 4)}` : ""}
</div>
</div>
{isError && (
<div className="text-xs text-stats-danger truncate mt-1">{req.errorMessage}</div>
)}
</div>
</div>
);
})}
{previewRequests.length === 0 && (
<div className="py-8 text-center stats-text-muted text-sm">No recent requests found</div>
)}
</div>
</AsyncBoundary>
</Panel>
</div>
</div>
<Panel
title="Recent Requests Preview"
subtitle="Latest transactions processed by the proxy"
actions={
<a href={`#/requests?range=${range}`} className="stats-button stats-button-secondary text-xs">
View All Requests
</a>
}
>
<AsyncBoundary loading={requestsLoading} error={requestsError} data={recentRequests}>
<DataTable
columns={columns}
data={previewRequests}
keyExtractor={item => item.id || `${item.sessionFile}-${item.entryId}`}
onRowClick={item => item.id && onRequestClick(item.id)}
renderMobileCard={renderMobileCard}
emptyText="No recent requests found"
/>
</AsyncBoundary>
</Panel>
</div>
);
}
@@ -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) => (
<div
className="stats-font-medium stats-text-primary truncate max-w-[440px]"
title={item.folder || "(root)"}
>
{item.folder || "(root)"}
</div>
),
},
{
key: "totalRequests",
header: "Requests",
numeric: true,
render: (item: FolderRowView) => (
<div className="stats-text-right">
<div className="font-mono">{formatInteger(item.totalRequests)}</div>
<div className="stats-progress-bar-track mt-1 ml-auto w-24 h-1">
<div
className="stats-progress-bar-fill"
data-variant="link"
style={{ width: `${item.requestsPercentage}%` }}
/>
</div>
</div>
),
},
{
key: "totalCost",
header: "Cost",
numeric: true,
render: (item: FolderRowView) => (
<div className="stats-text-right">
<div className="font-mono">{formatCost(item.totalCost)}</div>
<div className="stats-progress-bar-track mt-1 ml-auto w-24 h-1">
<div
className="stats-progress-bar-fill"
data-variant="success"
style={{ width: `${item.costPercentage}%` }}
/>
</div>
</div>
),
},
{
key: "totalTokens",
header: "Tokens",
numeric: true,
render: (item: FolderRowView) => (
<div className="font-mono">{formatInteger(item.totalInputTokens + item.totalOutputTokens)}</div>
),
},
{
key: "cacheRate",
header: "Cache Rate",
numeric: true,
render: (item: FolderRowView) => (
<span className="stats-text-success font-medium">{formatPercent(item.cacheRate)}</span>
),
},
{
key: "errorRate",
header: "Error Rate",
numeric: true,
render: (item: FolderRowView) => (
<StatusPill variant={item.errorRate > 0.1 ? "danger" : item.errorRate > 0 ? "warning" : "success"}>
{formatPercent(item.errorRate)}
</StatusPill>
),
},
{
key: "avgDuration",
header: "Avg Duration",
numeric: true,
render: (item: FolderRowView) => formatDurationMs(item.avgDuration),
},
],
[],
);
const renderMobileCard = (item: FolderRowView) => (
<div className="stats-mobile-card">
<div className="stats-mobile-card-header mb-2">
<div className="stats-font-semibold stats-text-primary">{item.folder || "(root)"}</div>
<StatusPill variant={item.errorRate > 0.1 ? "danger" : item.errorRate > 0 ? "warning" : "success"}>
{formatPercent(item.errorRate)} Err
</StatusPill>
</div>
<div className="stats-mobile-card-grid">
<div>
<div className="stats-mobile-card-label">Requests</div>
<div className="stats-mobile-card-value font-mono">{formatInteger(item.totalRequests)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Cost</div>
<div className="stats-mobile-card-value font-mono">{formatCost(item.totalCost)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Cache</div>
<div className="stats-mobile-card-value">{formatPercent(item.cacheRate)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Duration</div>
<div className="stats-mobile-card-value">{formatDurationMs(item.avgDuration)}</div>
</div>
</div>
</div>
);
return (
<div className="stats-route-container">
<Panel title="Projects & Folders" subtitle="Aggregate proxy metrics grouped by folder path">
<AsyncBoundary
loading={loading}
error={error}
data={foldersData}
emptyText="No project folders recorded for this range."
>
<DataTable
columns={columns}
data={folderRows}
keyExtractor={item => item.folder}
renderMobileCard={renderMobileCard}
emptyText="No project folders recorded for this range."
/>
</AsyncBoundary>
</Panel>
</div>
);
}
@@ -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) => (
<div>
<div className="stats-font-medium stats-text-primary">{item.model}</div>
<div className="stats-text-xs stats-text-muted">{item.provider}</div>
</div>
),
},
{
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) => (
<StatusPill variant={item.errorMessage ? "danger" : "success"}>
{item.errorMessage ? "Failed" : "Success"}
</StatusPill>
),
},
],
[],
);
const renderMobileCard = (item: MessageStats, onClick?: () => void) => (
<div className="stats-mobile-card" onClick={onClick}>
<div className="stats-mobile-card-header">
<div>
<div className="stats-font-semibold stats-text-primary">{item.model}</div>
<div className="stats-text-xs stats-text-muted">{item.provider}</div>
</div>
<StatusPill variant={item.errorMessage ? "danger" : "success"}>
{item.errorMessage ? "Failed" : "Success"}
</StatusPill>
</div>
<div className="stats-mobile-card-grid">
<div>
<div className="stats-mobile-card-label">Time</div>
<div className="stats-mobile-card-value">{formatRelativeTime(item.timestamp)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Cost</div>
<div className="stats-mobile-card-value">{formatCost(item.usage.cost.total, 4)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Tokens</div>
<div className="stats-mobile-card-value">{formatInteger(item.usage.totalTokens)}</div>
</div>
<div>
<div className="stats-mobile-card-label">Duration</div>
<div className="stats-mobile-card-value">{formatDurationMs(item.duration)}</div>
</div>
</div>
{item.errorMessage && <div className="stats-mobile-card-error truncate mt-2">{item.errorMessage}</div>}
</div>
);
return (
<div className="stats-route-container">
<Panel title="All Recent Requests" subtitle="Up to 50 most recent requests processed by OMP">
<AsyncBoundary loading={loading} error={error} data={recentRequests}>
<DataTable
columns={columns}
data={recentRequests || []}
keyExtractor={item => item.id || `${item.sessionFile}-${item.entryId}`}
onRowClick={item => item.id && onRequestClick(item.id)}
renderMobileCard={renderMobileCard}
emptyText="No recent requests found"
/>
</AsyncBoundary>
</Panel>
</div>
);
}
@@ -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";
File diff suppressed because it is too large Load Diff
@@ -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 <ErrorState error={error} onRetry={onRetry} />;
}
// If it's loading and there is no stale data, render Loading state / Skeleton
if (loading && data === null) {
if (fallback) {
return <>{fallback}</>;
}
return (
<div className="stats-boundary-skeleton">
<Skeleton variant="text" width="60%" height={24} className="mb-4" />
<Skeleton variant="rect" width="100%" height={160} className="mb-4" />
<Skeleton variant="text" width="80%" height={20} className="mb-2" />
<Skeleton variant="text" width="40%" height={20} />
</div>
);
}
// If there is data but it's empty, render EmptyState
if (!loading && (empty || data === null)) {
return <EmptyState message={emptyText} />;
}
// Render children (stale data is kept visible even if loading is true in background)
return <>{children}</>;
}
+122
View File
@@ -0,0 +1,122 @@
import type React from "react";
export interface DataTableColumn<T> {
key: string;
header: React.ReactNode;
render?: (item: T) => React.ReactNode;
className?: string;
numeric?: boolean;
}
export interface DataTableProps<T> {
columns: DataTableColumn<T>[];
data: T[];
keyExtractor: (item: T) => string | number;
onRowClick?: (item: T) => void;
renderMobileCard?: (item: T, onClick?: () => void) => React.ReactNode;
emptyText?: string;
}
export function DataTable<T>({
columns,
data,
keyExtractor,
onRowClick,
renderMobileCard,
emptyText = "No data available",
}: DataTableProps<T>) {
const handleKeyDown = (e: React.KeyboardEvent<HTMLTableRowElement>, item: T) => {
if (onRowClick && (e.key === "Enter" || e.key === " ")) {
e.preventDefault();
onRowClick(item);
}
};
if (data.length === 0) {
return <div className="stats-table-empty">{emptyText}</div>;
}
return (
<div className="stats-table-wrapper">
{/* Mobile layout */}
{renderMobileCard && (
<div className="stats-table-mobile-only">
<div className="stats-table-mobile-list">
{data.map(item => {
const key = String(keyExtractor(item));
const onClick = onRowClick ? () => onRowClick(item) : undefined;
return (
<div key={key} className="stats-table-mobile-card-wrapper">
{renderMobileCard(item, onClick)}
</div>
);
})}
</div>
</div>
)}
{/* Desktop layout */}
<div className={renderMobileCard ? "stats-table-desktop-only" : "stats-table-container"}>
<table className="stats-table">
<thead>
<tr>
{columns.map(col => {
const headerClasses = [
"stats-table-th",
col.numeric ? "stats-text-right" : "stats-text-left",
col.className || "",
]
.filter(Boolean)
.join(" ");
return (
<th key={col.key} className={headerClasses}>
{col.header}
</th>
);
})}
</tr>
</thead>
<tbody>
{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 (
<tr
key={key}
className={rowClasses}
onClick={isClickable ? () => 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 (
<td key={col.key} className={cellClasses}>
{col.render
? col.render(item)
: ((item as Record<string, unknown>)[col.key] as React.ReactNode)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
);
}
@@ -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 (
<div className={`stats-empty-state ${className}`}>
<Icon size={24} className="stats-empty-state-icon" aria-hidden="true" />
<p className="stats-empty-state-message">{message}</p>
</div>
);
}
@@ -0,0 +1,25 @@
export interface ErrorStateProps {
error?: Error | null;
onRetry?: () => void;
className?: string;
}
export function ErrorState({ error, onRetry, className = "" }: ErrorStateProps) {
return (
<div className={`stats-error-state ${className}`}>
<div className="stats-error-state-content">
<h4 className="stats-error-state-title">Failed to load data</h4>
{error && <p className="stats-error-state-message">{error.message}</p>}
{onRetry && (
<button
type="button"
onClick={onRetry}
className="stats-button stats-button-secondary stats-error-state-btn"
>
Retry
</button>
)}
</div>
</div>
);
}
@@ -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<number>(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<HTMLDivElement>) => {
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 (
<div className="stats-json-block">
<div
className="stats-json-block-header"
onClick={() => setCollapsed(!collapsed)}
onKeyDown={handleKeyDown}
tabIndex={0}
role="button"
aria-expanded={!collapsed}
>
<span className="stats-json-block-title">{title || "JSON"}</span>
<div className="stats-json-actions">
<button
type="button"
className="stats-json-copy-btn"
onClick={handleCopy}
aria-label={copied ? "Copied to clipboard" : "Copy JSON to clipboard"}
>
{copied ? <Check size={13} /> : <Copy size={13} />}
{copied ? "Copied" : "Copy"}
</button>
<span className="stats-json-block-toggle-indicator" data-collapsed={collapsed}>
{collapsed ? "▶ Show" : "▼ Hide"}
</span>
</div>
</div>
{!collapsed && (
<div className="stats-json-block-content-wrapper">
<pre className="stats-json-block-content">
<code>{jsonStr}</code>
</pre>
</div>
)}
</div>
);
}
@@ -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 (
<div className="stats-metric-cluster">
<div className="stats-metric-primary-grid">
<div className="stats-metric-card primary">
<div className="stats-metric-label">Total Cost</div>
<div className="stats-metric-value">
{formatCost(stats.totalCost, stats.totalCost > 0 && stats.totalCost < 0.01 ? 4 : 2)}
</div>
</div>
<div className="stats-metric-card primary">
<div className="stats-metric-label">Requests</div>
<div className="stats-metric-value">{formatInteger(stats.totalRequests)}</div>
</div>
<div className="stats-metric-card primary">
<div className="stats-metric-label">Cache Rate</div>
<div className="stats-metric-value">{formatPercent(stats.cacheRate)}</div>
</div>
<div className="stats-metric-card primary">
<div className="stats-metric-label">Error Rate</div>
<div className="stats-metric-value">{formatPercent(stats.errorRate)}</div>
</div>
</div>
<div className="stats-metric-secondary-grid">
<div className="stats-metric-card secondary">
<div className="stats-metric-label">Input Tokens</div>
<div className="stats-metric-value">{formatCompact(stats.totalInputTokens)}</div>
</div>
<div className="stats-metric-card secondary">
<div className="stats-metric-label">Output Tokens</div>
<div className="stats-metric-value">{formatCompact(stats.totalOutputTokens)}</div>
</div>
<div className="stats-metric-card secondary">
<div className="stats-metric-label">Premium Requests</div>
<div className="stats-metric-value">{formatInteger(stats.totalPremiumRequests)}</div>
</div>
<div className="stats-metric-card secondary">
<div className="stats-metric-label">Tokens/s</div>
<div className="stats-metric-value">{formatTokensPerSecond(stats.avgTokensPerSecond)}</div>
</div>
<div className="stats-metric-card secondary">
<div className="stats-metric-label">Avg Latency</div>
<div className="stats-metric-value">{formatDurationMs(stats.avgDuration)}</div>
</div>
<div className="stats-metric-card secondary">
<div className="stats-metric-label">Avg TTFT</div>
<div className="stats-metric-value">{formatDurationMs(stats.avgTtft)}</div>
</div>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type React from "react";
export interface PanelProps extends Omit<React.HTMLAttributes<HTMLDivElement>, "title"> {
title?: React.ReactNode;
subtitle?: React.ReactNode;
actions?: React.ReactNode;
}
export function Panel({ title, subtitle, actions, children, className = "", ...props }: PanelProps) {
return (
<div className={`stats-panel ${className}`} {...props}>
{(title || subtitle || actions) && (
<div className="stats-panel-header">
<div className="stats-panel-header-titles">
{title && <h3 className="stats-panel-title">{title}</h3>}
{subtitle && <p className="stats-panel-subtitle">{subtitle}</p>}
</div>
{actions && <div className="stats-panel-actions">{actions}</div>}
</div>
)}
<div className="stats-panel-body">{children}</div>
</div>
);
}
@@ -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<RequestDetails | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const previousActiveElement = useRef<HTMLElement | null>(null);
const closeButtonRef = useRef<HTMLButtonElement>(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<HTMLDivElement>) => {
if (e.target === e.currentTarget) {
onClose();
}
};
return (
<div className="stats-drawer-overlay" onClick={handleOverlayClick} role="presentation">
<div className="stats-drawer" role="dialog" aria-modal="true" aria-label="Request details">
{/* Drawer Header */}
<div className="stats-drawer-header">
<div className="stats-drawer-header-left">
<h2 className="stats-drawer-title">Request Details</h2>
{details && <span className="stats-drawer-id">ID: {id}</span>}
</div>
<button
ref={closeButtonRef}
type="button"
onClick={onClose}
className="stats-drawer-close-btn"
aria-label="Close request details"
>
<X size={18} />
</button>
</div>
<div className="stats-drawer-body">
{loading && (
<div className="stats-drawer-loading">
<Skeleton variant="text" width="60%" height={24} className="mb-4" />
<Skeleton variant="rect" width="100%" height={80} className="mb-4" />
<Skeleton variant="rect" width="100%" height={120} className="mb-4" />
<Skeleton variant="rect" width="100%" height={200} />
</div>
)}
{error && (
<div className="stats-drawer-error">
<p className="stats-drawer-error-title">Failed to load request details</p>
<p className="stats-drawer-error-message">{error.message}</p>
</div>
)}
{details && (
<div className="stats-drawer-content">
{/* Status Card */}
<div className="stats-drawer-status-card">
<div className="stats-drawer-status-row">
<div>
<div className="stats-drawer-model">{details.model}</div>
<div className="stats-drawer-provider">{details.provider}</div>
</div>
<StatusPill variant={details.errorMessage ? "danger" : "success"}>
{details.errorMessage ? "Error" : "Success"}
</StatusPill>
</div>
{details.errorMessage && (
<div className="stats-drawer-error-block">
<div className="stats-drawer-error-label">Error Message</div>
<div className="stats-drawer-error-text">{details.errorMessage}</div>
</div>
)}
</div>
{/* Metrics Grid */}
<div className="stats-drawer-metrics-grid">
<div className="stats-drawer-metric-card">
<div className="stats-drawer-metric-label">
<Coins size={14} className="stats-drawer-metric-icon" />
Cost
</div>
<div className="stats-drawer-metric-value">{formatCost(details.usage.cost.total, 4)}</div>
</div>
<div className="stats-drawer-metric-card">
<div className="stats-drawer-metric-label">
<Star size={14} className="stats-drawer-metric-icon" />
Premium
</div>
<div className="stats-drawer-metric-value">
{formatInteger(details.usage.premiumRequests ?? 0)}
</div>
</div>
<div className="stats-drawer-metric-card">
<div className="stats-drawer-metric-label">
<Hash size={14} className="stats-drawer-metric-icon" />
Total Tokens
</div>
<div className="stats-drawer-metric-value">{formatInteger(details.usage.totalTokens)}</div>
<div className="stats-drawer-metric-sub">
{formatInteger(details.usage.input)} in · {formatInteger(details.usage.output)} out
</div>
</div>
<div className="stats-drawer-metric-card">
<div className="stats-drawer-metric-label">
<Clock size={14} className="stats-drawer-metric-icon" />
Duration
</div>
<div className="stats-drawer-metric-value">{formatDurationMs(details.duration)}</div>
</div>
<div className="stats-drawer-metric-card">
<div className="stats-drawer-metric-label">
<Zap size={14} className="stats-drawer-metric-icon" />
TTFT
</div>
<div className="stats-drawer-metric-value">{formatDurationMs(details.ttft)}</div>
</div>
{details.duration && details.usage.output > 0 && (
<div className="stats-drawer-metric-card">
<div className="stats-drawer-metric-label">
<Gauge size={14} className="stats-drawer-metric-icon" />
Throughput
</div>
<div className="stats-drawer-metric-value">
{((details.usage.output * 1000) / details.duration).toFixed(1)}
</div>
<div className="stats-drawer-metric-sub">tokens/second</div>
</div>
)}
</div>
{/* JSON blocks */}
<div className="stats-drawer-json-blocks">
<JsonBlock data={details.output} title="Output Payload" initialCollapsed={false} />
<JsonBlock data={details} title="Raw Request Metadata" initialCollapsed={true} />
</div>
</div>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,36 @@
export interface SegmentedControlOption<T> {
value: T;
label: string;
title?: string;
}
export interface SegmentedControlProps<T> {
options: SegmentedControlOption<T>[];
value: T;
onChange: (value: T) => void;
className?: string;
}
export function SegmentedControl<T>({ options, value, onChange, className = "" }: SegmentedControlProps<T>) {
return (
<div className={`stats-segmented-control ${className}`} role="radiogroup">
{options.map(opt => {
const isActive = opt.value === value;
return (
<button
key={String(opt.value)}
type="button"
role="radio"
aria-checked={isActive}
data-active={isActive ? "true" : "false"}
className="stats-segmented-control-btn"
title={opt.title}
onClick={() => onChange(opt.value)}
>
{opt.label}
</button>
);
})}
</div>
);
}
+17
View File
@@ -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 <div className={`stats-skeleton ${className}`} data-variant={variant} style={style} />;
}
@@ -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 (
<span className={`stats-status-pill ${className}`} data-variant={variant}>
{children}
</span>
);
}
+11
View File
@@ -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";
+76 -20
View File
@@ -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<SystemTheme>(() => 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 };
}
@@ -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]);
});
});
@@ -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 () => {