@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
@@ -1,2 +1,11 @@
1
1
  export type SystemTheme = "light" | "dark";
2
+ export type ThemePreference = "system" | "light" | "dark";
3
+ export declare function setThemePreference(next: ThemePreference): void;
4
+ /** Reader for the active resolved theme. Reflects system default and overrides. */
2
5
  export declare function useSystemTheme(): SystemTheme;
6
+ /** Reader + writer for the theme preference (powers the toggle). */
7
+ export declare function useThemePreference(): {
8
+ preference: ThemePreference;
9
+ resolved: SystemTheme;
10
+ setPreference: (next: ThemePreference) => void;
11
+ };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/omp-stats",
4
- "version": "16.0.4",
4
+ "version": "16.0.5",
5
5
  "description": "Local observability dashboard for pi AI usage statistics",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,9 +37,9 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-ai": "16.0.4",
41
- "@oh-my-pi/pi-catalog": "16.0.4",
42
- "@oh-my-pi/pi-utils": "16.0.4",
40
+ "@oh-my-pi/pi-ai": "16.0.5",
41
+ "@oh-my-pi/pi-catalog": "16.0.5",
42
+ "@oh-my-pi/pi-utils": "16.0.5",
43
43
  "@tailwindcss/node": "^4.3.0",
44
44
  "chart.js": "^4.5.1",
45
45
  "date-fns": "^4.4.0",
