@oh-my-pi/omp-stats 16.0.4 → 16.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/build.ts +11 -0
  3. package/dist/client/index.css +1 -1
  4. package/dist/client/index.html +11 -0
  5. package/dist/client/index.js +108 -108
  6. package/dist/client/styles.css +1070 -631
  7. package/dist/types/client/api.d.ts +19 -10
  8. package/dist/types/client/app/AppLayout.d.ts +16 -0
  9. package/dist/types/client/app/NavRail.d.ts +7 -0
  10. package/dist/types/client/app/RangeControl.d.ts +7 -0
  11. package/dist/types/client/app/SyncButton.d.ts +14 -0
  12. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  13. package/dist/types/client/app/TopBar.d.ts +15 -0
  14. package/dist/types/client/app/routes.d.ts +12 -0
  15. package/dist/types/client/components/chart-shared.d.ts +26 -40
  16. package/dist/types/client/components/models-table-shared.d.ts +20 -40
  17. package/dist/types/client/data/charts.d.ts +1 -0
  18. package/dist/types/client/data/formatters.d.ts +7 -0
  19. package/dist/types/client/data/useHashRoute.d.ts +8 -0
  20. package/dist/types/client/data/useResource.d.ts +13 -0
  21. package/dist/types/client/data/view-models.d.ts +37 -0
  22. package/dist/types/client/index.d.ts +1 -0
  23. package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
  24. package/dist/types/client/routes/CostsRoute.d.ts +7 -0
  25. package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
  26. package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
  27. package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
  28. package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
  29. package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
  30. package/dist/types/client/routes/index.d.ts +7 -0
  31. package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
  32. package/dist/types/client/ui/DataTable.d.ts +17 -0
  33. package/dist/types/client/ui/EmptyState.d.ts +7 -0
  34. package/dist/types/client/ui/ErrorState.d.ts +6 -0
  35. package/dist/types/client/ui/JsonBlock.d.ts +7 -0
  36. package/dist/types/client/ui/MetricCluster.d.ts +5 -0
  37. package/dist/types/client/ui/Panel.d.ts +7 -0
  38. package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
  39. package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
  40. package/dist/types/client/ui/Skeleton.d.ts +8 -0
  41. package/dist/types/client/ui/StatusPill.d.ts +7 -0
  42. package/dist/types/client/ui/index.d.ts +11 -0
  43. package/dist/types/client/useSystemTheme.d.ts +9 -0
  44. package/package.json +4 -4
  45. package/src/aggregator.ts +4 -3
  46. package/src/client/App.tsx +89 -207
  47. package/src/client/api.ts +55 -37
  48. package/src/client/app/AppLayout.tsx +93 -0
  49. package/src/client/app/NavRail.tsx +44 -0
  50. package/src/client/app/RangeControl.tsx +39 -0
  51. package/src/client/app/SyncButton.tsx +75 -0
  52. package/src/client/app/ThemeToggle.tsx +37 -0
  53. package/src/client/app/TopBar.tsx +73 -0
  54. package/src/client/app/routes.ts +50 -0
  55. package/src/client/components/chart-shared.tsx +28 -91
  56. package/src/client/components/models-table-shared.tsx +9 -29
  57. package/src/client/components/range-meta.ts +3 -2
  58. package/src/client/data/charts.ts +14 -0
  59. package/src/client/data/formatters.ts +38 -0
  60. package/src/client/data/useHashRoute.ts +85 -0
  61. package/src/client/data/useResource.ts +154 -0
  62. package/src/client/data/view-models.ts +178 -0
  63. package/src/client/index.tsx +4 -0
  64. package/src/client/routes/BehaviorRoute.tsx +623 -0
  65. package/src/client/routes/CostsRoute.tsx +234 -0
  66. package/src/client/routes/ErrorsRoute.tsx +118 -0
  67. package/src/client/routes/ModelsRoute.tsx +430 -0
  68. package/src/client/routes/OverviewRoute.tsx +332 -0
  69. package/src/client/routes/ProjectsRoute.tsx +163 -0
  70. package/src/client/routes/RequestsRoute.tsx +123 -0
  71. package/src/client/routes/index.ts +7 -0
  72. package/src/client/styles.css +1242 -225
  73. package/src/client/ui/AsyncBoundary.tsx +54 -0
  74. package/src/client/ui/DataTable.tsx +122 -0
  75. package/src/client/ui/EmptyState.tsx +16 -0
  76. package/src/client/ui/ErrorState.tsx +25 -0
  77. package/src/client/ui/JsonBlock.tsx +75 -0
  78. package/src/client/ui/MetricCluster.tsx +67 -0
  79. package/src/client/ui/Panel.tsx +24 -0
  80. package/src/client/ui/RequestDrawer.tsx +208 -0
  81. package/src/client/ui/SegmentedControl.tsx +36 -0
  82. package/src/client/ui/Skeleton.tsx +17 -0
  83. package/src/client/ui/StatusPill.tsx +15 -0
  84. package/src/client/ui/index.ts +11 -0
  85. package/src/client/useSystemTheme.ts +73 -17
  86. package/dist/types/client/components/BehaviorChart.d.ts +0 -6
  87. package/dist/types/client/components/BehaviorModelsTable.d.ts +0 -7
  88. package/dist/types/client/components/BehaviorSummary.d.ts +0 -7
  89. package/dist/types/client/components/ChartsContainer.d.ts +0 -7
  90. package/dist/types/client/components/CostChart.d.ts +0 -6
  91. package/dist/types/client/components/CostSummary.d.ts +0 -6
  92. package/dist/types/client/components/Header.d.ts +0 -12
  93. package/dist/types/client/components/ModelsTable.d.ts +0 -8
  94. package/dist/types/client/components/RequestDetail.d.ts +0 -6
  95. package/dist/types/client/components/RequestList.d.ts +0 -8
  96. package/dist/types/client/components/StatsGrid.d.ts +0 -6
  97. package/src/client/components/BehaviorChart.tsx +0 -189
  98. package/src/client/components/BehaviorModelsTable.tsx +0 -342
  99. package/src/client/components/BehaviorSummary.tsx +0 -95
  100. package/src/client/components/ChartsContainer.tsx +0 -221
  101. package/src/client/components/CostChart.tsx +0 -171
  102. package/src/client/components/CostSummary.tsx +0 -53
  103. package/src/client/components/Header.tsx +0 -72
  104. package/src/client/components/ModelsTable.tsx +0 -265
  105. package/src/client/components/RequestDetail.tsx +0 -172
  106. package/src/client/components/RequestList.tsx +0 -73
  107. package/src/client/components/StatsGrid.tsx +0 -135
