@oh-my-pi/omp-stats 18.3.5 → 18.4.0

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 (192) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/README.md +13 -11
  3. package/build.ts +5 -49
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +2 -2
  6. package/dist/client/index.js +105 -126
  7. package/dist/types/aggregator.d.ts +25 -22
  8. package/dist/types/client/api.d.ts +11 -7
  9. package/dist/types/client/app/LiveChip.d.ts +2 -0
  10. package/dist/types/client/app/Shell.d.ts +16 -0
  11. package/dist/types/client/app/ThemeToggle.d.ts +1 -0
  12. package/dist/types/client/app/nav.d.ts +18 -0
  13. package/dist/types/client/charts/BarList.d.ts +17 -0
  14. package/dist/types/client/charts/Chart.d.ts +49 -0
  15. package/dist/types/client/charts/Legend.d.ts +16 -0
  16. package/dist/types/client/charts/ShareBar.d.ts +11 -0
  17. package/dist/types/client/charts/Sparkline.d.ts +11 -0
  18. package/dist/types/client/charts/TimeChart.d.ts +9 -0
  19. package/dist/types/client/charts/index.d.ts +7 -0
  20. package/dist/types/client/charts/types.d.ts +25 -0
  21. package/dist/types/client/charts/useWidth.d.ts +3 -0
  22. package/dist/types/client/data/colors.d.ts +25 -0
  23. package/dist/types/client/data/formatters.d.ts +9 -1
  24. package/dist/types/client/data/live.d.ts +29 -0
  25. package/dist/types/client/data/query.d.ts +35 -0
  26. package/dist/types/client/data/range.d.ts +31 -0
  27. package/dist/types/client/data/series.d.ts +28 -0
  28. package/dist/types/client/data/useHashRoute.d.ts +18 -4
  29. package/dist/types/client/data/view-models.d.ts +111 -28
  30. package/dist/types/client/index.d.ts +0 -1
  31. package/dist/types/client/routes/CostsRoute.d.ts +2 -2
  32. package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
  33. package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
  34. package/dist/types/client/routes/GainRoute.d.ts +1 -2
  35. package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
  36. package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
  37. package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
  38. package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
  39. package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
  40. package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
  41. package/dist/types/client/routes/TracesRoute.d.ts +2 -2
  42. package/dist/types/client/routes/index.d.ts +1 -1
  43. package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
  44. package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
  45. package/dist/types/client/traces/trace-colors.d.ts +18 -12
  46. package/dist/types/client/ui/Badge.d.ts +19 -0
  47. package/dist/types/client/ui/Card.d.ts +25 -0
  48. package/dist/types/client/ui/Drawer.d.ts +25 -0
  49. package/dist/types/client/ui/JsonBlock.d.ts +2 -2
  50. package/dist/types/client/ui/Modal.d.ts +23 -0
  51. package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
  52. package/dist/types/client/ui/SearchInput.d.ts +8 -0
  53. package/dist/types/client/ui/Segmented.d.ts +15 -0
  54. package/dist/types/client/ui/Stat.d.ts +36 -0
  55. package/dist/types/client/ui/States.d.ts +41 -0
  56. package/dist/types/client/ui/Table.d.ts +48 -0
  57. package/dist/types/client/ui/index.d.ts +9 -10
  58. package/dist/types/db.d.ts +90 -106
  59. package/dist/types/frustration.d.ts +70 -0
  60. package/dist/types/index.d.ts +3 -1
  61. package/dist/types/live.d.ts +30 -0
  62. package/dist/types/rollup.d.ts +115 -0
  63. package/dist/types/server.d.ts +6 -1
  64. package/dist/types/shared-types.d.ts +116 -56
  65. package/dist/types/types.d.ts +4 -0
  66. package/dist/types/user-metrics.d.ts +8 -0
  67. package/package.json +7 -13
  68. package/src/aggregator.ts +276 -211
  69. package/src/client/App.tsx +38 -80
  70. package/src/client/api.ts +65 -7
  71. package/src/client/app/LiveChip.tsx +97 -0
  72. package/src/client/app/Shell.tsx +181 -0
  73. package/src/client/app/ThemeToggle.tsx +5 -2
  74. package/src/client/app/nav.ts +78 -0
  75. package/src/client/charts/BarList.tsx +48 -0
  76. package/src/client/charts/Chart.tsx +388 -0
  77. package/src/client/charts/Legend.tsx +61 -0
  78. package/src/client/charts/ShareBar.tsx +26 -0
  79. package/src/client/charts/Sparkline.tsx +38 -0
  80. package/src/client/charts/TimeChart.tsx +22 -0
  81. package/src/client/charts/index.ts +7 -0
  82. package/src/client/charts/types.ts +27 -0
  83. package/src/client/charts/useWidth.ts +19 -0
  84. package/src/client/data/colors.ts +45 -0
  85. package/src/client/data/formatters.ts +30 -9
  86. package/src/client/data/live.tsx +85 -0
  87. package/src/client/data/query.ts +166 -0
  88. package/src/client/data/range.ts +82 -0
  89. package/src/client/data/series.ts +85 -0
  90. package/src/client/data/useHashRoute.ts +41 -83
  91. package/src/client/data/view-models.ts +347 -140
  92. package/src/client/index.tsx +3 -6
  93. package/src/client/routes/CostsRoute.tsx +394 -222
  94. package/src/client/routes/ErrorsRoute.tsx +401 -98
  95. package/src/client/routes/FrustrationRoute.tsx +924 -0
  96. package/src/client/routes/GainRoute.tsx +209 -206
  97. package/src/client/routes/ModelsRoute.tsx +540 -431
  98. package/src/client/routes/OverviewRoute.tsx +283 -311
  99. package/src/client/routes/ProjectsRoute.tsx +298 -156
  100. package/src/client/routes/ProvidersRoute.tsx +948 -416
  101. package/src/client/routes/RequestsRoute.tsx +295 -107
  102. package/src/client/routes/ToolsRoute.tsx +495 -398
  103. package/src/client/routes/TracesRoute.tsx +136 -117
  104. package/src/client/routes/costs.css +24 -0
  105. package/src/client/routes/errors.css +60 -0
  106. package/src/client/routes/frustration.css +123 -0
  107. package/src/client/routes/index.ts +1 -1
  108. package/src/client/routes/models.css +43 -0
  109. package/src/client/routes/projects.css +6 -0
  110. package/src/client/routes/providers.css +28 -0
  111. package/src/client/routes/tools.css +14 -0
  112. package/src/client/styles.css +1396 -1299
  113. package/src/client/traces/AggregatesPanel.tsx +55 -74
  114. package/src/client/traces/Minimap.tsx +6 -15
  115. package/src/client/traces/SpanDrawer.tsx +128 -194
  116. package/src/client/traces/SummaryStrip.tsx +21 -24
  117. package/src/client/traces/TimelineCanvas.tsx +27 -44
  118. package/src/client/traces/TraceView.tsx +175 -130
  119. package/src/client/traces/TranscriptList.tsx +13 -41
  120. package/src/client/traces/trace-colors.ts +60 -53
  121. package/src/client/traces/traces.css +279 -0
  122. package/src/client/ui/Badge.tsx +29 -0
  123. package/src/client/ui/Card.tsx +61 -0
  124. package/src/client/ui/Drawer.tsx +93 -0
  125. package/src/client/ui/JsonBlock.tsx +26 -44
  126. package/src/client/ui/Modal.tsx +106 -0
  127. package/src/client/ui/RequestDrawer.tsx +163 -188
  128. package/src/client/ui/SearchInput.tsx +25 -0
  129. package/src/client/ui/Segmented.tsx +61 -0
  130. package/src/client/ui/Stat.tsx +104 -0
  131. package/src/client/ui/States.tsx +81 -0
  132. package/src/client/ui/Table.tsx +229 -0
  133. package/src/client/ui/index.ts +9 -10
  134. package/src/client/ui/modal.css +54 -0
  135. package/src/client/ui/request-drawer.css +40 -0
  136. package/src/db.ts +379 -983
  137. package/src/frustration.ts +473 -0
  138. package/src/index.ts +22 -22
  139. package/src/live.ts +259 -0
  140. package/src/parser.ts +55 -66
  141. package/src/rollup.ts +951 -0
  142. package/src/server.ts +123 -40
  143. package/src/shared-types.ts +120 -56
  144. package/src/trace.ts +14 -7
  145. package/src/types.ts +4 -0
  146. package/src/user-metrics.ts +13 -0
  147. package/dist/client/styles.css +0 -1976
  148. package/dist/types/client/app/AppLayout.d.ts +0 -16
  149. package/dist/types/client/app/NavRail.d.ts +0 -7
  150. package/dist/types/client/app/RangeControl.d.ts +0 -7
  151. package/dist/types/client/app/SyncButton.d.ts +0 -14
  152. package/dist/types/client/app/TopBar.d.ts +0 -15
  153. package/dist/types/client/app/routes.d.ts +0 -12
  154. package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
  155. package/dist/types/client/components/chart-shared.d.ts +0 -178
  156. package/dist/types/client/components/models-table-shared.d.ts +0 -175
  157. package/dist/types/client/components/range-meta.d.ts +0 -21
  158. package/dist/types/client/data/charts.d.ts +0 -1
  159. package/dist/types/client/data/useResource.d.ts +0 -13
  160. package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
  161. package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
  162. package/dist/types/client/ui/DataTable.d.ts +0 -17
  163. package/dist/types/client/ui/EmptyState.d.ts +0 -7
  164. package/dist/types/client/ui/ErrorState.d.ts +0 -6
  165. package/dist/types/client/ui/MetricCluster.d.ts +0 -5
  166. package/dist/types/client/ui/Panel.d.ts +0 -7
  167. package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
  168. package/dist/types/client/ui/Skeleton.d.ts +0 -8
  169. package/dist/types/client/ui/StatusPill.d.ts +0 -7
  170. package/src/client/app/AppLayout.tsx +0 -93
  171. package/src/client/app/NavRail.tsx +0 -44
  172. package/src/client/app/RangeControl.tsx +0 -39
  173. package/src/client/app/SyncButton.tsx +0 -75
  174. package/src/client/app/TopBar.tsx +0 -73
  175. package/src/client/app/routes.ts +0 -93
  176. package/src/client/components/AgentTokenShare.tsx +0 -68
  177. package/src/client/components/chart-shared.tsx +0 -273
  178. package/src/client/components/models-table-shared.tsx +0 -255
  179. package/src/client/components/range-meta.ts +0 -73
  180. package/src/client/data/charts.ts +0 -14
  181. package/src/client/data/useResource.ts +0 -154
  182. package/src/client/routes/BehaviorRoute.tsx +0 -623
  183. package/src/client/ui/AsyncBoundary.tsx +0 -54
  184. package/src/client/ui/DataTable.tsx +0 -122
  185. package/src/client/ui/EmptyState.tsx +0 -16
  186. package/src/client/ui/ErrorState.tsx +0 -25
  187. package/src/client/ui/MetricCluster.tsx +0 -92
  188. package/src/client/ui/Panel.tsx +0 -24
  189. package/src/client/ui/SegmentedControl.tsx +0 -36
  190. package/src/client/ui/Skeleton.tsx +0 -17
  191. package/src/client/ui/StatusPill.tsx +0 -15
  192. package/tailwind.config.js +0 -40
