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:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{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..." />
|
||||
)}
|
||||
<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 === "costs" && (
|
||||
<div className="space-y-6 animate-fade-in">
|
||||
{costStats ? (
|
||||
<>
|
||||
<CostSummary costSeries={costStats.costSeries} />
|
||||
<CostChart costSeries={costStats.costSeries} />
|
||||
<RequestDrawer id={selectedRequestId} onClose={closeDrawer} />
|
||||
</>
|
||||
) : (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
}
|
||||
@@ -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";
|
||||
+1251
-234
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}</>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user