@@ -0,0 +1,75 @@
1
+ import { RefreshCw } from "lucide-react";
2
+ import { useState } from "react";
3
+ import { sync } from "../api";
4
+
5
+ export interface SyncButtonProps {
6
+ onSyncStart?: () => void;
7
+ onSyncComplete?: (result: {
8
+ success: boolean;
9
+ data?: { processed: number; files: number; totalMessages: number };
10
+ error?: string;
11
+ }) => void;
12
+ className?: string;
13
+ }
14
+
15
+ export function SyncButton({ onSyncStart, onSyncComplete, className = "" }: SyncButtonProps) {
16
+ const [syncing, setSyncing] = useState(false);
17
+ const [status, setStatus] = useState<{ type: "success" | "error"; message: string } | null>(null);
18
+
19
+ const handleSync = async () => {
20
+ if (syncing) return;
21
+
22
+ setSyncing(true);
23
+ setStatus(null);
24
+ if (onSyncStart) {
25
+ onSyncStart();
26
+ }
27
+
28
+ try {
29
+ const data = await sync();
30
+ const result = {
31
+ processed: typeof data?.processed === "number" ? data.processed : 0,
32
+ files: typeof data?.files === "number" ? data.files : 0,
33
+ totalMessages: typeof data?.totalMessages === "number" ? data.totalMessages : 0,
34
+ };
35
+ setStatus({
36
+ type: "success",
37
+ message: `Synced: ${result.processed} new request${result.processed === 1 ? "" : "s"} found.`,
38
+ });
39
+ if (onSyncComplete) {
40
+ onSyncComplete({ success: true, data: result });
41
+ }
42
+ } catch (err) {
43
+ const errorMessage = err instanceof Error ? err.message : String(err);
44
+ setStatus({
45
+ type: "error",
46
+ message: `Sync failed: ${errorMessage}`,
47
+ });
48
+ if (onSyncComplete) {
49
+ onSyncComplete({ success: false, error: errorMessage });
50
+ }
51
+ } finally {
52
+ setSyncing(false);
53
+ }
54
+ };
55
+
56
+ return (
57
+ <div className={`stats-sync-container ${className}`}>
58
+ {status && (
59
+ <span className="stats-sync-status-msg" data-type={status.type}>
60
+ {status.message}
61
+ </span>
62
+ )}
63
+ <button
64
+ type="button"
65
+ onClick={handleSync}
66
+ disabled={syncing}
67
+ className="stats-button stats-button-primary stats-sync-btn"
68
+ aria-busy={syncing}
69
+ >
70
+ <RefreshCw size={14} className={`stats-sync-icon ${syncing ? "stats-spin" : ""}`} />
71
+ {syncing ? "Syncing..." : "Sync DB"}
72
+ </button>
73
+ </div>
74
+ );
75
+ }
@@ -0,0 +1,37 @@
1
+ import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react";
2
+ import { type ThemePreference, useThemePreference } from "../useSystemTheme";
3
+
4
+ const NEXT_PREFERENCE: Record<ThemePreference, ThemePreference> = {
5
+ system: "light",
6
+ light: "dark",
7
+ dark: "system",
8
+ };
9
+
10
+ const PREFERENCE_ICON: Record<ThemePreference, LucideIcon> = {
11
+ system: Monitor,
12
+ light: Sun,
13
+ dark: Moon,
14
+ };
15
+
16
+ const PREFERENCE_LABEL: Record<ThemePreference, string> = {
17
+ system: "System theme",
18
+ light: "Light theme",
19
+ dark: "Dark theme",
20
+ };
21
+
22
+ export function ThemeToggle() {
23
+ const { preference, setPreference } = useThemePreference();
24
+ const Icon = PREFERENCE_ICON[preference];
25
+
26
+ return (
27
+ <button
28
+ type="button"
29
+ className="stats-theme-toggle"
30
+ onClick={() => setPreference(NEXT_PREFERENCE[preference])}
31
+ aria-label={`${PREFERENCE_LABEL[preference]} (click to switch)`}
32
+ title={`${PREFERENCE_LABEL[preference]} — click to switch`}
33
+ >
34
+ <Icon size={16} />
35
+ </button>
36
+ );
37
+ }
@@ -0,0 +1,73 @@
1
+ import { Menu } from "lucide-react";
2
+ import type { TimeRange } from "../types";
3
+ import { RangeControl } from "./RangeControl";
4
+ import type { DashboardSection } from "./routes";
5
+ import { routes } from "./routes";
6
+ import { SyncButton } from "./SyncButton";
7
+ import { ThemeToggle } from "./ThemeToggle";
8
+
9
+ export interface TopBarProps {
10
+ activeSection: DashboardSection;
11
+ range: TimeRange;
12
+ onRangeChange: (range: TimeRange) => void;
13
+ updatedAt: number | null;
14
+ onSyncStart?: () => void;
15
+ onSyncComplete?: (result: { success: boolean }) => void;
16
+ onMenuToggle?: () => void;
17
+ className?: string;
18
+ }
19
+
20
+ export function TopBar({
21
+ activeSection,
22
+ range,
23
+ onRangeChange,
24
+ updatedAt,
25
+ onSyncStart,
26
+ onSyncComplete,
27
+ onMenuToggle,
28
+ className = "",
29
+ }: TopBarProps) {
30
+ const currentRoute = routes.find(r => r.id === activeSection);
31
+ const title = currentRoute?.label || "Observability";
32
+
33
+ const formatLastUpdated = (time: number | null) => {
34
+ if (!time) return "Not updated";
35
+ const date = new Date(time);
36
+ return `Updated ${date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}`;
37
+ };
38
+
39
+ return (
40
+ <header className={`stats-top-bar ${className}`}>
41
+ <div className="stats-top-bar-left">
42
+ {onMenuToggle && (
43
+ <button
44
+ type="button"
45
+ onClick={onMenuToggle}
46
+ className="stats-mobile-menu-btn"
47
+ aria-label="Open navigation menu"
48
+ >
49
+ <Menu size={20} />
50
+ </button>
51
+ )}
52
+ <h1 className="stats-page-title">{title}</h1>
53
+ </div>
54
+
55
+ <div className="stats-top-bar-right">
56
+ <div className="stats-top-bar-meta">
57
+ <span
58
+ className="stats-last-updated"
59
+ title={updatedAt ? new Date(updatedAt).toLocaleString() : undefined}
60
+ >
61
+ {formatLastUpdated(updatedAt)}
62
+ </span>
63
+ </div>
64
+
65
+ <RangeControl value={range} onChange={onRangeChange} />
66
+
67
+ <ThemeToggle />
68
+
69
+ <SyncButton onSyncStart={onSyncStart} onSyncComplete={onSyncComplete} />
70
+ </div>
71
+ </header>
72
+ );
73
+ }
@@ -0,0 +1,50 @@
1
+ import { Activity, AlertCircle, Coins, Cpu, Folder, LayoutDashboard, Smile } from "lucide-react";
2
+ import type React from "react";
3
+
4
+ export type DashboardSection = "overview" | "requests" | "errors" | "models" | "costs" | "behavior" | "projects";
5
+
6
+ export interface DashboardRoute {
7
+ id: DashboardSection;
8
+ label: string;
9
+ shortLabel?: string;
10
+ icon: React.ComponentType<{ size?: number; className?: string }>;
11
+ }
12
+
13
+ export const routes: DashboardRoute[] = [
14
+ {
15
+ id: "overview",
16
+ label: "Overview",
17
+ icon: LayoutDashboard,
18
+ },
19
+ {
20
+ id: "requests",
21
+ label: "Requests",
22
+ icon: Activity,
23
+ },
24
+ {
25
+ id: "errors",
26
+ label: "Errors",
27
+ icon: AlertCircle,
28
+ },
29
+ {
30
+ id: "models",
31
+ label: "Models",
32
+ icon: Cpu,
33
+ },
34
+ {
35
+ id: "costs",
36
+ label: "Costs",
37
+ icon: Coins,
38
+ },
39
+ {
40
+ id: "behavior",
41
+ label: "Behavior",
42
+ shortLabel: "Behavior",
43
+ icon: Smile,
44
+ },
45
+ {
46
+ id: "projects",
47
+ label: "Projects",
48
+ icon: Folder,
49
+ },
50
+ ];
@@ -1,40 +1,43 @@
1
1
  /**
2
- * Shared chart primitives for the dashboard timeline charts (BehaviorChart,
3
- * CostChart). Each chart owns its data shape and metric labels — this module
4
- * owns the layout, theme, legend/tooltip plumbing, and the top-N-by-model
5
- * bucketing scaffold that's identical between cost and behavior series.
2
+ * Shared chart primitives for the dashboard timeline charts: the OMP color
3
+ * palette, light/dark chart chrome, legend/tooltip + scale plumbing, dataset
4
+ * styling, and the top-N-by-model / aggregate bucketing used by the cost and
5
+ * behavior series.
6
6
  */