@@ -1,11 +1,12 @@
1
1
  import { useCallback, useRef, useState } from "react";
2
- import { AppLayout } from "./app/AppLayout";
3
- import type { DashboardSection } from "./app/routes";
2
+ import type { DashboardSection } from "./app/nav";
3
+ import { Shell } from "./app/Shell";
4
+ import { LiveProvider } from "./data/live";
4
5
  import { useHashRoute } from "./data/useHashRoute";
5
6
  import {
6
- BehaviorRoute,
7
7
  CostsRoute,
8
8
  ErrorsRoute,
9
+ FrustrationRoute,
9
10
  GainRoute,
10
11
  ModelsRoute,
11
12
  OverviewRoute,
@@ -18,104 +19,61 @@ import {
18
19
  import { RequestDrawer } from "./ui/RequestDrawer";
19
20
 
20
21
  export default function App() {
22
+ return (
23
+ <LiveProvider>
24
+ <Dashboard />
25
+ </LiveProvider>
26
+ );
27
+ }
28
+
29
+ function Dashboard() {
21
30
  const { section, setSection, range, setRange, session, setSession } = useHashRoute();
22
- const [refreshTrigger, setRefreshTrigger] = useState(0);
23
31
  const [selectedRequestId, setSelectedRequestId] = useState<number | null>(null);
24
- const [updatedAt, setUpdatedAt] = useState<number | null>(() => Date.now());
25
-
26
- const handleSyncComplete = useCallback((result: { success: boolean }) => {
27
- if (result.success) {
28
- setRefreshTrigger(prev => prev + 1);
29
- setUpdatedAt(Date.now());
30
- }
31
- }, []);
32
-
33
- // Stable identity so the drawer's effects don't tear down on every App render.
32
+ // Stable identity so the drawer's effects don't tear down on every render.
34
33
  const closeDrawer = useCallback(() => setSelectedRequestId(null), []);
35
34
 
36
- const active = section;
35
+ // Visited pages stay mounted (hidden) so revisits are instant and keep their
36
+ // scroll-independent UI state; only the active page fetches (`active`).
37
+ const mounted = useRef(new Set<DashboardSection>());
38
+ mounted.current.add(section);
37
39
 
38
- // Keep every visited section mounted and just toggle visibility. Remounting a
39
- // route on each navigation replays the chart entry animations (a visible
40
- // flicker); keeping it alive makes revisits instant while the live chart
41
- // instances still animate in place on data/range updates. Only the active
42
- // route fetches/polls (enabled), so hidden routes don't keep hitting the API.
43
- const mountedRef = useRef<Set<DashboardSection>>(new Set());
44
- mountedRef.current.add(active);
45
-
46
- const renderRoute = (target: DashboardSection) => {
47
- const isActive = target === active;
40
+ const render = (target: DashboardSection) => {
41
+ const active = target === section;
48
42
  switch (target) {
49
43
  case "overview":
50
- return (
51
- <OverviewRoute
52
- active={isActive}
53
- range={range}
54
- refreshTrigger={refreshTrigger}
55
- onRequestClick={setSelectedRequestId}
56
- />
57
- );
44
+ return <OverviewRoute active={active} range={range} onRequestClick={setSelectedRequestId} />;
58
45
  case "requests":
59
- return (
60
- <RequestsRoute
61
- active={isActive}
62
- range={range}
63
- refreshTrigger={refreshTrigger}
64
- onRequestClick={setSelectedRequestId}
65
- />
66
- );
67
- case "traces":
68
- return (
69
- <TracesRoute
70
- active={isActive}
71
- session={session}
72
- onOpenSession={setSession}
73
- refreshTrigger={refreshTrigger}
74
- />
75
- );
46
+ return <RequestsRoute active={active} range={range} onRequestClick={setSelectedRequestId} />;
76
47
  case "errors":
77
- return (
78
- <ErrorsRoute
79
- active={isActive}
80
- range={range}
81
- refreshTrigger={refreshTrigger}
82
- onRequestClick={setSelectedRequestId}
83
- />
84
- );
48
+ return <ErrorsRoute active={active} range={range} onRequestClick={setSelectedRequestId} />;
49
+ case "traces":
50
+ return <TracesRoute active={active} session={session} onOpenSession={setSession} />;
85
51
  case "models":
86
- return <ModelsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
52
+ return <ModelsRoute active={active} range={range} />;
87
53
  case "providers":
88
- return <ProvidersRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
89
- case "tools":
90
- return <ToolsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
54
+ return <ProvidersRoute active={active} range={range} />;
91
55
  case "costs":
92
- return <CostsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
93
- case "behavior":
94
- return <BehaviorRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
56
+ return <CostsRoute active={active} range={range} />;
57
+ case "tools":
58
+ return <ToolsRoute active={active} range={range} />;
59
+ case "frustration":
60
+ return <FrustrationRoute active={active} range={range} />;
95
61
  case "projects":
96
- return <ProjectsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
62
+ return <ProjectsRoute active={active} range={range} />;
97
63
  case "gain":
98
- return <GainRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
64
+ return <GainRoute active={active} range={range} />;
99
65
  }
100
66
  };
101
67
 
102
68
  return (
103
69
  <>
104
- <AppLayout
105
- activeSection={active}
106
- onSectionChange={setSection}
107
- range={range}
108
- onRangeChange={setRange}
109
- updatedAt={updatedAt}
110
- onSyncComplete={handleSyncComplete}
111
- >
112
- {[...mountedRef.current].map(target => (
113
- <div key={target} hidden={target !== active}>
114
- {renderRoute(target)}
70
+ <Shell section={section} onSectionChange={setSection} range={range} onRangeChange={setRange}>
71
+ {[...mounted.current].map(target => (
72
+ <div key={target} hidden={target !== section}>
73
+ {render(target)}
115
74
  </div>
116
75
  ))}
117
- </AppLayout>
118
-
76
+ </Shell>
119
77
  <RequestDrawer id={selectedRequestId} onClose={closeDrawer} />
120
78
  </>
121
79
  );
package/src/client/api.ts CHANGED
@@ -1,12 +1,16 @@
1
1
  import type {
2
- BehaviorDashboardStats,
3
2
  CostDashboardStats,
4
3
  FolderStats,
4
+ FrustrationDashboardStats,
5
+ FrustrationEstimate,
6
+ FrustrationJobStatus,
5
7
  GainDashboardStats,
8
+ LiveStatus,
6
9
  MessageStats,
7
10
  ModelDashboardStats,
8
11
  OverviewStats,
9
12
  ProviderDashboardStats,
13
+ ProviderWindowStats,
10
14
  RequestDetails,
11
15
  SessionSummary,
12
16
  SessionTrace,
@@ -28,14 +32,33 @@ export class ApiError extends Error {
28
32
  }
29
33
  }
30
34
 
35
+ /** Prefer the server's `{ error }` body over a bare status line. */
36
+ async function readErrorMessage(res: Response, endpoint: string): Promise<string> {
37
+ try {
38
+ const body: unknown = await res.json();
39
+ if (typeof body === "object" && body !== null && "error" in body && typeof body.error === "string") {
40
+ return body.error;
41
+ }
42
+ } catch {
43
+ // Non-JSON error body: fall through to the status line.
44
+ }
45
+ return `HTTP error ${res.status} on ${endpoint}`;
46
+ }
47
+
31
48
  async function fetchJson<T>(endpoint: string, options?: RequestInit): Promise<T> {
32
49
  const res = await fetch(endpoint, options);
33
50
  if (!res.ok) {
34
- throw new ApiError(res.status, endpoint, `HTTP error ${res.status} on ${endpoint}`);
51
+ throw new ApiError(res.status, endpoint, await readErrorMessage(res, endpoint));
35
52
  }
36
53
  return res.json() as Promise<T>;
37
54
  }
38
55
 
56
+ /**
57
+ * Spend-incurring actions carry this custom header. It forces a CORS preflight
58
+ * the server never approves, so a cross-site page cannot trigger them.
59
+ */
60
+ const ACTION_HEADERS = { "X-Omp-Stats-Action": "1" };
61
+
39
62
  export async function getOverviewStats(range: TimeRange = "24h", signal?: AbortSignal): Promise<OverviewStats> {
40
63
  return fetchJson<OverviewStats>(`${API_BASE}/stats/overview?range=${encodeURIComponent(range)}`, {
41
64
  signal,
@@ -76,19 +99,42 @@ export async function getRequestDetails(id: number, signal?: AbortSignal): Promi
76
99
  return fetchJson<RequestDetails>(`${API_BASE}/request/${id}`, { signal });
77
100
  }
78
101
 
79
- export async function sync(signal?: AbortSignal): Promise<{ processed: number; files: number; totalMessages: number }> {
80
- return fetchJson<{ processed: number; files: number; totalMessages: number }>(`${API_BASE}/sync`, { signal });
102
+ /** Ask the server to start a background sync; progress arrives on the `/api/events` stream. */
103
+ export async function requestSync(): Promise<LiveStatus> {
104
+ return fetchJson<LiveStatus>(`${API_BASE}/sync`, { method: "POST" });
81
105
  }
82
106
 
83
- export async function getBehaviorDashboardStats(
107
+ export async function getFrustrationDashboardStats(
84
108
  range: TimeRange = "24h",
85
109
  signal?: AbortSignal,
86
- ): Promise<BehaviorDashboardStats> {
87
- return fetchJson<BehaviorDashboardStats>(`${API_BASE}/stats/behavior?range=${encodeURIComponent(range)}`, {
110
+ ): Promise<FrustrationDashboardStats> {
111
+ return fetchJson<FrustrationDashboardStats>(`${API_BASE}/stats/frustration?range=${encodeURIComponent(range)}`, {
112
+ signal,
113
+ });
114
+ }
115
+
116
+ /** Pre-run cost quote for judging every unjudged message in `range`. */
117
+ export async function getFrustrationEstimate(range: TimeRange, signal?: AbortSignal): Promise<FrustrationEstimate> {
118
+ return fetchJson<FrustrationEstimate>(`${API_BASE}/frustration/estimate?range=${encodeURIComponent(range)}`, {
88
119
  signal,
89
120
  });
90
121
  }
91
122
 
123
+ /** Start the judge run over the unjudged messages in `range`. Spends money. */
124
+ export async function startFrustrationRun(range: TimeRange): Promise<FrustrationJobStatus> {
125
+ return fetchJson<FrustrationJobStatus>(`${API_BASE}/frustration/judge?range=${encodeURIComponent(range)}`, {
126
+ method: "POST",
127
+ headers: ACTION_HEADERS,
128
+ });
129
+ }
130
+
131
+ export async function cancelFrustrationRun(): Promise<FrustrationJobStatus> {
132
+ return fetchJson<FrustrationJobStatus>(`${API_BASE}/frustration/cancel`, {
133
+ method: "POST",
134
+ headers: ACTION_HEADERS,
135
+ });
136
+ }
137
+
92
138
  export async function getFolderStats(range: TimeRange = "24h", signal?: AbortSignal): Promise<FolderStats[]> {
93
139
  return fetchJson<FolderStats[]>(`${API_BASE}/stats/folders?range=${encodeURIComponent(range)}`, { signal });
94
140
  }
@@ -118,6 +164,18 @@ export async function getProviderDashboardStats(
118
164
  signal,
119
165
  });
120
166
  }
167
+
168
+ /** Subscription-window insights (all providers) plus utilization series for `provider` only. */
169
+ export async function getProviderWindowStats(
170
+ range: TimeRange,
171
+ provider: string | null,
172
+ signal?: AbortSignal,
173
+ ): Promise<ProviderWindowStats> {
174
+ const params = new URLSearchParams({ range });
175
+ if (provider !== null) params.set("provider", provider);
176
+ return fetchJson<ProviderWindowStats>(`${API_BASE}/stats/provider-windows?${params}`, { signal });
177
+ }
178
+
121
179
  export async function getSessions(limit = 100, q?: string, signal?: AbortSignal): Promise<SessionSummary[]> {
122
180
  const params = new URLSearchParams({ limit: String(limit) });
123
181
  if (q) params.set("q", q);
@@ -0,0 +1,97 @@
1
+ import { RefreshCw } from "lucide-react";
2
+ import { type ReactNode, useEffect, useState } from "react";
3
+ import { formatInteger } from "../data/formatters";
4
+ import { useLive } from "../data/live";
5
+ import { Dot } from "../ui";
6
+
7
+ /** Topbar ingest status; click to sync now. */
8
+ export function LiveChip() {
9
+ const { sync, indexingHours, connected, requestSync } = useLive();
10
+ const now = useNow(sync.phase === "idle" ? 15_000 : null);
11
+
12
+ let body: ReactNode;
13
+ let title: string;
14
+ if (!connected) {
15
+ body = (
16
+ <>
17
+ <Dot tone="warn" />
18
+ <span>Reconnecting</span>
19
+ </>
20
+ );
21
+ title = "Lost the live connection to the stats server; retrying";
22
+ } else if (sync.phase === "syncing") {
23
+ body = (
24
+ <>
25
+ <RefreshCw size={13} className="spin" />
26
+ <span>Syncing</span>
27
+ {sync.total > 0 && (
28
+ <span className="live-chip-count">
29
+ {formatInteger(sync.current)}/{formatInteger(sync.total)}
30
+ </span>
31
+ )}
32
+ </>
33
+ );
34
+ title = "Ingesting session files";
35
+ } else if (sync.phase === "error") {
36
+ body = (
37
+ <>
38
+ <Dot tone="bad" />
39
+ <span>Sync failed</span>
40
+ </>
41
+ );
42
+ title = `${sync.error ?? "Sync failed"} — click to retry`;
43
+ } else if (indexingHours > INDEXING_VISIBLE_HOURS) {
44
+ body = (
45
+ <>
46
+ <RefreshCw size={13} className="spin" />
47
+ <span>Indexing</span>
48
+ <span className="live-chip-count">{formatInteger(indexingHours)}h left</span>
49
+ </>
50
+ );
51
+ title = "Building hourly rollups; older ranges fill in as it goes";
52
+ } else {
53
+ body = (
54
+ <>
55
+ <Dot tone="live" pulse />
56
+ <span>Live</span>
57
+ {sync.lastSyncedAt !== null && <span className="live-chip-count">{ago(now - sync.lastSyncedAt)}</span>}
58
+ </>
59
+ );
60
+ title = "Click to sync session files now";
61
+ }
62
+
63
+ return (
64
+ <button
65
+ type="button"
66
+ className="live-chip"
67
+ onClick={requestSync}
68
+ disabled={sync.phase === "syncing"}
69
+ title={title}
70
+ >
71
+ {body}
72
+ </button>
73
+ );
74
+ }
75
+
76
+ /** A handful of dirty hours is normal live churn; only a real backlog is worth showing. */
77
+ const INDEXING_VISIBLE_HOURS = 24;
78
+
79
+ function useNow(intervalMs: number | null): number {
80
+ const [now, setNow] = useState(Date.now);
81
+ useEffect(() => {
82
+ if (intervalMs === null) return;
83
+ setNow(Date.now());
84
+ const timer = setInterval(() => setNow(Date.now()), intervalMs);
85
+ return () => clearInterval(timer);
86
+ }, [intervalMs]);
87
+ return now;
88
+ }
89
+
90
+ function ago(ms: number): string {
91
+ const s = Math.max(0, Math.round(ms / 1000));
92
+ if (s < 45) return "just now";
93
+ const m = Math.round(s / 60);
94
+ if (m < 60) return `${m}m ago`;
95
+ const h = Math.round(m / 60);
96
+ return h < 24 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
97
+ }
@@ -0,0 +1,181 @@
1
+ import { Menu } from "lucide-react";
2
+ import { type ReactNode, useEffect, useState } from "react";
3
+ import { rangeMeta, TIME_RANGES } from "../data/range";
4
+ import { useLive } from "../data/live";
5
+ import type { TimeRange } from "../types";
6
+ import { Segmented } from "../ui";
7
+ import { LiveChip } from "./LiveChip";
8
+ import { type DashboardSection, NAV } from "./nav";
9
+ import { ThemeToggle } from "./ThemeToggle";
10
+
11
+ export interface ShellProps {
12
+ section: DashboardSection;
13
+ onSectionChange: (section: DashboardSection) => void;
14
+ range: TimeRange;
15
+ onRangeChange: (range: TimeRange) => void;
16
+ children: ReactNode;
17
+ }
18
+
19
+ const RANGE_OPTIONS = TIME_RANGES.map((range, i) => ({
20
+ value: range,
21
+ label: rangeMeta(range).label,
22
+ title: `${rangeMeta(range).windowLabel} (${i + 1})`,
23
+ }));
24
+
25
+ /**
26
+ * Fixed glass topbar + naked sidebar framing one inset content panel. Owns
27
+ * the global keyboard shortcuts: `1`–`6` pick a range, `g` then a letter jumps
28
+ * to a page.
29
+ */
30
+ export function Shell({ section, onSectionChange, range, onRangeChange, children }: ShellProps) {
31
+ const [menuOpen, setMenuOpen] = useState(false);
32
+ const scrolled = useScrolled();
33
+ const { sync } = useLive();
34
+ useShortcuts(onSectionChange, onRangeChange);
35
+
36
+ const go = (next: DashboardSection) => {
37
+ onSectionChange(next);
38
+ setMenuOpen(false);
39
+ window.scrollTo({ top: 0 });
40
+ };
41
+
42
+ const progress = sync.phase === "syncing" ? (sync.total > 0 ? sync.current / sync.total : null) : undefined;
43
+
44
+ return (
45
+ <>
46
+ <div className="shell-ambient" />
47
+ <header className="topbar" data-scrolled={scrolled}>
48
+ <button
49
+ type="button"
50
+ className="btn topbar-menu"
51
+ data-variant="ghost"
52
+ data-icon="true"
53
+ onClick={() => setMenuOpen(true)}
54
+ aria-label="Open navigation"
55
+ >
56
+ <Menu size={16} />
57
+ </button>
58
+ <div className="topbar-brand">
59
+ <svg className="topbar-mark" viewBox="0 0 64 64" width="22" height="22" aria-hidden="true">
60
+ <defs>
61
+ <linearGradient id="omp-mark-grad" x1="0" y1="0" x2="1" y2="1">
62
+ <stop offset="0" stopColor="oklch(0.7 0.24 340)" />
63
+ <stop offset=".5" stopColor="oklch(0.62 0.21 295)" />
64
+ <stop offset="1" stopColor="oklch(0.81 0.14 200)" />
65
+ </linearGradient>
66
+ </defs>
67
+ <path fill="url(#omp-mark-grad)" d="M10 14h44v9H43v33h-9V23h-9v22h-9V23H10z" />
68
+ </svg>
69
+ <span>omp</span>
70
+ <span className="topbar-slash">/</span>
71
+ <span className="topbar-title">stats</span>
72
+ </div>
73
+ <div className="topbar-spacer" />
74
+ <div className="topbar-actions">
75
+ <span className="topbar-hide-narrow">
76
+ <LiveChip />
77
+ </span>
78
+ <Segmented options={RANGE_OPTIONS} value={range} onChange={onRangeChange} aria-label="Time range" />
79
+ <ThemeToggle />
80
+ </div>
81
+ {progress !== undefined && (
82
+ <div className="topbar-progress">
83
+ <div
84
+ className="topbar-progress-fill"
85
+ data-indeterminate={progress === null}
86
+ style={{ width: `${(progress ?? 0) * 100}%` }}
87
+ />
88
+ </div>
89
+ )}
90
+ </header>
91
+
92
+ {menuOpen && <div className="drawer-scrim" onClick={() => setMenuOpen(false)} role="presentation" />}
93
+ <nav className="sidebar" data-open={menuOpen} aria-label="Pages">
94
+ {NAV.map(group => (
95
+ <div key={group.heading} className="nav-group">
96
+ <div className="nav-heading">{group.heading}</div>
97
+ {group.items.map(item => (
98
+ <button
99
+ key={item.id}
100
+ type="button"
101
+ className="nav-row"
102
+ data-active={item.id === section}
103
+ aria-current={item.id === section ? "page" : undefined}
104
+ onClick={() => go(item.id)}
105
+ >
106
+ <item.icon size={15} />
107
+ <span className="nav-row-label">{item.label}</span>
108
+ <kbd>G {item.hotkey.toUpperCase()}</kbd>
109
+ </button>
110
+ ))}
111
+ </div>
112
+ ))}
113
+ <div className="sidebar-foot micro">
114
+ <span>
115
+ <span className="kbd">1</span>–<span className="kbd">6</span> range
116
+ </span>
117
+ <span>
118
+ <span className="kbd">G</span> then letter to jump
119
+ </span>
120
+ </div>
121
+ </nav>
122
+
123
+ <main className="shell-panel">
124
+ <div className="shell-content">{children}</div>
125
+ </main>
126
+ </>
127
+ );
128
+ }
129
+
130
+ function useScrolled(): boolean {
131
+ const [scrolled, setScrolled] = useState(false);
132
+ useEffect(() => {
133
+ const onScroll = () => setScrolled(window.scrollY > 4);
134
+ onScroll();
135
+ window.addEventListener("scroll", onScroll, { passive: true });
136
+ return () => window.removeEventListener("scroll", onScroll);
137
+ }, []);
138
+ return scrolled;
139
+ }
140
+
141
+ function useShortcuts(onSection: (section: DashboardSection) => void, onRange: (range: TimeRange) => void): void {
142
+ useEffect(() => {
143
+ let pendingG = 0;
144
+ const onKey = (e: KeyboardEvent) => {
145
+ if (e.metaKey || e.ctrlKey || e.altKey || e.defaultPrevented) return;
146
+ const target = e.target;
147
+ if (
148
+ target instanceof HTMLElement &&
149
+ (target.isContentEditable ||
150
+ target.tagName === "INPUT" ||
151
+ target.tagName === "TEXTAREA" ||
152
+ target.tagName === "SELECT")
153
+ ) {
154
+ return;
155
+ }
156
+ if (document.querySelector('[aria-modal="true"]')) return;
157
+ const key = e.key.toLowerCase();
158
+ if (pendingG && Date.now() - pendingG < 1200) {
159
+ pendingG = 0;
160
+ const item = NAV.flatMap(group => group.items).find(i => i.hotkey === key);
161
+ if (item) {
162
+ e.preventDefault();
163
+ onSection(item.id);
164
+ window.scrollTo({ top: 0 });
165
+ }
166
+ return;
167
+ }
168
+ if (key === "g") {
169
+ pendingG = Date.now();
170
+ return;
171
+ }
172
+ const index = Number(key) - 1;
173
+ if (Number.isInteger(index) && index >= 0 && index < TIME_RANGES.length) {
174
+ e.preventDefault();
175
+ onRange(TIME_RANGES[index]);
176
+ }
177
+ };
178
+ window.addEventListener("keydown", onKey);
179
+ return () => window.removeEventListener("keydown", onKey);
180
+ }, [onSection, onRange]);
181
+ }
@@ -19,6 +19,7 @@ const PREFERENCE_LABEL: Record<ThemePreference, string> = {
19
19
  dark: "Dark theme",
20
20
  };
21
21
 
22
+ /** Cycles system → light → dark. */
22
23
  export function ThemeToggle() {
23
24
  const { preference, setPreference } = useThemePreference();
24
25
  const Icon = PREFERENCE_ICON[preference];
@@ -26,12 +27,14 @@ export function ThemeToggle() {
26
27
  return (
27
28
  <button
28
29
  type="button"
29
- className="stats-theme-toggle"
30
+ className="btn"
31
+ data-variant="ghost"
32
+ data-icon="true"
30
33
  onClick={() => setPreference(NEXT_PREFERENCE[preference])}
31
34
  aria-label={`${PREFERENCE_LABEL[preference]} (click to switch)`}
32
35
  title={`${PREFERENCE_LABEL[preference]} — click to switch`}
33
36
  >
34
- <Icon size={16} />
37
+ <Icon size={15} />
35
38
  </button>
36
39
  );
37
40
  }
@@ -0,0 +1,78 @@
1
+ import {
2
+ Activity,
3
+ CircleAlert,
4
+ Coins,
5
+ Cpu,
6
+ FolderGit2,
7
+ Frown,
8
+ LayoutGrid,
9
+ type LucideIcon,
10
+ PlugZap,
11
+ Sparkles,
12
+ SquareChartGantt,
13
+ Wrench,
14
+ } from "lucide-react";
15
+
16
+ export type DashboardSection =
17
+ | "overview"
18
+ | "models"
19
+ | "providers"
20
+ | "costs"
21
+ | "requests"
22
+ | "errors"
23
+ | "traces"
24
+ | "tools"
25
+ | "frustration"
26
+ | "projects"
27
+ | "gain";
28
+
29
+ export interface NavItem {
30
+ id: DashboardSection;
31
+ label: string;
32
+ icon: LucideIcon;
33
+ /** Second key of the `g <key>` jump shortcut. */
34
+ hotkey: string;
35
+ }
36
+
37
+ export interface NavGroup {
38
+ heading: string;
39
+ items: readonly NavItem[];
40
+ }
41
+
42
+ /** Sidebar structure; the order here is the order on screen. */
43
+ export const NAV: readonly NavGroup[] = [
44
+ {
45
+ heading: "Usage",
46
+ items: [
47
+ { id: "overview", label: "Overview", icon: LayoutGrid, hotkey: "o" },
48
+ { id: "models", label: "Models", icon: Cpu, hotkey: "m" },
49
+ { id: "providers", label: "Providers", icon: PlugZap, hotkey: "p" },
50
+ { id: "costs", label: "Costs", icon: Coins, hotkey: "c" },
51
+ ],
52
+ },
53
+ {
54
+ heading: "Activity",
55
+ items: [
56
+ { id: "requests", label: "Requests", icon: Activity, hotkey: "r" },
57
+ { id: "errors", label: "Errors", icon: CircleAlert, hotkey: "e" },
58
+ { id: "traces", label: "Traces", icon: SquareChartGantt, hotkey: "t" },
59
+ ],
60
+ },
61
+ {
62
+ heading: "Insights",
63
+ items: [
64
+ { id: "tools", label: "Tools", icon: Wrench, hotkey: "l" },
65
+ { id: "frustration", label: "Frustration", icon: Frown, hotkey: "f" },
66
+ { id: "projects", label: "Projects", icon: FolderGit2, hotkey: "j" },
67
+ { id: "gain", label: "Gain", icon: Sparkles, hotkey: "g" },
68
+ ],
69
+ },
70
+ ];
71
+
72
+ export const NAV_ITEMS: readonly NavItem[] = NAV.flatMap(group => group.items);
73
+
74
+ export const SECTIONS: readonly DashboardSection[] = NAV_ITEMS.map(item => item.id);
75
+
76
+ export function navItem(section: DashboardSection): NavItem {
77
+ return NAV_ITEMS.find(item => item.id === section) ?? NAV_ITEMS[0];
78
+ }