@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,154 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ export interface ResourceResult<T> {
4
+ data: T | null;
5
+ error: Error | null;
6
+ loading: boolean;
7
+ refreshing: boolean;
8
+ refetch: () => Promise<void>;
9
+ updatedAt: number | null;
10
+ }
11
+
12
+ export interface ResourceOptions {
13
+ pollMs?: number;
14
+ enabled?: boolean;
15
+ }
16
+
17
+ // Session-scoped cache keyed by the resource key. Lets navigation between
18
+ // screens (and back to an already-visited range) render instantly from the
19
+ // last value and refresh in the background, instead of unmounting to a
20
+ // skeleton on every visit. Cleared only on full page reload.
21
+ const resourceCache = new Map<string, { data: unknown; updatedAt: number }>();
22
+ const RESOURCE_CACHE_LIMIT = 64;
23
+
24
+ export function useResource<T>(
25
+ key: readonly unknown[],
26
+ fetcher: (signal: AbortSignal) => Promise<T>,
27
+ options?: ResourceOptions,
28
+ ): ResourceResult<T> {
29
+ const keyString = JSON.stringify(key);
30
+
31
+ const [data, setData] = useState<T | null>(() => (resourceCache.get(keyString)?.data as T | undefined) ?? null);
32
+ const [error, setError] = useState<Error | null>(null);
33
+ const [loading, setLoading] = useState(() => !resourceCache.has(keyString));
34
+ const [refreshing, setRefreshing] = useState(false);
35
+ const [updatedAt, setUpdatedAt] = useState<number | null>(() => resourceCache.get(keyString)?.updatedAt ?? null);
36
+
37
+ const fetcherRef = useRef(fetcher);
38
+ fetcherRef.current = fetcher;
39
+ const keyStringRef = useRef(keyString);
40
+ keyStringRef.current = keyString;
41
+
42
+ const enabled = options?.enabled ?? true;
43
+ const pollMs = options?.pollMs;
44
+
45
+ const controllerRef = useRef<AbortController | null>(null);
46
+
47
+ // Track whether we already hold data so a key change refreshes in the
48
+ // background — keeping the prior view mounted so charts animate to the new
49
+ // data instead of flashing a skeleton.
50
+ const hasDataRef = useRef(false);
51
+ hasDataRef.current = data !== null;
52
+
53
+ const executeFetch = useCallback(async (isBackground: boolean) => {
54
+ if (controllerRef.current) {
55
+ controllerRef.current.abort();
56
+ }
57
+
58
+ const controller = new AbortController();
59
+ controllerRef.current = controller;
60
+
61
+ if (isBackground) {
62
+ setRefreshing(true);
63
+ } else {
64
+ setLoading(true);
65
+ setData(null);
66
+ }
67
+ setError(null);
68
+
69
+ try {
70
+ const result = await fetcherRef.current(controller.signal);
71
+ if (controller.signal.aborted) {
72
+ return;
73
+ }
74
+ resourceCache.set(keyStringRef.current, { data: result, updatedAt: Date.now() });
75
+ if (resourceCache.size > RESOURCE_CACHE_LIMIT) {
76
+ const oldestKey = resourceCache.keys().next().value;
77
+ if (oldestKey !== undefined) resourceCache.delete(oldestKey);
78
+ }
79
+ setData(result);
80
+ setUpdatedAt(Date.now());
81
+ setError(null);
82
+ } catch (err) {
83
+ if (controller.signal.aborted) {
84
+ return;
85
+ }
86
+ setError(err instanceof Error ? err : new Error(String(err)));
87
+ } finally {
88
+ if (!controller.signal.aborted) {
89
+ setLoading(false);
90
+ setRefreshing(false);
91
+ if (controllerRef.current === controller) {
92
+ controllerRef.current = null;
93
+ }
94
+ }
95
+ }
96
+ }, []);
97
+
98
+ useEffect(() => {
99
+ if (!enabled) {
100
+ setLoading(false);
101
+ setRefreshing(false);
102
+ return;
103
+ }
104
+
105
+ const cached = resourceCache.get(keyString);
106
+ if (cached) {
107
+ // Show the cached value immediately, then revalidate in the background.
108
+ setData(cached.data as T);
109
+ setUpdatedAt(cached.updatedAt);
110
+ setLoading(false);
111
+ executeFetch(true);
112
+ } else {
113
+ // No cache: keep any stale data (range morph) or show a skeleton (first load).
114
+ executeFetch(hasDataRef.current);
115
+ }
116
+
117
+ return () => {
118
+ if (controllerRef.current) {
119
+ controllerRef.current.abort();
120
+ controllerRef.current = null;
121
+ }
122
+ };
123
+ }, [keyString, enabled, executeFetch]);
124
+
125
+ useEffect(() => {
126
+ if (!enabled || !pollMs) {
127
+ return;
128
+ }
129
+
130
+ const interval = setInterval(() => {
131
+ if (document.hidden) {
132
+ return;
133
+ }
134
+ void executeFetch(true);
135
+ }, pollMs);
136
+
137
+ return () => {
138
+ clearInterval(interval);
139
+ };
140
+ }, [enabled, pollMs, executeFetch]);
141
+
142
+ const refetch = useCallback(async () => {
143
+ await executeFetch(true);
144
+ }, [executeFetch]);
145
+
146
+ return {
147
+ data,
148
+ error,
149
+ loading,
150
+ refreshing,
151
+ refetch,
152
+ updatedAt,
153
+ };
154
+ }
@@ -0,0 +1,178 @@
1
+ import { rangeMeta } from "../components/range-meta";
2
+ import type {
3
+ BehaviorOverallStats,
4
+ BehaviorTimeSeriesPoint,
5
+ CostTimeSeriesPoint,
6
+ FolderStats,
7
+ ModelPerformancePoint,
8
+ TimeRange,
9
+ } from "../types";
10
+
11
+ export interface CostSummaryView {
12
+ totalCost: number;
13
+ avgDailyCost: number;
14
+ topModelName: string;
15
+ topModelCost: number;
16
+ }
17
+
18
+ export interface ModelPerformanceDataPoint {
19
+ timestamp: number;
20
+ avgTtftSeconds: number | null;
21
+ avgTokensPerSecond: number | null;
22
+ requests: number;
23
+ }
24
+
25
+ export interface ModelPerformanceSeries {
26
+ label: string;
27
+ data: ModelPerformanceDataPoint[];
28
+ }
29
+
30
+ export interface BehaviorSummaryView {
31
+ totalMessages: number;
32
+ totalYelling: number;
33
+ totalProfanity: number;
34
+ totalAnguish: number;
35
+ totalFrustration: number;
36
+ highestFrictionModel: {
37
+ model: string;
38
+ provider: string;
39
+ score: number;
40
+ } | null;
41
+ }
42
+
43
+ export interface FolderRowView extends FolderStats {
44
+ costPercentage: number;
45
+ requestsPercentage: number;
46
+ }
47
+
48
+ export function buildCostSummary(costSeries: CostTimeSeriesPoint[]): CostSummaryView {
49
+ const totalCost = costSeries.reduce((sum, p) => sum + p.cost, 0);
50
+ const dayBuckets = new Set(costSeries.map(p => p.timestamp)).size;
51
+ const avgDailyCost = dayBuckets > 0 ? totalCost / dayBuckets : 0;
52
+
53
+ const modelTotals = new Map<string, number>();
54
+ for (const point of costSeries) {
55
+ modelTotals.set(point.model, (modelTotals.get(point.model) ?? 0) + point.cost);
56
+ }
57
+
58
+ let topModelName = "";
59
+ let topModelCost = 0;
60
+ for (const [model, cost] of modelTotals) {
61
+ if (cost > topModelCost) {
62
+ topModelName = model;
63
+ topModelCost = cost;
64
+ }
65
+ }
66
+
67
+ return {
68
+ totalCost,
69
+ avgDailyCost,
70
+ topModelName,
71
+ topModelCost,
72
+ };
73
+ }
74
+
75
+ export function buildModelPerformanceLookup(
76
+ points: ModelPerformancePoint[],
77
+ range: TimeRange,
78
+ ): Map<string, ModelPerformanceSeries> {
79
+ if (points.length === 0) return new Map();
80
+
81
+ const meta = rangeMeta(range);
82
+ const bucketMs = meta.bucketMs;
83
+ const bucketCount = meta.bucketCount;
84
+
85
+ const buckets =
86
+ bucketCount > 0
87
+ ? (() => {
88
+ const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0);
89
+ const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / bucketMs) * bucketMs;
90
+ const start = anchor - (bucketCount - 1) * bucketMs;
91
+ return Array.from({ length: bucketCount }, (_, index) => start + index * bucketMs);
92
+ })()
93
+ : Array.from(new Set(points.map(p => p.timestamp))).sort((a, b) => a - b);
94
+ const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index]));
95
+ const seriesByKey = new Map<string, ModelPerformanceSeries>();
96
+
97
+ for (const point of points) {
98
+ const key = `${point.model}::${point.provider}`;
99
+ let series = seriesByKey.get(key);
100
+ if (!series) {
101
+ series = {
102
+ label: `${point.model} (${point.provider})`,
103
+ data: buckets.map(timestamp => ({
104
+ timestamp,
105
+ avgTtftSeconds: null,
106
+ avgTokensPerSecond: null,
107
+ requests: 0,
108
+ })),
109
+ };
110
+ seriesByKey.set(key, series);
111
+ }
112
+
113
+ const index = bucketIndex.get(point.timestamp);
114
+ if (index === undefined) continue;
115
+
116
+ series.data[index] = {
117
+ timestamp: point.timestamp,
118
+ avgTtftSeconds: point.avgTtft !== null ? point.avgTtft / 1000 : null,
119
+ avgTokensPerSecond: point.avgTokensPerSecond,
120
+ requests: point.requests,
121
+ };
122
+ }
123
+
124
+ return seriesByKey;
125
+ }
126
+
127
+ export function buildBehaviorSummary(
128
+ overall: BehaviorOverallStats,
129
+ series: BehaviorTimeSeriesPoint[],
130
+ ): BehaviorSummaryView {
131
+ const totalFrustration = overall.totalNegation + overall.totalRepetition + overall.totalBlame;
132
+
133
+ const totals = new Map<string, { model: string; provider: string; score: number }>();
134
+ for (const point of series) {
135
+ const key = `${point.model}::${point.provider}`;
136
+ const existing = totals.get(key);
137
+ const score = point.yelling + point.profanity + point.anguish + point.negation + point.repetition + point.blame;
138
+ if (existing) {
139
+ existing.score += score;
140
+ } else {
141
+ totals.set(key, { model: point.model, provider: point.provider, score });
142
+ }
143
+ }
144
+
145
+ let highestFrictionModel: { model: string; provider: string; score: number } | null = null;
146
+ for (const entry of totals.values()) {
147
+ if (!highestFrictionModel || entry.score > highestFrictionModel.score) {
148
+ highestFrictionModel = entry;
149
+ }
150
+ }
151
+
152
+ return {
153
+ totalMessages: overall.totalMessages,
154
+ totalYelling: overall.totalYelling,
155
+ totalProfanity: overall.totalProfanity,
156
+ totalAnguish: overall.totalAnguish,
157
+ totalFrustration,
158
+ highestFrictionModel,
159
+ };
160
+ }
161
+
162
+ export function buildFolderRows(folders: FolderStats[]): FolderRowView[] {
163
+ const sorted = [...folders].sort((a, b) => {
164
+ if (b.totalCost !== a.totalCost) {
165
+ return b.totalCost - a.totalCost;
166
+ }
167
+ return b.totalRequests - a.totalRequests;
168
+ });
169
+
170
+ const maxCost = sorted.reduce((max, f) => Math.max(max, f.totalCost), 0);
171
+ const maxRequests = sorted.reduce((max, f) => Math.max(max, f.totalRequests), 0);
172
+
173
+ return sorted.map(f => ({
174
+ ...f,
175
+ costPercentage: maxCost > 0 ? (f.totalCost / maxCost) * 100 : 0,
176
+ requestsPercentage: maxRequests > 0 ? (f.totalRequests / maxRequests) * 100 : 0,
177
+ }));
178
+ }
@@ -1,4 +1,8 @@
1
1
  import "./styles.css";
2
+ // Side-effect import: registers all Chart.js scales/elements/plugins exactly
3
+ // once before any chart renders. Without it the first chart throws
4
+ // "category is not a registered scale" and unmounts the whole app.
5
+ import "./data/charts";
2
6
  import { createRoot } from "react-dom/client";
3
7
  import App from "./App";
4
8