7
7
 
8
8
  import { format } from "date-fns";
9
9
 
10
+ // OMP brand palette (packages/collab-web/src/styles/tokens.css): pink/purple/cyan.
11
+ // Categorical series lead with the brand gradient hues (pink -> purple -> cyan).
10
12
  export const MODEL_COLORS = [
11
- "#a78bfa", // violet
12
- "#22d3ee", // cyan
13
- "#ec4899", // pink
14
- "#4ade80", // green
15
- "#fbbf24", // amber
16
- "#f87171", // red
17
- "#60a5fa", // blue
13
+ "#ed4abf", // brand pink (accent)
14
+ "#9b4dff", // brand violet
15
+ "#5ad8e6", // brand cyan
16
+ "#62d394", // green
17
+ "#c77dff", // light purple
18
+ "#ff8fd1", // light pink
19
+ "#f5c14b", // amber
20
+ "#ff6b7d", // rose
18
21
  ];
19
22
 
20
23
  export const CHART_THEMES = {
21
24
  dark: {
22
- legendLabel: "#94a3b8",
23
- tooltipBackground: "#16161e",
24
- tooltipTitle: "#f8fafc",
25
- tooltipBody: "#94a3b8",
26
- tooltipBorder: "rgba(255, 255, 255, 0.1)",
25
+ legendLabel: "#a89fb3",
26
+ tooltipBackground: "#241a2e",
27
+ tooltipTitle: "#eae5ef",
28
+ tooltipBody: "#a89fb3",
29
+ tooltipBorder: "rgba(255, 255, 255, 0.12)",
27
30
  grid: "rgba(255, 255, 255, 0.06)",
28
- tick: "#64748b",
31
+ tick: "#867a93",
29
32
  },
30
33
  light: {
31
- legendLabel: "#475569",
34
+ legendLabel: "#5a5462",
32
35
  tooltipBackground: "#ffffff",
33
- tooltipTitle: "#0f172a",
34
- tooltipBody: "#334155",
35
- tooltipBorder: "rgba(15, 23, 42, 0.18)",
36
- grid: "rgba(15, 23, 42, 0.08)",
37
- tick: "#64748b",
36
+ tooltipTitle: "#241a2e",
37
+ tooltipBody: "#5a5462",
38
+ tooltipBorder: "rgba(20, 12, 28, 0.15)",
39
+ grid: "rgba(20, 12, 28, 0.08)",
40
+ tick: "#6a6275",
38
41
  },
39
42
  } as const;