package/src/aggregator.ts CHANGED
@@ -255,6 +255,7 @@ export async function syncAllSessions(opts?: SyncOptions): Promise<{ processed:
255
255
 
256
256
  const HOUR_MS = 60 * 60 * 1000;
257
257
  const DAY_MS = 24 * HOUR_MS;
258
+ const FIVE_MIN_MS = 5 * 60 * 1000;
258
259
 
259
260
  type TimeRange = "1h" | "24h" | "7d" | "30d" | "90d" | "all";
260
261
 
@@ -274,11 +275,11 @@ const DEFAULT_TIME_RANGE: TimeRange = "24h";
274
275
  const TIME_RANGE_TO_CONFIG: Record<TimeRange, Omit<TimeRangeConfig, "cutoff">> = {
275
276
  "1h": {
276
277
  timeSeriesHours: 1,
277
- timeSeriesBucketMs: HOUR_MS,
278
+ timeSeriesBucketMs: FIVE_MIN_MS,
278
279
  modelSeriesDays: 1,
279
- modelSeriesBucketMs: HOUR_MS,
280
+ modelSeriesBucketMs: FIVE_MIN_MS,
280
281
  modelPerformanceDays: 1,
281
- modelPerformanceBucketMs: HOUR_MS,
282
+ modelPerformanceBucketMs: FIVE_MIN_MS,
282
283
  costSeriesDays: 1,
283
284
  },
284
285
  "24h": {
@@ -1,221 +1,103 @@
1
- import { useCallback, useEffect, useState } from "react";
1
+ import { useCallback, useRef, useState } from "react";
2
+ import { AppLayout } from "./app/AppLayout";
3
+ import type { DashboardSection } from "./app/routes";
4
+ import { useHashRoute } from "./data/useHashRoute";
2
5
  import {
3
- getBehaviorDashboardStats,
4
- getCostDashboardStats,
5
- getModelDashboardStats,
6
- getOverviewStats,
7
- getRecentErrors,
8
- getRecentRequests,
9
- sync,
10
- } from "./api";
11
- import { BehaviorChart } from "./components/BehaviorChart";
12
- import { BehaviorModelsTable } from "./components/BehaviorModelsTable";
13
- import { BehaviorSummary } from "./components/BehaviorSummary";
14
- import { ChartsContainer } from "./components/ChartsContainer";
15
- import { CostChart } from "./components/CostChart";
16
- import { CostSummary } from "./components/CostSummary";
17
- import { Header } from "./components/Header";
18
- import { ModelsTable } from "./components/ModelsTable";
19
- import { RequestDetail } from "./components/RequestDetail";
20
- import { RequestList } from "./components/RequestList";
21
- import { StatsGrid } from "./components/StatsGrid";
22
- import type {
23
- BehaviorDashboardStats,
24
- CostDashboardStats,
25
- MessageStats,
26
- ModelDashboardStats,
27
- OverviewStats,
28
- TimeRange,
29
- } from "./types";
30
-
31
- type Tab = "overview" | "requests" | "errors" | "models" | "costs" | "behavior";
6
+ BehaviorRoute,
7
+ CostsRoute,
8
+ ErrorsRoute,
9
+ ModelsRoute,
10
+ OverviewRoute,
11
+ ProjectsRoute,
12
+ RequestsRoute,
13
+ } from "./routes";
14
+ import { RequestDrawer } from "./ui/RequestDrawer";
32
15
 
33
16
  export default function App() {
34
- const [overviewStats, setOverviewStats] = useState<OverviewStats | null>(null);
35
- const [modelStats, setModelStats] = useState<ModelDashboardStats | null>(null);
36
- const [costStats, setCostStats] = useState<CostDashboardStats | null>(null);
37
- const [behaviorStats, setBehaviorStats] = useState<BehaviorDashboardStats | null>(null);
38
- const [recentRequests, setRecentRequests] = useState<MessageStats[]>([]);
39
- const [recentErrors, setRecentErrors] = useState<MessageStats[]>([]);
40
- const [selectedRequest, setSelectedRequest] = useState<number | null>(null);
41
- const [syncing, setSyncing] = useState(false);
42
- const [activeTab, setActiveTab] = useState<Tab>("overview");
43
- const [timeRange, setTimeRange] = useState<TimeRange>("24h");
44
-
45
- const loadRecentLists = useCallback(async () => {
46
- try {
47
- const [requests, errors] = await Promise.all([getRecentRequests(50), getRecentErrors(50)]);
48
- setRecentRequests(requests);
49
- setRecentErrors(errors);
50
- } catch (err) {
51
- console.error(err);
17
+ const { section, setSection, range, setRange } = useHashRoute();
18
+ const [refreshTrigger, setRefreshTrigger] = useState(0);
19
+ const [selectedRequestId, setSelectedRequestId] = useState<number | null>(null);
20
+ const [updatedAt, setUpdatedAt] = useState<number | null>(() => Date.now());
21
+
22
+ const handleSyncComplete = useCallback((result: { success: boolean }) => {
23
+ if (result.success) {
24
+ setRefreshTrigger(prev => prev + 1);
25
+ setUpdatedAt(Date.now());
52
26
  }
53
27
  }, []);
54
28
 
55
- const loadActiveTabStats = useCallback(async () => {
56
- try {
57
- if (activeTab === "models") {
58
- setModelStats(await getModelDashboardStats(timeRange));
59
- return;
60
- }
61
- if (activeTab === "costs") {
62
- setCostStats(await getCostDashboardStats(timeRange));
63
- return;
64
- }
65
- if (activeTab === "behavior") {
66
- setBehaviorStats(await getBehaviorDashboardStats(timeRange));
67
- return;
68
- }
69
- if (activeTab === "overview") {
70
- setOverviewStats(await getOverviewStats(timeRange));
71
- }
72
- } catch (err) {
73
- console.error(err);
74
- }
75
- }, [activeTab, timeRange]);
76
-
77
- const handleSync = async () => {
78
- setSyncing(true);
79
- try {
80
- await sync();
81
- await Promise.all([loadActiveTabStats(), loadRecentLists()]);
82
- } finally {
83
- setSyncing(false);
29
+ // Stable identity so the drawer's effects don't tear down on every App render.
30
+ const closeDrawer = useCallback(() => setSelectedRequestId(null), []);
31
+
32
+ const active = section;
33
+
34
+ // Keep every visited section mounted and just toggle visibility. Remounting a
35
+ // route on each navigation replays the chart entry animations (a visible
36
+ // flicker); keeping it alive makes revisits instant while the live chart
37
+ // instances still animate in place on data/range updates. Only the active
38
+ // route fetches/polls (enabled), so hidden routes don't keep hitting the API.
39
+ const mountedRef = useRef<Set<DashboardSection>>(new Set());
40
+ mountedRef.current.add(active);
41
+
42
+ const renderRoute = (target: DashboardSection) => {
43
+ const isActive = target === active;
44
+ switch (target) {
45
+ case "overview":
46
+ return (
47
+ <OverviewRoute
48
+ active={isActive}
49
+ range={range}
50
+ refreshTrigger={refreshTrigger}
51
+ onRequestClick={setSelectedRequestId}
52
+ />
53
+ );
54
+ case "requests":
55
+ return (
56
+ <RequestsRoute
57
+ active={isActive}
58
+ range={range}
59
+ refreshTrigger={refreshTrigger}
60
+ onRequestClick={setSelectedRequestId}
61
+ />
62
+ );
63
+ case "errors":
64
+ return (
65
+ <ErrorsRoute
66
+ active={isActive}
67
+ range={range}
68
+ refreshTrigger={refreshTrigger}
69
+ onRequestClick={setSelectedRequestId}
70
+ />
71
+ );
72
+ case "models":
73
+ return <ModelsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
74
+ case "costs":
75
+ return <CostsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
76
+ case "behavior":
77
+ return <BehaviorRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
78
+ case "projects":
79
+ return <ProjectsRoute active={isActive} range={range} refreshTrigger={refreshTrigger} />;
84
80
  }
85
81
  };
86
82
 
87
- useEffect(() => {
88
- loadRecentLists();
89
- const interval = setInterval(loadRecentLists, 30000);
90
- return () => clearInterval(interval);
91
- }, [loadRecentLists]);
92
-
93
- useEffect(() => {
94
- loadActiveTabStats();
95
- const interval = setInterval(loadActiveTabStats, 30000);
96
- return () => clearInterval(interval);
97
- }, [loadActiveTabStats]);
98
-
99
83
  return (
100
- <div className="min-h-screen">
101
- <div className="max-w-[1600px] mx-auto px-6 py-6">
102
- <Header
103
- activeTab={activeTab}
104
- onTabChange={setActiveTab}
105
- onSync={handleSync}
106
- syncing={syncing}
107
- timeRange={timeRange}
108
- onTimeRangeChange={setTimeRange}
109
- />
110
-
111
- {activeTab === "overview" && (
112
- <div className="space-y-6 animate-fade-in">
113
- {overviewStats ? (
114
- <StatsGrid stats={overviewStats.overall} />
115
- ) : (
116
- <LoadingState label="Loading overview..." />
117
- )}
118
-
119
- <div className="grid lg:grid-cols-2 gap-6">
120
- <RequestList
121
- title="Recent Requests"
122
- requests={recentRequests.slice(0, 10)}
123
- onSelect={r => r.id && setSelectedRequest(r.id)}
124
- />
125
- <RequestList
126
- title="Recent Errors"
127
- requests={recentErrors.slice(0, 10)}
128
- onSelect={r => r.id && setSelectedRequest(r.id)}
129
- />
130
- </div>
131
- </div>
132
- )}
133
-
134
- {activeTab === "requests" && (
135
- <div className="h-[calc(100vh-140px)] animate-fade-in">
136
- <RequestList
137
- title="All Recent Requests"
138
- requests={recentRequests}
139
- onSelect={r => r.id && setSelectedRequest(r.id)}
140
- />
141
- </div>
142
- )}
143
-
144
- {activeTab === "errors" && (
145
- <div className="h-[calc(100vh-140px)] animate-fade-in">
146
- <RequestList
147
- title="Failed Requests"
148
- requests={recentErrors}
149
- onSelect={r => r.id && setSelectedRequest(r.id)}
150
- />
151
- </div>
152
- )}
153
-
154
- {activeTab === "models" && (
155
- <div className="space-y-6 animate-fade-in">
156
- {modelStats ? (
157
- <>
158
- <ChartsContainer modelSeries={modelStats.modelSeries} timeRange={timeRange} />
159
- <ModelsTable
160
- models={modelStats.byModel}
161
- performanceSeries={modelStats.modelPerformanceSeries}
162
- timeRange={timeRange}
163
- />
164
- </>
165
- ) : (
166
- <LoadingState label="Loading models..." />
167
- )}
84
+ <>
85
+ <AppLayout
86
+ activeSection={active}
87
+ onSectionChange={setSection}
88
+ range={range}
89
+ onRangeChange={setRange}
90
+ updatedAt={updatedAt}
91
+ onSyncComplete={handleSyncComplete}
92
+ >
93
+ {[...mountedRef.current].map(target => (
94
+ <div key={target} hidden={target !== active}>
95
+ {renderRoute(target)}
168
96
  </div>
169
- )}
97
+ ))}
98
+ </AppLayout>
170
99
 
171
- {activeTab === "costs" && (
172
- <div className="space-y-6 animate-fade-in">
173
- {costStats ? (
174
- <>
175
- <CostSummary costSeries={costStats.costSeries} />
176
- <CostChart costSeries={costStats.costSeries} />
177
- </>
178
- ) : (
179
- <LoadingState label="Loading costs..." />
180
- )}
181
- </div>
182
- )}
183
-
184
- {activeTab === "behavior" && (
185
- <div className="space-y-6 animate-fade-in">
186
- {behaviorStats ? (
187
- <>
188
- <BehaviorSummary
189
- overall={behaviorStats.overall}
190
- behaviorSeries={behaviorStats.behaviorSeries}
191
- />
192
- <BehaviorChart behaviorSeries={behaviorStats.behaviorSeries} />
193
- <BehaviorModelsTable
194
- models={behaviorStats.byModel}
195
- behaviorSeries={behaviorStats.behaviorSeries}
196
- />
197
- </>
198
- ) : (
199
- <LoadingState label="Loading behavior..." />
200
- )}
201
- </div>
202
- )}
203
-
204
- {selectedRequest !== null && (
205
- <RequestDetail id={selectedRequest} onClose={() => setSelectedRequest(null)} />
206
- )}
207
- </div>
208
- </div>
209
- );
210
- }
211
-
212
- function LoadingState({ label }: { label: string }) {
213
- return (
214
- <div className="min-h-[180px] flex items-center justify-center">
215
- <div className="flex items-center gap-3 text-[var(--text-muted)]">
216
- <div className="w-5 h-5 border-2 border-[var(--border-default)] border-t-[var(--accent-cyan)] rounded-full spin" />
217
- <span className="text-sm">{label}</span>
218
- </div>
219
- </div>
100
+ <RequestDrawer id={selectedRequestId} onClose={closeDrawer} />
101
+ </>
220
102
  );
221
103
  }
package/src/client/api.ts CHANGED
@@ -1,65 +1,83 @@
1
1
  import type {
2
2
  BehaviorDashboardStats,
3
3
  CostDashboardStats,
4
- DashboardStats,
4
+ FolderStats,
5
5
  MessageStats,
6
6
  ModelDashboardStats,
7
7
  OverviewStats,
8
8
  RequestDetails,
9
+ TimeRange,
9
10
  } from "./types";
10
11
 
11
12
  const API_BASE = "/api";
12
13
 
13
- export async function getStats(range = "24h"): Promise<DashboardStats> {
14
- const res = await fetch(`${API_BASE}/stats?range=${encodeURIComponent(range)}`);
15
- if (!res.ok) throw new Error("Failed to fetch stats");
16
- return res.json() as Promise<DashboardStats>;
14
+ export class ApiError extends Error {
15
+ status: number;
16
+ endpoint: string;
17
+
18
+ constructor(status: number, endpoint: string, message: string) {
19
+ super(message);
20
+ this.name = "ApiError";
21
+ this.status = status;
22
+ this.endpoint = endpoint;
23
+ }
24
+ }
25
+
26
+ async function fetchJson<T>(endpoint: string, options?: RequestInit): Promise<T> {
27
+ const res = await fetch(endpoint, options);
28
+ if (!res.ok) {
29
+ throw new ApiError(res.status, endpoint, `HTTP error ${res.status} on ${endpoint}`);
30
+ }
31
+ return res.json() as Promise<T>;
32
+ }
33
+
34
+ export async function getOverviewStats(range: TimeRange = "24h", signal?: AbortSignal): Promise<OverviewStats> {
35
+ return fetchJson<OverviewStats>(`${API_BASE}/stats/overview?range=${encodeURIComponent(range)}`, {
36
+ signal,
37
+ });
17
38
  }
18
39
 
19
- export async function getOverviewStats(range = "24h"): Promise<OverviewStats> {
20
- const res = await fetch(`${API_BASE}/stats/overview?range=${encodeURIComponent(range)}`);
21
- if (!res.ok) throw new Error("Failed to fetch overview stats");
22
- return res.json() as Promise<OverviewStats>;
40
+ export async function getModelDashboardStats(
41
+ range: TimeRange = "24h",
42
+ signal?: AbortSignal,
43
+ ): Promise<ModelDashboardStats> {
44
+ return fetchJson<ModelDashboardStats>(`${API_BASE}/stats/model-dashboard?range=${encodeURIComponent(range)}`, {
45
+ signal,
46
+ });
23
47
  }
24
48
 
25
- export async function getModelDashboardStats(range = "24h"): Promise<ModelDashboardStats> {
26
- const res = await fetch(`${API_BASE}/stats/model-dashboard?range=${encodeURIComponent(range)}`);
27
- if (!res.ok) throw new Error("Failed to fetch model stats");
28
- return res.json() as Promise<ModelDashboardStats>;
49
+ export async function getCostDashboardStats(
50
+ range: TimeRange = "24h",
51
+ signal?: AbortSignal,
52
+ ): Promise<CostDashboardStats> {
53
+ return fetchJson<CostDashboardStats>(`${API_BASE}/stats/costs?range=${encodeURIComponent(range)}`, { signal });
29
54
  }
30
55
 
31
- export async function getCostDashboardStats(range = "24h"): Promise<CostDashboardStats> {
32
- const res = await fetch(`${API_BASE}/stats/costs?range=${encodeURIComponent(range)}`);
33
- if (!res.ok) throw new Error("Failed to fetch cost stats");
34
- return res.json() as Promise<CostDashboardStats>;
56
+ export async function getRecentRequests(limit = 50, signal?: AbortSignal): Promise<MessageStats[]> {
57
+ return fetchJson<MessageStats[]>(`${API_BASE}/stats/recent?limit=${limit}`, { signal });
35
58
  }
36
59
 
37
- export async function getRecentRequests(limit = 50): Promise<MessageStats[]> {
38
- const res = await fetch(`${API_BASE}/stats/recent?limit=${limit}`);
39
- if (!res.ok) throw new Error("Failed to fetch recent requests");
40
- return res.json() as Promise<MessageStats[]>;
60
+ export async function getRecentErrors(limit = 50, signal?: AbortSignal): Promise<MessageStats[]> {
61
+ return fetchJson<MessageStats[]>(`${API_BASE}/stats/errors?limit=${limit}`, { signal });
41
62
  }
42
63
 
43
- export async function getRecentErrors(limit = 50): Promise<MessageStats[]> {
44
- const res = await fetch(`${API_BASE}/stats/errors?limit=${limit}`);
45
- if (!res.ok) throw new Error("Failed to fetch recent errors");
46
- return res.json() as Promise<MessageStats[]>;
64
+ export async function getRequestDetails(id: number, signal?: AbortSignal): Promise<RequestDetails> {
65
+ return fetchJson<RequestDetails>(`${API_BASE}/request/${id}`, { signal });
47
66
  }
48
67
 
49
- export async function getRequestDetails(id: number): Promise<RequestDetails> {
50
- const res = await fetch(`${API_BASE}/request/${id}`);
51
- if (!res.ok) throw new Error("Failed to fetch request details");
52
- return res.json() as Promise<RequestDetails>;
68
+ export async function sync(signal?: AbortSignal): Promise<{ processed: number; files: number; totalMessages: number }> {
69
+ return fetchJson<{ processed: number; files: number; totalMessages: number }>(`${API_BASE}/sync`, { signal });
53
70
  }
54
71
 
55
- export async function sync(): Promise<any> {
56
- const res = await fetch(`${API_BASE}/sync`);
57
- if (!res.ok) throw new Error("Failed to sync");
58
- return res.json();
72
+ export async function getBehaviorDashboardStats(
73
+ range: TimeRange = "24h",
74
+ signal?: AbortSignal,
75
+ ): Promise<BehaviorDashboardStats> {
76
+ return fetchJson<BehaviorDashboardStats>(`${API_BASE}/stats/behavior?range=${encodeURIComponent(range)}`, {
77
+ signal,
78
+ });
59
79
  }
60
80
 
61
- export async function getBehaviorDashboardStats(range = "24h"): Promise<BehaviorDashboardStats> {
62
- const res = await fetch(`${API_BASE}/stats/behavior?range=${encodeURIComponent(range)}`);
63
- if (!res.ok) throw new Error("Failed to fetch behavior stats");
64
- return res.json() as Promise<BehaviorDashboardStats>;
81
+ export async function getFolderStats(range: TimeRange = "24h", signal?: AbortSignal): Promise<FolderStats[]> {
82
+ return fetchJson<FolderStats[]>(`${API_BASE}/stats/folders?range=${encodeURIComponent(range)}`, { signal });
65
83
  }
@@ -0,0 +1,93 @@
1
+ import { X } from "lucide-react";
2
+ import type React from "react";
3
+ import { useState } from "react";
4
+ import type { TimeRange } from "../types";
5
+ import { NavRail } from "./NavRail";
6
+ import type { DashboardSection } from "./routes";
7
+ import { TopBar } from "./TopBar";
8
+
9
+ export interface AppLayoutProps {
10
+ activeSection: DashboardSection;
11
+ onSectionChange: (section: DashboardSection) => void;
12
+ range: TimeRange;
13
+ onRangeChange: (range: TimeRange) => void;
14
+ updatedAt: number | null;
15
+ onSyncStart?: () => void;
16
+ onSyncComplete?: (result: { success: boolean }) => void;
17
+ children: React.ReactNode;
18
+ }
19
+
20
+ export function AppLayout({
21
+ activeSection,
22
+ onSectionChange,
23
+ range,
24
+ onRangeChange,
25
+ updatedAt,
26
+ onSyncStart,
27
+ onSyncComplete,
28
+ children,
29
+ }: AppLayoutProps) {
30
+ const [menuOpen, setMenuOpen] = useState(false);
31
+
32
+ const handleSectionChange = (section: DashboardSection) => {
33
+ onSectionChange(section);
34
+ setMenuOpen(false);
35
+ };
36
+
37
+ return (
38
+ <div className="stats-app-container">
39
+ {/* Desktop Rail */}
40
+ <NavRail activeSection={activeSection} onSectionChange={handleSectionChange} className="stats-desktop-nav" />
41
+
42
+ {/* Mobile Nav Drawer */}
43
+ {menuOpen && (
44
+ <div className="stats-mobile-drawer-overlay" onClick={() => setMenuOpen(false)} role="presentation">
45
+ <div
46
+ className="stats-mobile-drawer"
47
+ onClick={e => e.stopPropagation()}
48
+ role="dialog"
49
+ aria-modal="true"
50
+ aria-label="Navigation menu"
51
+ >
52
+ <div className="stats-mobile-drawer-header">
53
+ <div className="stats-logo-container">
54
+ <span className="stats-logo-text">OH MY PI</span>
55
+ <span className="stats-logo-subtext">Observability</span>
56
+ </div>
57
+ <button
58
+ type="button"
59
+ onClick={() => setMenuOpen(false)}
60
+ className="stats-drawer-close-btn"
61
+ aria-label="Close navigation menu"
62
+ >
63
+ <X size={18} />
64
+ </button>
65
+ </div>
66
+ <NavRail
67
+ activeSection={activeSection}
68
+ onSectionChange={handleSectionChange}
69
+ className="stats-mobile-nav"
70
+ />
71
+ </div>
72
+ </div>
73
+ )}
74
+
75
+ {/* Main Layout Pane */}
76
+ <div className="stats-main-pane">
77
+ <TopBar
78
+ activeSection={activeSection}
79
+ range={range}
80
+ onRangeChange={onRangeChange}
81
+ updatedAt={updatedAt}
82
+ onSyncStart={onSyncStart}
83
+ onSyncComplete={onSyncComplete}
84
+ onMenuToggle={() => setMenuOpen(true)}
85
+ />
86
+
87
+ <main className="stats-content-area">
88
+ <div className="stats-content-inner">{children}</div>
89
+ </main>
90
+ </div>
91
+ </div>
92
+ );
93
+ }
@@ -0,0 +1,44 @@
1
+ import { type DashboardSection, routes } from "./routes";
2
+
3
+ export interface NavRailProps {
4
+ activeSection: DashboardSection;
5
+ onSectionChange: (section: DashboardSection) => void;
6
+ className?: string;
7
+ }
8
+
9
+ export function NavRail({ activeSection, onSectionChange, className = "" }: NavRailProps) {
10
+ return (
11
+ <aside className={`stats-nav-rail ${className}`}>
12
+ <div className="stats-nav-rail-header">
13
+ <div className="stats-logo-container">
14
+ <span className="stats-logo-text">OH MY PI</span>
15
+ <span className="stats-logo-subtext">Observability</span>
16
+ </div>
17
+ </div>
18
+
19
+ <nav className="stats-nav-rail-menu">
20
+ {routes.map(route => {
21
+ const isActive = route.id === activeSection;
22
+ const Icon = route.icon;
23
+ return (
24
+ <button
25
+ key={route.id}
26
+ type="button"
27
+ onClick={() => onSectionChange(route.id)}
28
+ className="stats-nav-rail-item"
29
+ data-active={isActive ? "true" : "false"}
30
+ aria-current={isActive ? "page" : undefined}
31
+ >
32
+ <Icon size={16} className="stats-nav-rail-item-icon" />
33
+ <span className="stats-nav-rail-item-label">{route.label}</span>
34
+ </button>
35
+ );
36
+ })}
37
+ </nav>
38
+
39
+ <div className="stats-nav-rail-footer">
40
+ <span className="stats-version-tag">OMP Stats v1.0.0</span>
41
+ </div>
42
+ </aside>
43
+ );
44
+ }
@@ -0,0 +1,39 @@
1
+ import type { TimeRange } from "../types";
2
+
3
+ export interface RangeControlProps {
4
+ value: TimeRange;
5
+ onChange: (value: TimeRange) => void;
6
+ className?: string;
7
+ }
8
+
9
+ const RANGE_OPTIONS: { value: TimeRange; label: string }[] = [
10
+ { value: "1h", label: "1h" },
11
+ { value: "24h", label: "24h" },
12
+ { value: "7d", label: "7d" },
13
+ { value: "30d", label: "30d" },
14
+ { value: "90d", label: "90d" },
15
+ { value: "all", label: "All" },
16
+ ];
17
+
18
+ export function RangeControl({ value, onChange, className = "" }: RangeControlProps) {
19
+ return (
20
+ <div className={`stats-range-control ${className}`} role="radiogroup" aria-label="Select time range">
21
+ {RANGE_OPTIONS.map(opt => {
22
+ const isActive = opt.value === value;
23
+ return (
24
+ <button
25
+ key={opt.value}
26
+ type="button"
27
+ role="radio"
28
+ aria-checked={isActive}
29
+ data-active={isActive ? "true" : "false"}
30
+ className="stats-range-control-btn"
31
+ onClick={() => onChange(opt.value)}
32
+ >
33
+ {opt.label}
34
+ </button>
35
+ );
36
+ })}
37
+ </div>
38
+ );
39
+ }