@oh-my-pi-zen/omp-stats 16.3.6-zen.1
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.
- package/CHANGELOG.md +197 -0
- package/README.md +82 -0
- package/build.ts +95 -0
- package/dist/client/index.css +1 -0
- package/dist/client/index.html +24 -0
- package/dist/client/index.js +257 -0
- package/dist/client/styles.css +1656 -0
- package/dist/types/aggregator.d.ts +87 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/api.d.ts +21 -0
- package/dist/types/client/app/AppLayout.d.ts +16 -0
- package/dist/types/client/app/NavRail.d.ts +7 -0
- package/dist/types/client/app/RangeControl.d.ts +7 -0
- package/dist/types/client/app/SyncButton.d.ts +14 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/TopBar.d.ts +15 -0
- package/dist/types/client/app/routes.d.ts +12 -0
- package/dist/types/client/components/AgentTokenShare.d.ts +5 -0
- package/dist/types/client/components/chart-shared.d.ts +173 -0
- package/dist/types/client/components/models-table-shared.d.ts +175 -0
- package/dist/types/client/components/range-meta.d.ts +21 -0
- package/dist/types/client/data/charts.d.ts +1 -0
- package/dist/types/client/data/formatters.d.ts +8 -0
- package/dist/types/client/data/useHashRoute.d.ts +8 -0
- package/dist/types/client/data/useResource.d.ts +13 -0
- package/dist/types/client/data/view-models.d.ts +67 -0
- package/dist/types/client/index.d.ts +2 -0
- package/dist/types/client/routes/BehaviorRoute.d.ts +7 -0
- package/dist/types/client/routes/CostsRoute.d.ts +7 -0
- package/dist/types/client/routes/ErrorsRoute.d.ts +8 -0
- package/dist/types/client/routes/GainRoute.d.ts +7 -0
- package/dist/types/client/routes/ModelsRoute.d.ts +7 -0
- package/dist/types/client/routes/OverviewRoute.d.ts +8 -0
- package/dist/types/client/routes/ProjectsRoute.d.ts +7 -0
- package/dist/types/client/routes/RequestsRoute.d.ts +8 -0
- package/dist/types/client/routes/ToolsRoute.d.ts +7 -0
- package/dist/types/client/routes/index.d.ts +9 -0
- package/dist/types/client/types.d.ts +63 -0
- package/dist/types/client/ui/AsyncBoundary.d.ts +12 -0
- package/dist/types/client/ui/DataTable.d.ts +17 -0
- package/dist/types/client/ui/EmptyState.d.ts +7 -0
- package/dist/types/client/ui/ErrorState.d.ts +6 -0
- package/dist/types/client/ui/JsonBlock.d.ts +7 -0
- package/dist/types/client/ui/MetricCluster.d.ts +5 -0
- package/dist/types/client/ui/Panel.d.ts +7 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +5 -0
- package/dist/types/client/ui/SegmentedControl.d.ts +12 -0
- package/dist/types/client/ui/Skeleton.d.ts +8 -0
- package/dist/types/client/ui/StatusPill.d.ts +7 -0
- package/dist/types/client/ui/index.d.ts +11 -0
- package/dist/types/client/useSystemTheme.d.ts +11 -0
- package/dist/types/db.d.ts +144 -0
- package/dist/types/embedded-client.d.ts +18 -0
- package/dist/types/gain-aggregator.d.ts +26 -0
- package/dist/types/index.d.ts +7 -0
- package/dist/types/parser.d.ts +53 -0
- package/dist/types/server.d.ts +7 -0
- package/dist/types/shared-types.d.ts +301 -0
- package/dist/types/sync-worker.d.ts +31 -0
- package/dist/types/types.d.ts +164 -0
- package/dist/types/user-metrics.d.ts +72 -0
- package/package.json +95 -0
- package/src/aggregator.ts +501 -0
- package/src/client/App.tsx +109 -0
- package/src/client/api.ts +102 -0
- package/src/client/app/AppLayout.tsx +93 -0
- package/src/client/app/NavRail.tsx +44 -0
- package/src/client/app/RangeControl.tsx +39 -0
- package/src/client/app/SyncButton.tsx +75 -0
- package/src/client/app/ThemeToggle.tsx +37 -0
- package/src/client/app/TopBar.tsx +73 -0
- package/src/client/app/routes.ts +69 -0
- package/src/client/components/AgentTokenShare.tsx +68 -0
- package/src/client/components/chart-shared.tsx +257 -0
- package/src/client/components/models-table-shared.tsx +255 -0
- package/src/client/components/range-meta.ts +73 -0
- package/src/client/css.d.ts +1 -0
- package/src/client/data/charts.ts +14 -0
- package/src/client/data/formatters.ts +45 -0
- package/src/client/data/useHashRoute.ts +87 -0
- package/src/client/data/useResource.ts +154 -0
- package/src/client/data/view-models.ts +251 -0
- package/src/client/index.tsx +10 -0
- package/src/client/routes/BehaviorRoute.tsx +623 -0
- package/src/client/routes/CostsRoute.tsx +234 -0
- package/src/client/routes/ErrorsRoute.tsx +118 -0
- package/src/client/routes/GainRoute.tsx +226 -0
- package/src/client/routes/ModelsRoute.tsx +430 -0
- package/src/client/routes/OverviewRoute.tsx +342 -0
- package/src/client/routes/ProjectsRoute.tsx +163 -0
- package/src/client/routes/RequestsRoute.tsx +123 -0
- package/src/client/routes/ToolsRoute.tsx +463 -0
- package/src/client/routes/index.ts +9 -0
- package/src/client/styles.css +1330 -0
- package/src/client/types.ts +80 -0
- package/src/client/ui/AsyncBoundary.tsx +54 -0
- package/src/client/ui/DataTable.tsx +122 -0
- package/src/client/ui/EmptyState.tsx +16 -0
- package/src/client/ui/ErrorState.tsx +25 -0
- package/src/client/ui/JsonBlock.tsx +75 -0
- package/src/client/ui/MetricCluster.tsx +67 -0
- package/src/client/ui/Panel.tsx +24 -0
- package/src/client/ui/RequestDrawer.tsx +208 -0
- package/src/client/ui/SegmentedControl.tsx +36 -0
- package/src/client/ui/Skeleton.tsx +17 -0
- package/src/client/ui/StatusPill.tsx +15 -0
- package/src/client/ui/index.ts +11 -0
- package/src/client/useSystemTheme.ts +87 -0
- package/src/db.ts +1530 -0
- package/src/embedded-client.generated.txt +0 -0
- package/src/embedded-client.ts +26 -0
- package/src/gain-aggregator.ts +281 -0
- package/src/index.ts +194 -0
- package/src/parser.ts +450 -0
- package/src/server.ts +352 -0
- package/src/shared-types.ts +323 -0
- package/src/sync-worker.ts +40 -0
- package/src/types.ts +171 -0
- package/src/user-metrics.ts +685 -0
- package/tailwind.config.js +40 -0
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import type { Plugin } from "chart.js";
|
|
2
|
+
import { useMemo, useState } from "react";
|
|
3
|
+
import { Bar, Line } from "react-chartjs-2";
|
|
4
|
+
import { getCostDashboardStats } from "../api";
|
|
5
|
+
import {
|
|
6
|
+
barDatasetStyle,
|
|
7
|
+
buildAggregateTimeSeries,
|
|
8
|
+
buildSharedPlugins,
|
|
9
|
+
buildSharedScales,
|
|
10
|
+
buildTopNByModelSeries,
|
|
11
|
+
CHART_THEMES,
|
|
12
|
+
lineDatasetStyle,
|
|
13
|
+
MODEL_COLORS,
|
|
14
|
+
styleDatasets,
|
|
15
|
+
} from "../components/chart-shared";
|
|
16
|
+
import { formatCost } from "../data/formatters";
|
|
17
|
+
import { useResource } from "../data/useResource";
|
|
18
|
+
import { buildCostSummary } from "../data/view-models";
|
|
19
|
+
import type { CostTimeSeriesPoint, TimeRange } from "../types";
|
|
20
|
+
import { AsyncBoundary, Panel, SegmentedControl } from "../ui";
|
|
21
|
+
import { useSystemTheme } from "../useSystemTheme";
|
|
22
|
+
|
|
23
|
+
export interface CostsRouteProps {
|
|
24
|
+
active: boolean;
|
|
25
|
+
range: TimeRange;
|
|
26
|
+
refreshTrigger: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function CostsRoute({ active, range, refreshTrigger }: CostsRouteProps) {
|
|
30
|
+
const {
|
|
31
|
+
data: costStats,
|
|
32
|
+
error,
|
|
33
|
+
loading,
|
|
34
|
+
} = useResource(["costs", range, refreshTrigger], signal => getCostDashboardStats(range, signal), {
|
|
35
|
+
pollMs: 30000,
|
|
36
|
+
enabled: active,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
return (
|
|
40
|
+
<div className="stats-route-container space-y-6">
|
|
41
|
+
<AsyncBoundary loading={loading} error={error} data={costStats}>
|
|
42
|
+
{costStats && (
|
|
43
|
+
<>
|
|
44
|
+
<CostOverviewPanel costSeries={costStats.costSeries} />
|
|
45
|
+
<CostTrendPanel costSeries={costStats.costSeries} />
|
|
46
|
+
</>
|
|
47
|
+
)}
|
|
48
|
+
</AsyncBoundary>
|
|
49
|
+
</div>
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function CostOverviewPanel({ costSeries }: { costSeries: CostTimeSeriesPoint[] }) {
|
|
54
|
+
const summary = useMemo(() => buildCostSummary(costSeries), [costSeries]);
|
|
55
|
+
|
|
56
|
+
const cards = [
|
|
57
|
+
{ label: "Total Cost", value: formatCost(summary.totalCost) },
|
|
58
|
+
{ label: "Average / Day", value: formatCost(summary.avgDailyCost) },
|
|
59
|
+
{
|
|
60
|
+
label: "Top Model",
|
|
61
|
+
value: summary.topModelName || "—",
|
|
62
|
+
sub: summary.topModelName ? formatCost(summary.topModelCost) : undefined,
|
|
63
|
+
},
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
return (
|
|
67
|
+
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
68
|
+
{cards.map(card => (
|
|
69
|
+
<Panel key={card.label} className="stats-cost-overview-card py-4 px-5">
|
|
70
|
+
<p className="text-xs stats-text-muted mb-1 font-medium uppercase tracking-wider">{card.label}</p>
|
|
71
|
+
<p className="text-2xl font-bold stats-text-primary truncate" title={card.value}>
|
|
72
|
+
{card.value}
|
|
73
|
+
</p>
|
|
74
|
+
{card.sub && <p className="text-xs stats-text-muted mt-1 font-medium">Total spent: {card.sub}</p>}
|
|
75
|
+
</Panel>
|
|
76
|
+
))}
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const BAR_LABEL_COLORS = {
|
|
82
|
+
dark: "rgba(248, 250, 252, 0.7)",
|
|
83
|
+
light: "rgba(15, 23, 42, 0.6)",
|
|
84
|
+
} as const;
|
|
85
|
+
|
|
86
|
+
// Inline Chart.js plugin to draw cost value above bars
|
|
87
|
+
function makeBarLabelPlugin(color: string): Plugin<"bar"> {
|
|
88
|
+
return {
|
|
89
|
+
id: "costBarLabels",
|
|
90
|
+
afterDatasetsDraw(chart) {
|
|
91
|
+
const { ctx } = chart;
|
|
92
|
+
const dataset = chart.data.datasets[0];
|
|
93
|
+
if (!dataset) return;
|
|
94
|
+
const meta = chart.getDatasetMeta(0);
|
|
95
|
+
ctx.save();
|
|
96
|
+
ctx.font = "11px system-ui, sans-serif";
|
|
97
|
+
ctx.fillStyle = color;
|
|
98
|
+
ctx.textAlign = "center";
|
|
99
|
+
ctx.textBaseline = "bottom";
|
|
100
|
+
for (const bar of meta.data) {
|
|
101
|
+
// Accessing Chart.js internal parsed coordinates via unknown cast
|
|
102
|
+
const value = (bar as unknown as { $context: { parsed: { y: number } } }).$context.parsed.y;
|
|
103
|
+
if (!value) continue;
|
|
104
|
+
const label = `$${Math.round(value)}`;
|
|
105
|
+
// Accessing internal getProps for positioning via unknown cast
|
|
106
|
+
const { x, y } = bar.getProps(["x", "y"], true) as {
|
|
107
|
+
x: number;
|
|
108
|
+
y: number;
|
|
109
|
+
};
|
|
110
|
+
ctx.fillText(label, x, y - 3);
|
|
111
|
+
}
|
|
112
|
+
ctx.restore();
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function CostTrendPanel({ costSeries }: { costSeries: CostTimeSeriesPoint[] }) {
|
|
118
|
+
const [byModel, setByModel] = useState(false);
|
|
119
|
+
const theme = useSystemTheme();
|
|
120
|
+
const chartTheme = CHART_THEMES[theme];
|
|
121
|
+
|
|
122
|
+
const chartData = useMemo(() => {
|
|
123
|
+
if (byModel) {
|
|
124
|
+
return buildTopNByModelSeries<CostTimeSeriesPoint, { total: number }>(costSeries, {
|
|
125
|
+
rankWeight: point => point.cost,
|
|
126
|
+
initBucket: () => ({ total: 0 }),
|
|
127
|
+
accumulate: (bucket, point) => {
|
|
128
|
+
bucket.total += point.cost;
|
|
129
|
+
},
|
|
130
|
+
bucketToValue: bucket => bucket.total,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
return buildAggregateTimeSeries<CostTimeSeriesPoint, { total: number }>(costSeries, "Cost", {
|
|
134
|
+
initBucket: () => ({ total: 0 }),
|
|
135
|
+
accumulate: (bucket, point) => {
|
|
136
|
+
bucket.total += point.cost;
|
|
137
|
+
},
|
|
138
|
+
bucketToValue: bucket => bucket.total,
|
|
139
|
+
});
|
|
140
|
+
}, [costSeries, byModel]);
|
|
141
|
+
|
|
142
|
+
const sharedPlugins = useMemo(() => {
|
|
143
|
+
return buildSharedPlugins({
|
|
144
|
+
chartTheme,
|
|
145
|
+
showLegend: byModel,
|
|
146
|
+
defaultLabel: "Cost",
|
|
147
|
+
formatValue: v => `$${v.toFixed(2)}`,
|
|
148
|
+
footer: items => {
|
|
149
|
+
if (!byModel || items.length < 2) return undefined;
|
|
150
|
+
const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
|
|
151
|
+
return `Total: $${total.toFixed(2)}`;
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
}, [chartTheme, byModel]);
|
|
155
|
+
|
|
156
|
+
const { sharedScaleBase, yScale } = useMemo(() => {
|
|
157
|
+
return buildSharedScales({
|
|
158
|
+
chartTheme,
|
|
159
|
+
formatY: v => `$${Math.round(v)}`,
|
|
160
|
+
});
|
|
161
|
+
}, [chartTheme]);
|
|
162
|
+
|
|
163
|
+
const barLabelPlugin = useMemo(() => {
|
|
164
|
+
return makeBarLabelPlugin(BAR_LABEL_COLORS[theme]);
|
|
165
|
+
}, [theme]);
|
|
166
|
+
|
|
167
|
+
const lineData = useMemo(() => {
|
|
168
|
+
if (!byModel) return null;
|
|
169
|
+
return {
|
|
170
|
+
labels: chartData.labels,
|
|
171
|
+
datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
172
|
+
};
|
|
173
|
+
}, [chartData, byModel]);
|
|
174
|
+
|
|
175
|
+
const lineOptions = useMemo(() => {
|
|
176
|
+
return {
|
|
177
|
+
responsive: true,
|
|
178
|
+
maintainAspectRatio: false,
|
|
179
|
+
interaction: { mode: "index" as const, intersect: false },
|
|
180
|
+
plugins: sharedPlugins,
|
|
181
|
+
scales: { x: sharedScaleBase, y: yScale },
|
|
182
|
+
};
|
|
183
|
+
}, [sharedPlugins, sharedScaleBase, yScale]);
|
|
184
|
+
|
|
185
|
+
const barData = useMemo(() => {
|
|
186
|
+
if (byModel) return null;
|
|
187
|
+
return {
|
|
188
|
+
labels: chartData.labels,
|
|
189
|
+
datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
190
|
+
};
|
|
191
|
+
}, [chartData, byModel]);
|
|
192
|
+
|
|
193
|
+
const barOptions = useMemo(() => {
|
|
194
|
+
return {
|
|
195
|
+
responsive: true,
|
|
196
|
+
maintainAspectRatio: false,
|
|
197
|
+
interaction: { mode: "index" as const, intersect: false },
|
|
198
|
+
plugins: {
|
|
199
|
+
...sharedPlugins,
|
|
200
|
+
costBarLabels: {},
|
|
201
|
+
},
|
|
202
|
+
scales: {
|
|
203
|
+
x: { ...sharedScaleBase, stacked: true },
|
|
204
|
+
y: { ...yScale, stacked: true },
|
|
205
|
+
},
|
|
206
|
+
layout: { padding: { top: 24 } },
|
|
207
|
+
};
|
|
208
|
+
}, [sharedPlugins, sharedScaleBase, yScale]);
|
|
209
|
+
|
|
210
|
+
const toggleOptions = [
|
|
211
|
+
{ value: false, label: "All Models" },
|
|
212
|
+
{ value: true, label: "By Model" },
|
|
213
|
+
];
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<Panel
|
|
217
|
+
title="Daily Cost"
|
|
218
|
+
subtitle="API spending over time"
|
|
219
|
+
actions={<SegmentedControl options={toggleOptions} value={byModel} onChange={setByModel} />}
|
|
220
|
+
>
|
|
221
|
+
<div className="h-[300px]">
|
|
222
|
+
{chartData.labels.length === 0 ? (
|
|
223
|
+
<div className="h-full flex items-center justify-center text-stats-muted text-sm">
|
|
224
|
+
No cost data available
|
|
225
|
+
</div>
|
|
226
|
+
) : byModel && lineData ? (
|
|
227
|
+
<Line data={lineData} options={lineOptions} />
|
|
228
|
+
) : barData ? (
|
|
229
|
+
<Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />
|
|
230
|
+
) : null}
|
|
231
|
+
</div>
|
|
232
|
+
</Panel>
|
|
233
|
+
);
|
|
234
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useMemo } from "react";
|
|
2
|
+
import { getRecentErrors } from "../api";
|
|
3
|
+
import { formatCost, formatInteger, formatRelativeTime } from "../data/formatters";
|
|
4
|
+
import { useResource } from "../data/useResource";
|
|
5
|
+
import type { MessageStats, TimeRange } from "../types";
|
|
6
|
+
import { AsyncBoundary, DataTable, Panel, StatusPill } from "../ui";
|
|
7
|
+
|
|
8
|
+
export interface ErrorsRouteProps {
|
|
9
|
+
active: boolean;
|
|
10
|
+
range: TimeRange;
|
|
11
|
+
refreshTrigger: number;
|
|
12
|
+
onRequestClick: (id: number) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function ErrorsRoute({ active, refreshTrigger, onRequestClick }: ErrorsRouteProps) {
|
|
16
|
+
const {
|
|
17
|
+
data: recentErrors,
|
|
18
|
+
error,
|
|
19
|
+
loading,
|
|
20
|
+
} = useResource(["recent-errors-dense", refreshTrigger], signal => getRecentErrors(50, signal), {
|
|
21
|
+
pollMs: 30000,
|
|
22
|
+
enabled: active,
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
const columns = useMemo(
|
|
26
|
+
() => [
|
|
27
|
+
{
|
|
28
|
+
key: "model",
|
|
29
|
+
header: "Model",
|
|
30
|
+
render: (item: MessageStats) => (
|
|
31
|
+
<div>
|
|
32
|
+
<div className="stats-font-medium stats-text-primary">{item.model}</div>
|
|
33
|
+
<div className="stats-text-xs stats-text-muted">{item.provider}</div>
|
|
34
|
+
</div>
|
|
35
|
+
),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
key: "timestamp",
|
|
39
|
+
header: "Time",
|
|
40
|
+
render: (item: MessageStats) => formatRelativeTime(item.timestamp),
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
key: "errorMessage",
|
|
44
|
+
header: "Error Message",
|
|
45
|
+
render: (item: MessageStats) => (
|
|
46
|
+
<div
|
|
47
|
+
className="stats-text-xs stats-text-danger stats-truncate stats-max-w-md stats-font-mono"
|
|
48
|
+
title={item.errorMessage || ""}
|
|
49
|
+
>
|
|
50
|
+
{item.errorMessage || "Unknown error"}
|
|
51
|
+
</div>
|
|
52
|
+
),
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
key: "tokens",
|
|
56
|
+
header: "Tokens",
|
|
57
|
+
numeric: true,
|
|
58
|
+
render: (item: MessageStats) => formatInteger(item.usage.totalTokens),
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
key: "cost",
|
|
62
|
+
header: "Cost",
|
|
63
|
+
numeric: true,
|
|
64
|
+
render: (item: MessageStats) => formatCost(item.usage.cost.total, 4),
|
|
65
|
+
},
|
|
66
|
+
],
|
|
67
|
+
[],
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
const renderMobileCard = (item: MessageStats, onClick?: () => void) => (
|
|
71
|
+
<div className="stats-mobile-card stats-border-danger" onClick={onClick}>
|
|
72
|
+
<div className="stats-mobile-card-header">
|
|
73
|
+
<div>
|
|
74
|
+
<div className="stats-font-semibold stats-text-primary">{item.model}</div>
|
|
75
|
+
<div className="stats-text-xs stats-text-muted">{item.provider}</div>
|
|
76
|
+
</div>
|
|
77
|
+
<StatusPill variant="danger">Failed</StatusPill>
|
|
78
|
+
</div>
|
|
79
|
+
<div className="stats-mobile-card-grid">
|
|
80
|
+
<div>
|
|
81
|
+
<div className="stats-mobile-card-label">Time</div>
|
|
82
|
+
<div className="stats-mobile-card-value">{formatRelativeTime(item.timestamp)}</div>
|
|
83
|
+
</div>
|
|
84
|
+
<div>
|
|
85
|
+
<div className="stats-mobile-card-label">Cost</div>
|
|
86
|
+
<div className="stats-mobile-card-value">{formatCost(item.usage.cost.total, 4)}</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div>
|
|
89
|
+
<div className="stats-mobile-card-label">Tokens</div>
|
|
90
|
+
<div className="stats-mobile-card-value">{formatInteger(item.usage.totalTokens)}</div>
|
|
91
|
+
</div>
|
|
92
|
+
</div>
|
|
93
|
+
{item.errorMessage && <div className="stats-mobile-card-error mt-2 stats-font-mono">{item.errorMessage}</div>}
|
|
94
|
+
</div>
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
return (
|
|
98
|
+
<div className="stats-route-container">
|
|
99
|
+
<Panel title="Recent Errors" subtitle="Up to 50 most recent failed requests in the stats database">
|
|
100
|
+
<AsyncBoundary
|
|
101
|
+
loading={loading}
|
|
102
|
+
error={error}
|
|
103
|
+
data={recentErrors}
|
|
104
|
+
emptyText="No recent failures in the local stats database"
|
|
105
|
+
>
|
|
106
|
+
<DataTable
|
|
107
|
+
columns={columns}
|
|
108
|
+
data={recentErrors || []}
|
|
109
|
+
keyExtractor={item => item.id || `${item.sessionFile}-${item.entryId}`}
|
|
110
|
+
onRowClick={item => item.id && onRequestClick(item.id)}
|
|
111
|
+
renderMobileCard={renderMobileCard}
|
|
112
|
+
emptyText="No recent failures in the local stats database"
|
|
113
|
+
/>
|
|
114
|
+
</AsyncBoundary>
|
|
115
|
+
</Panel>
|
|
116
|
+
</div>
|
|
117
|
+
);
|
|
118
|
+
}
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { useMemo, useState } from "react";
|
|
2
|
+
import { Line } from "react-chartjs-2";
|
|
3
|
+
import { getGainDashboardStats } from "../api";
|
|
4
|
+
import { buildSharedPlugins, buildSharedScales, CHART_THEMES, lineDatasetStyle } from "../components/chart-shared";
|
|
5
|
+
import { formatBytes, formatCompact, formatInteger, formatPercent } from "../data/formatters";
|
|
6
|
+
import { useResource } from "../data/useResource";
|
|
7
|
+
import type { GainDashboardStats, GainSourceTotals, GainTimeSeriesPoint, TimeRange } from "../types";
|
|
8
|
+
import { AsyncBoundary, Panel } from "../ui";
|
|
9
|
+
import { useSystemTheme } from "../useSystemTheme";
|
|
10
|
+
|
|
11
|
+
export interface GainRouteProps {
|
|
12
|
+
active: boolean;
|
|
13
|
+
range: TimeRange;
|
|
14
|
+
refreshTrigger: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function GainRoute({ active, range, refreshTrigger }: GainRouteProps) {
|
|
18
|
+
const [project, setProject] = useState<string | null>(null);
|
|
19
|
+
|
|
20
|
+
const {
|
|
21
|
+
data: stats,
|
|
22
|
+
error,
|
|
23
|
+
loading,
|
|
24
|
+
} = useResource(["gain", range, refreshTrigger, project], signal => getGainDashboardStats(range, project, signal), {
|
|
25
|
+
pollMs: 30_000,
|
|
26
|
+
enabled: active,
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="stats-route-container space-y-6">
|
|
31
|
+
<AsyncBoundary loading={loading} error={error} data={stats}>
|
|
32
|
+
{stats && (
|
|
33
|
+
<>
|
|
34
|
+
<GainProjectSelector projects={stats.projects} selected={project} onChange={setProject} />
|
|
35
|
+
<GainOverallPanel overall={stats.overall} />
|
|
36
|
+
<GainBySourcePanel bySource={stats.bySource} />
|
|
37
|
+
<GainTimeSeriesPanel timeSeries={stats.timeSeries} />
|
|
38
|
+
</>
|
|
39
|
+
)}
|
|
40
|
+
</AsyncBoundary>
|
|
41
|
+
</div>
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Project selector
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
function GainProjectSelector({
|
|
50
|
+
projects,
|
|
51
|
+
selected,
|
|
52
|
+
onChange,
|
|
53
|
+
}: {
|
|
54
|
+
projects: string[];
|
|
55
|
+
selected: string | null;
|
|
56
|
+
onChange: (p: string | null) => void;
|
|
57
|
+
}) {
|
|
58
|
+
if (projects.length === 0) return null;
|
|
59
|
+
return (
|
|
60
|
+
<div style={{ display: "flex", alignItems: "center", gap: "0.5rem" }}>
|
|
61
|
+
<span className="stats-text-secondary" style={{ fontSize: "0.875rem", whiteSpace: "nowrap" }}>
|
|
62
|
+
Project
|
|
63
|
+
</span>
|
|
64
|
+
<select
|
|
65
|
+
className="stats-select"
|
|
66
|
+
value={selected ?? ""}
|
|
67
|
+
onChange={e => onChange(e.target.value || null)}
|
|
68
|
+
style={{ maxWidth: "480px", flex: 1 }}
|
|
69
|
+
>
|
|
70
|
+
<option value="">All projects</option>
|
|
71
|
+
{projects.map(p => (
|
|
72
|
+
<option key={p} value={p}>
|
|
73
|
+
{p}
|
|
74
|
+
</option>
|
|
75
|
+
))}
|
|
76
|
+
</select>
|
|
77
|
+
</div>
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// ---------------------------------------------------------------------------
|
|
82
|
+
// Overall metrics panel
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
|
|
85
|
+
function GainOverallPanel({ overall }: { overall: GainSourceTotals }) {
|
|
86
|
+
return (
|
|
87
|
+
<Panel title="Overall Gain" subtitle="Aggregate snapcompact savings">
|
|
88
|
+
<div className="stats-metric-primary-grid">
|
|
89
|
+
<div className="stats-metric-card primary">
|
|
90
|
+
<div className="stats-metric-label">Saved Tokens</div>
|
|
91
|
+
<div className="stats-metric-value">{formatCompact(overall.savedTokens)}</div>
|
|
92
|
+
</div>
|
|
93
|
+
<div className="stats-metric-card primary">
|
|
94
|
+
<div className="stats-metric-label">Saved Bytes</div>
|
|
95
|
+
<div className="stats-metric-value">{formatBytes(overall.savedBytes)}</div>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="stats-metric-card primary">
|
|
98
|
+
<div className="stats-metric-label">Reduction</div>
|
|
99
|
+
<div className="stats-metric-value">
|
|
100
|
+
{overall.reductionPercent !== null ? formatPercent(overall.reductionPercent) : "—"}
|
|
101
|
+
</div>
|
|
102
|
+
</div>
|
|
103
|
+
<div className="stats-metric-card primary">
|
|
104
|
+
<div className="stats-metric-label">Total Hits</div>
|
|
105
|
+
<div className="stats-metric-value">{formatInteger(overall.hits)}</div>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
</Panel>
|
|
109
|
+
);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// ---------------------------------------------------------------------------
|
|
113
|
+
// By-source breakdown panel
|
|
114
|
+
// ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
function SourceCard({ title, totals }: { title: string; totals: GainSourceTotals }) {
|
|
117
|
+
return (
|
|
118
|
+
<div className="stats-metric-card secondary" style={{ flex: 1 }}>
|
|
119
|
+
<div className="stats-metric-label" style={{ fontWeight: 600, marginBottom: 8 }}>
|
|
120
|
+
{title}
|
|
121
|
+
</div>
|
|
122
|
+
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 8 }}>
|
|
123
|
+
<div>
|
|
124
|
+
<div className="stats-metric-label">Saved Tokens</div>
|
|
125
|
+
<div className="stats-metric-value" style={{ fontSize: "1rem" }}>
|
|
126
|
+
{formatCompact(totals.savedTokens)}
|
|
127
|
+
</div>
|
|
128
|
+
</div>
|
|
129
|
+
<div>
|
|
130
|
+
<div className="stats-metric-label">Saved Bytes</div>
|
|
131
|
+
<div className="stats-metric-value" style={{ fontSize: "1rem" }}>
|
|
132
|
+
{formatBytes(totals.savedBytes)}
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div>
|
|
136
|
+
<div className="stats-metric-label">Hits</div>
|
|
137
|
+
<div className="stats-metric-value" style={{ fontSize: "1rem" }}>
|
|
138
|
+
{formatInteger(totals.hits)}
|
|
139
|
+
</div>
|
|
140
|
+
</div>
|
|
141
|
+
<div>
|
|
142
|
+
<div className="stats-metric-label">Reduction</div>
|
|
143
|
+
<div className="stats-metric-value" style={{ fontSize: "1rem" }}>
|
|
144
|
+
{totals.reductionPercent !== null ? formatPercent(totals.reductionPercent) : "—"}
|
|
145
|
+
</div>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function GainBySourcePanel({ bySource }: { bySource: GainDashboardStats["bySource"] }) {
|
|
153
|
+
return (
|
|
154
|
+
<Panel title="By Source" subtitle="Savings breakdown per subsystem">
|
|
155
|
+
<div style={{ display: "flex", gap: 16, flexWrap: "wrap" }}>
|
|
156
|
+
<SourceCard title="Snapcompact" totals={bySource.snapcompact} />
|
|
157
|
+
</div>
|
|
158
|
+
</Panel>
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
// Time series chart (stacked area, daily)
|
|
164
|
+
// ---------------------------------------------------------------------------
|
|
165
|
+
|
|
166
|
+
const GAIN_COLORS = {
|
|
167
|
+
snapcompact: "rgb(34, 197, 94)",
|
|
168
|
+
} as const;
|
|
169
|
+
|
|
170
|
+
function GainTimeSeriesPanel({ timeSeries }: { timeSeries: GainTimeSeriesPoint[] }) {
|
|
171
|
+
const theme = useSystemTheme();
|
|
172
|
+
const chartTheme = CHART_THEMES[theme];
|
|
173
|
+
|
|
174
|
+
const { data, options } = useMemo(() => {
|
|
175
|
+
const labelFormatter = new Intl.DateTimeFormat(undefined, {
|
|
176
|
+
month: "short",
|
|
177
|
+
day: "numeric",
|
|
178
|
+
timeZone: "UTC",
|
|
179
|
+
});
|
|
180
|
+
const labels = timeSeries.map(p => labelFormatter.format(new Date(`${p.date}T00:00:00.000Z`)));
|
|
181
|
+
const chartData = {
|
|
182
|
+
labels,
|
|
183
|
+
datasets: [
|
|
184
|
+
{
|
|
185
|
+
label: "Snapcompact",
|
|
186
|
+
data: timeSeries.map(p => p.snapcompact),
|
|
187
|
+
...lineDatasetStyle(GAIN_COLORS.snapcompact),
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const { sharedScaleBase, yScale } = buildSharedScales({
|
|
193
|
+
chartTheme,
|
|
194
|
+
formatY: n => formatCompact(n),
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
const chartOptions = {
|
|
198
|
+
responsive: true,
|
|
199
|
+
maintainAspectRatio: false,
|
|
200
|
+
plugins: buildSharedPlugins({
|
|
201
|
+
chartTheme,
|
|
202
|
+
showLegend: true,
|
|
203
|
+
defaultLabel: "Tokens Saved",
|
|
204
|
+
formatValue: formatCompact,
|
|
205
|
+
}),
|
|
206
|
+
scales: {
|
|
207
|
+
x: { ...sharedScaleBase, stacked: true },
|
|
208
|
+
y: { ...yScale, stacked: true },
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
return { data: chartData, options: chartOptions };
|
|
213
|
+
}, [timeSeries, chartTheme]);
|
|
214
|
+
|
|
215
|
+
return (
|
|
216
|
+
<Panel title="Savings Over Time" subtitle="Daily token savings">
|
|
217
|
+
<div style={{ height: 240 }}>
|
|
218
|
+
{timeSeries.length === 0 ? (
|
|
219
|
+
<div className="stats-table-empty">No time series data yet</div>
|
|
220
|
+
) : (
|
|
221
|
+
<Line data={data} options={options as Parameters<typeof Line>[0]["options"]} />
|
|
222
|
+
)}
|
|
223
|
+
</div>
|
|
224
|
+
</Panel>
|
|
225
|
+
);
|
|
226
|
+
}
|