40
43
 
@@ -128,7 +131,8 @@ export function barDatasetStyle(color: string) {
128
131
  backgroundColor: color,
129
132
  borderColor: color,
130
133
  borderWidth: 0,
131
- borderRadius: 3,
134
+ borderRadius: 4,
135
+ maxBarThickness: 56,
132
136
  };
133
137
  }
134
138
 
@@ -251,70 +255,3 @@ export function buildTopNByModelSeries<T extends ModelKeyedPoint, B>(
251
255
  })),
252
256
  };
253
257
  }
254
-
255
- /** All Models / By Model segmented toggle — identical UI in every time chart. */
256
- function ByModelToggle({ byModel, onChange }: { byModel: boolean; onChange: (v: boolean) => void }) {
257
- return (
258
- <div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
259
- <button
260
- type="button"
261
- onClick={() => onChange(false)}
262
- className={`tab-btn text-xs ${!byModel ? "active" : ""}`}
263
- >
264
- All Models
265
- </button>
266
- <button type="button" onClick={() => onChange(true)} className={`tab-btn text-xs ${byModel ? "active" : ""}`}>
267
- By Model
268
- </button>
269
- </div>
270
- );
271
- }
272
-
273
- /**
274
- * Outer surface card used by both time charts. `controls` slot covers
275
- * chart-specific tabs (e.g. behavior metric picker); the by-model toggle and
276
- * empty-state are part of the frame so callers don't redeclare them.
277
- */
278
- export function ChartFrame({
279
- title,
280
- subtitle,
281
- empty,
282
- emptyMessage,
283
- controls,
284
- byModel,
285
- onByModelChange,
286
- children,
287
- }: {
288
- title: string;
289
- subtitle: string;
290
- empty: boolean;
291
- emptyMessage: string;
292
- controls?: React.ReactNode;
293
- byModel: boolean;
294
- onByModelChange: (v: boolean) => void;
295
- children: React.ReactNode;
296
- }) {
297
- return (
298
- <div className="surface overflow-hidden">
299
- <div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
300
- <div>
301
- <h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
302
- <p className="text-xs text-[var(--text-muted)] mt-1">{subtitle}</p>
303
- </div>
304
- <div className="flex items-center gap-2 flex-wrap">
305
- {controls}
306
- <ByModelToggle byModel={byModel} onChange={onByModelChange} />
307
- </div>
308
- </div>
309
- <div className="p-5 min-h-[320px]">
310
- {empty ? (
311
- <div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
312
- {emptyMessage}
313
- </div>
314
- ) : (
315
- <div className="h-[280px]">{children}</div>
316
- )}
317
- </div>
318
- </div>
319
- );
320
- }
@@ -1,39 +1,19 @@
1
1
  /**
2
- * Shared primitives for the per-model breakdown tables (ModelsTable,
3
- * BehaviorModelsTable). Each table still owns its column definitions, sort
4
- * order, sidebar contents and chart type this module owns the surface
5
- * chrome, expand-row plumbing, theme palette, and the mini-sparkline plus
2
+ * Shared primitives for the per-model breakdown tables. Each table owns its
3
+ * column definitions, sort order, and chart type this module owns the
4
+ * surface chrome, expand-row plumbing, theme palette, the mini-sparkline, and
6
5
  * the shared plugin/scale config consumed by multi-line detail charts.
7
6
  */
8
7
 
9
8
  import { format } from "date-fns";
10
9
  import { ChevronDown, ChevronUp } from "lucide-react";
11
10
  import { Line } from "react-chartjs-2";
11
+ import type { ChartTheme } from "./chart-shared";
12
12
 
13
- export { MODEL_COLORS } from "./chart-shared";
14
-
15
- export const TABLE_CHART_THEMES = {
16
- dark: {
17
- legendLabel: "#cbd5e1",
18
- tooltipBackground: "#16161e",
19
- tooltipTitle: "#f8fafc",
20
- tooltipBody: "#94a3b8",
21
- tooltipBorder: "rgba(255, 255, 255, 0.1)",
22
- grid: "rgba(255, 255, 255, 0.06)",
23
- tick: "#94a3b8",
24
- },
25
- light: {
26
- legendLabel: "#334155",
27
- tooltipBackground: "#ffffff",
28
- tooltipTitle: "#0f172a",
29
- tooltipBody: "#334155",
30
- tooltipBorder: "rgba(15, 23, 42, 0.18)",
31
- grid: "rgba(15, 23, 42, 0.08)",
32
- tick: "#475569",
33
- },
34
- } as const;
35
-
36
- export type TableChartTheme = (typeof TABLE_CHART_THEMES)[keyof typeof TABLE_CHART_THEMES];
13
+ // Detail-table charts share the exact OMP chart chrome as the timeline charts;
14
+ // re-export rather than duplicate so the palette has a single source of truth.
15
+ export { CHART_THEMES as TABLE_CHART_THEMES, MODEL_COLORS } from "./chart-shared";
16
+ export type TableChartTheme = ChartTheme;
37
17
 
38
18
  /** Style defaults for one line in a non-stacked detail chart. */
39
19
  export function lineSeriesStyle(color: string) {
@@ -167,7 +147,7 @@ export function ModelTableShell({
167
147
  children: React.ReactNode;
168
148
  }) {
169
149
  return (
170
- <div className="surface overflow-hidden">
150
+ <div className="stats-panel overflow-hidden">
171
151
  <div className="px-5 py-4 border-b border-[var(--border-subtle)]">
172
152
  <h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
173
153
  {subtitle ? <p className="text-xs text-[var(--text-muted)] mt-1">{subtitle}</p> : null}
@@ -9,6 +9,7 @@ import type { TimeRange } from "../types";
9
9
 
10
10
  const HOUR_MS = 60 * 60 * 1000;
11
11
  const DAY_MS = 24 * HOUR_MS;
12
+ const FIVE_MIN_MS = 5 * 60 * 1000;
12
13
 
13
14
  export interface RangeMeta {
14
15
  /** Human label used in chart subtitles ("the last 24 hours"). */
@@ -27,8 +28,8 @@ const RANGE_META: Record<TimeRange, RangeMeta> = {
27
28
  "1h": {
28
29
  windowLabel: "the last hour",
29
30
  trendLabel: "1h Trend",
30
- bucketMs: HOUR_MS,
31
- bucketCount: 1,
31
+ bucketMs: FIVE_MIN_MS,
32
+ bucketCount: 12,
32
33
  tickFormat: "HH:mm",
33
34
  },
34
35
  "24h": {
@@ -0,0 +1,14 @@
1
+ import {
2
+ BarElement,
3
+ CategoryScale,
4
+ Chart as ChartJS,
5
+ Filler,
6
+ Legend,
7
+ LinearScale,
8
+ LineElement,
9
+ PointElement,
10
+ Title,
11
+ Tooltip,
12
+ } from "chart.js";
13
+
14
+ ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
@@ -0,0 +1,38 @@
1
+ import { formatDistanceToNow } from "date-fns";
2
+
3
+ export function formatInteger(value: number): string {
4
+ return value.toLocaleString();
5
+ }
6
+
7
+ export function formatCompact(value: number): string {
8
+ return value.toLocaleString(undefined, { notation: "compact" });
9
+ }
10
+
11
+ export function formatCost(value: number, digits?: number): string {
12
+ if (value === 0) return "$0";
13
+ const fractionDigits = digits !== undefined ? digits : value > 0 && value < 0.01 ? 4 : 2;
14
+ return `$${value.toLocaleString(undefined, {
15
+ minimumFractionDigits: fractionDigits,
16
+ maximumFractionDigits: fractionDigits,
17
+ })}`;
18
+ }
19
+
20
+ export function formatPercent(value: number, digits = 1): string {
21
+ return `${(value * 100).toFixed(digits)}%`;
22
+ }
23
+
24
+ export function formatDurationMs(value: number | null, digits?: number): string {
25
+ if (value === null) return "-";
26
+ const sec = value / 1000;
27
+ const d = digits !== undefined ? digits : sec < 1 ? 2 : 1;
28
+ return `${sec.toFixed(d)}s`;
29
+ }
30
+
31
+ export function formatTokensPerSecond(value: number | null): string {
32
+ if (value === null) return "-";
33
+ return value.toFixed(1);
34
+ }
35
+
36
+ export function formatRelativeTime(timestamp: number): string {
37
+ return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
38
+ }
@@ -0,0 +1,85 @@
1
+ import { useCallback, useEffect, useState } from "react";
2
+ import type { DashboardSection } from "../app/routes";
3
+ import type { TimeRange } from "../types";
4
+
5
+ const VALID_SECTIONS: DashboardSection[] = [
6
+ "overview",
7
+ "requests",
8
+ "errors",
9
+ "models",
10
+ "costs",
11
+ "behavior",
12
+ "projects",
13
+ ];
14
+
15
+ const VALID_RANGES: TimeRange[] = ["1h", "24h", "7d", "30d", "90d", "all"];
16
+
17
+ function parseHash(hash: string): { section: DashboardSection; range: TimeRange } {
18
+ const cleanHash = hash.replace(/^#\/?/, "");
19
+ const [pathPart, queryPart] = cleanHash.split("?");
20
+
21
+ const section: DashboardSection = (VALID_SECTIONS as string[]).includes(pathPart)
22
+ ? (pathPart as DashboardSection)
23
+ : "overview";
24
+
25
+ let range: TimeRange = "24h";
26
+ if (queryPart) {
27
+ const params = new URLSearchParams(queryPart);
28
+ const rangeParam = params.get("range") as TimeRange;
29
+ if (VALID_RANGES.includes(rangeParam)) {
30
+ range = rangeParam;
31
+ }
32
+ }
33
+
34
+ return { section, range };
35
+ }
36
+
37
+ export function useHashRoute() {
38
+ const [route, setRouteState] = useState(() => parseHash(window.location.hash));
39
+
40
+ useEffect(() => {
41
+ const handleHashChange = () => {
42
+ setRouteState(parseHash(window.location.hash));
43
+ };
44
+
45
+ window.addEventListener("hashchange", handleHashChange);
46
+ return () => {
47
+ window.removeEventListener("hashchange", handleHashChange);
48
+ };
49
+ }, []);
50
+
51
+ const updateHash = useCallback((section: string, range: TimeRange) => {
52
+ window.location.hash = `/${section}?range=${range}`;
53
+ }, []);
54
+
55
+ const setSection = useCallback(
56
+ (newSection: DashboardSection) => {
57
+ updateHash(newSection, route.range);
58
+ },
59
+ [route.range, updateHash],
60
+ );
61
+
62
+ const setRange = useCallback(
63
+ (newRange: string) => {
64
+ const nextRange = VALID_RANGES.includes(newRange as TimeRange) ? (newRange as TimeRange) : "24h";
65
+ updateHash(route.section, nextRange);
66
+ },
67
+ [route.section, updateHash],
68
+ );
69
+
70
+ useEffect(() => {
71
+ const currentHash = window.location.hash;
72
+ const parsed = parseHash(currentHash);
73
+ const expectedHash = `#/${parsed.section}?range=${parsed.range}`;
74
+ if (currentHash !== expectedHash) {
75
+ window.location.hash = `/${parsed.section}?range=${parsed.range}`;
76
+ }
77
+ }, []);
78
+
79
+ return {
80
+ section: route.section,
81
+ setSection,
82
+ range: route.range,
83
+ setRange,
84
+ };
85
+ }