@gajae-code/stats 0.1.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/README.md +82 -0
- package/build.ts +84 -0
- package/dist/client/index.css +1 -0
- package/dist/client/index.html +13 -0
- package/dist/client/index.js +257 -0
- package/dist/client/styles.css +1159 -0
- package/dist/types/aggregator.d.ts +65 -0
- package/dist/types/client/App.d.ts +1 -0
- package/dist/types/client/api.d.ts +10 -0
- package/dist/types/client/components/BehaviorChart.d.ts +6 -0
- package/dist/types/client/components/BehaviorModelsTable.d.ts +7 -0
- package/dist/types/client/components/BehaviorSummary.d.ts +7 -0
- package/dist/types/client/components/ChartsContainer.d.ts +7 -0
- package/dist/types/client/components/CostChart.d.ts +6 -0
- package/dist/types/client/components/CostSummary.d.ts +6 -0
- package/dist/types/client/components/Header.d.ts +12 -0
- package/dist/types/client/components/ModelsTable.d.ts +8 -0
- package/dist/types/client/components/RequestDetail.d.ts +6 -0
- package/dist/types/client/components/RequestList.d.ts +8 -0
- package/dist/types/client/components/StatsGrid.d.ts +6 -0
- package/dist/types/client/components/chart-shared.d.ts +187 -0
- package/dist/types/client/components/models-table-shared.d.ts +195 -0
- package/dist/types/client/components/range-meta.d.ts +21 -0
- package/dist/types/client/index.d.ts +1 -0
- package/dist/types/client/types.d.ts +62 -0
- package/dist/types/client/useSystemTheme.d.ts +2 -0
- package/dist/types/db.d.ts +93 -0
- package/dist/types/index.d.ts +5 -0
- package/dist/types/parser.d.ts +40 -0
- package/dist/types/server.d.ts +7 -0
- package/dist/types/shared-types.d.ts +192 -0
- package/dist/types/sync-worker.d.ts +31 -0
- package/dist/types/types.d.ts +120 -0
- package/dist/types/user-metrics.d.ts +72 -0
- package/package.json +91 -0
- package/src/aggregator.ts +454 -0
- package/src/client/App.tsx +221 -0
- package/src/client/api.ts +65 -0
- package/src/client/components/BehaviorChart.tsx +189 -0
- package/src/client/components/BehaviorModelsTable.tsx +342 -0
- package/src/client/components/BehaviorSummary.tsx +95 -0
- package/src/client/components/ChartsContainer.tsx +221 -0
- package/src/client/components/CostChart.tsx +171 -0
- package/src/client/components/CostSummary.tsx +53 -0
- package/src/client/components/Header.tsx +72 -0
- package/src/client/components/ModelsTable.tsx +265 -0
- package/src/client/components/RequestDetail.tsx +172 -0
- package/src/client/components/RequestList.tsx +73 -0
- package/src/client/components/StatsGrid.tsx +135 -0
- package/src/client/components/chart-shared.tsx +320 -0
- package/src/client/components/models-table-shared.tsx +275 -0
- package/src/client/components/range-meta.ts +72 -0
- package/src/client/css.d.ts +1 -0
- package/src/client/index.tsx +6 -0
- package/src/client/styles.css +306 -0
- package/src/client/types.ts +78 -0
- package/src/client/useSystemTheme.ts +31 -0
- package/src/db.ts +1100 -0
- package/src/embedded-client.generated.txt +7 -0
- package/src/index.ts +182 -0
- package/src/parser.ts +334 -0
- package/src/server.ts +325 -0
- package/src/shared-types.ts +204 -0
- package/src/sync-worker.ts +40 -0
- package/src/types.ts +125 -0
- package/src/user-metrics.ts +686 -0
- package/tailwind.config.js +40 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CategoryScale,
|
|
3
|
+
Chart as ChartJS,
|
|
4
|
+
Filler,
|
|
5
|
+
Legend,
|
|
6
|
+
LinearScale,
|
|
7
|
+
LineElement,
|
|
8
|
+
PointElement,
|
|
9
|
+
Title,
|
|
10
|
+
Tooltip,
|
|
11
|
+
} from "chart.js";
|
|
12
|
+
import { useMemo } from "react";
|
|
13
|
+
import { Line } from "react-chartjs-2";
|
|
14
|
+
import type { ModelTimeSeriesPoint, TimeRange } from "../types";
|
|
15
|
+
import { useSystemTheme } from "../useSystemTheme";
|
|
16
|
+
import { formatRangeTick, rangeMeta } from "./range-meta";
|
|
17
|
+
|
|
18
|
+
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
|
|
19
|
+
|
|
20
|
+
const MODEL_COLORS = [
|
|
21
|
+
"#a78bfa", // violet
|
|
22
|
+
"#22d3ee", // cyan
|
|
23
|
+
"#ec4899", // pink
|
|
24
|
+
"#4ade80", // green
|
|
25
|
+
"#fbbf24", // amber
|
|
26
|
+
"#f87171", // red
|
|
27
|
+
"#60a5fa", // blue
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const CHART_THEMES = {
|
|
31
|
+
dark: {
|
|
32
|
+
legendLabel: "#94a3b8",
|
|
33
|
+
tooltipBackground: "#16161e",
|
|
34
|
+
tooltipTitle: "#f8fafc",
|
|
35
|
+
tooltipBody: "#94a3b8",
|
|
36
|
+
tooltipBorder: "rgba(255, 255, 255, 0.1)",
|
|
37
|
+
grid: "rgba(255, 255, 255, 0.06)",
|
|
38
|
+
tick: "#64748b",
|
|
39
|
+
},
|
|
40
|
+
light: {
|
|
41
|
+
legendLabel: "#475569",
|
|
42
|
+
tooltipBackground: "#ffffff",
|
|
43
|
+
tooltipTitle: "#0f172a",
|
|
44
|
+
tooltipBody: "#334155",
|
|
45
|
+
tooltipBorder: "rgba(15, 23, 42, 0.18)",
|
|
46
|
+
grid: "rgba(15, 23, 42, 0.08)",
|
|
47
|
+
tick: "#64748b",
|
|
48
|
+
},
|
|
49
|
+
} as const;
|
|
50
|
+
interface ChartsContainerProps {
|
|
51
|
+
modelSeries: ModelTimeSeriesPoint[];
|
|
52
|
+
timeRange: TimeRange;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function ChartsContainer({ modelSeries, timeRange }: ChartsContainerProps) {
|
|
56
|
+
const chartData = useMemo(() => buildModelPreferenceSeries(modelSeries), [modelSeries]);
|
|
57
|
+
const theme = useSystemTheme();
|
|
58
|
+
const chartTheme = CHART_THEMES[theme];
|
|
59
|
+
const meta = rangeMeta(timeRange);
|
|
60
|
+
const data = {
|
|
61
|
+
labels: chartData.data.map(d => formatRangeTick(d.timestamp, timeRange)),
|
|
62
|
+
datasets: chartData.series.map((seriesName, index) => ({
|
|
63
|
+
label: seriesName,
|
|
64
|
+
data: chartData.data.map(d => d[seriesName] ?? 0),
|
|
65
|
+
borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
66
|
+
backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`,
|
|
67
|
+
fill: true,
|
|
68
|
+
tension: 0.4,
|
|
69
|
+
pointRadius: 0,
|
|
70
|
+
pointHoverRadius: 4,
|
|
71
|
+
borderWidth: 2,
|
|
72
|
+
})),
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const options = {
|
|
76
|
+
responsive: true,
|
|
77
|
+
maintainAspectRatio: false,
|
|
78
|
+
interaction: {
|
|
79
|
+
mode: "index" as const,
|
|
80
|
+
intersect: false,
|
|
81
|
+
},
|
|
82
|
+
plugins: {
|
|
83
|
+
legend: {
|
|
84
|
+
position: "top" as const,
|
|
85
|
+
align: "start" as const,
|
|
86
|
+
labels: {
|
|
87
|
+
color: chartTheme.legendLabel,
|
|
88
|
+
usePointStyle: true,
|
|
89
|
+
padding: 16,
|
|
90
|
+
font: { size: 12 },
|
|
91
|
+
boxWidth: 8,
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
tooltip: {
|
|
95
|
+
backgroundColor: chartTheme.tooltipBackground,
|
|
96
|
+
titleColor: chartTheme.tooltipTitle,
|
|
97
|
+
bodyColor: chartTheme.tooltipBody,
|
|
98
|
+
borderColor: chartTheme.tooltipBorder,
|
|
99
|
+
borderWidth: 1,
|
|
100
|
+
padding: 12,
|
|
101
|
+
cornerRadius: 8,
|
|
102
|
+
callbacks: {
|
|
103
|
+
label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
|
104
|
+
const label = context.dataset.label ?? "";
|
|
105
|
+
const value = context.parsed.y;
|
|
106
|
+
return `${label}: ${(value ?? 0).toFixed(1)}%`;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
scales: {
|
|
112
|
+
x: {
|
|
113
|
+
grid: {
|
|
114
|
+
color: chartTheme.grid,
|
|
115
|
+
drawBorder: false,
|
|
116
|
+
},
|
|
117
|
+
ticks: {
|
|
118
|
+
color: chartTheme.tick,
|
|
119
|
+
font: { size: 11 },
|
|
120
|
+
},
|
|
121
|
+
},
|
|
122
|
+
y: {
|
|
123
|
+
grid: {
|
|
124
|
+
color: chartTheme.grid,
|
|
125
|
+
drawBorder: false,
|
|
126
|
+
},
|
|
127
|
+
ticks: {
|
|
128
|
+
color: chartTheme.tick,
|
|
129
|
+
font: { size: 11 },
|
|
130
|
+
callback: (value: number | string) => `${value}%`,
|
|
131
|
+
},
|
|
132
|
+
min: 0,
|
|
133
|
+
max: 100,
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return (
|
|
139
|
+
<div className="surface overflow-hidden">
|
|
140
|
+
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
|
|
141
|
+
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Model Preference</h3>
|
|
142
|
+
<p className="text-xs text-[var(--text-muted)] mt-1">Share of requests over {meta.windowLabel}</p>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="p-5 min-h-[320px]">
|
|
145
|
+
{chartData.data.length === 0 ? (
|
|
146
|
+
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
|
|
147
|
+
No data available
|
|
148
|
+
</div>
|
|
149
|
+
) : (
|
|
150
|
+
<div className="h-[280px]">
|
|
151
|
+
<Line data={data} options={options} />
|
|
152
|
+
</div>
|
|
153
|
+
)}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function buildModelPreferenceSeries(
|
|
160
|
+
points: ModelTimeSeriesPoint[],
|
|
161
|
+
topN = 5,
|
|
162
|
+
): {
|
|
163
|
+
data: Array<Record<string, number>>;
|
|
164
|
+
series: string[];
|
|
165
|
+
} {
|
|
166
|
+
if (points.length === 0) return { data: [], series: [] };
|
|
167
|
+
|
|
168
|
+
const totals = new Map<string, { model: string; provider: string; total: number }>();
|
|
169
|
+
for (const point of points) {
|
|
170
|
+
const key = `${point.model}::${point.provider}`;
|
|
171
|
+
const existing = totals.get(key);
|
|
172
|
+
if (existing) {
|
|
173
|
+
existing.total += point.requests;
|
|
174
|
+
} else {
|
|
175
|
+
totals.set(key, { model: point.model, provider: point.provider, total: point.requests });
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const sorted = [...totals.entries()].map(([key, value]) => ({ key, ...value })).sort((a, b) => b.total - a.total);
|
|
180
|
+
const topEntries = sorted.slice(0, topN);
|
|
181
|
+
const topKeys = new Set(topEntries.map(entry => entry.key));
|
|
182
|
+
|
|
183
|
+
const topModelCounts = new Map<string, number>();
|
|
184
|
+
for (const entry of topEntries) {
|
|
185
|
+
topModelCounts.set(entry.model, (topModelCounts.get(entry.model) ?? 0) + 1);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const labelByKey = new Map<string, string>();
|
|
189
|
+
for (const entry of topEntries) {
|
|
190
|
+
const showProvider = (topModelCounts.get(entry.model) ?? 0) > 1;
|
|
191
|
+
labelByKey.set(entry.key, showProvider ? `${entry.model} (${entry.provider})` : entry.model);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const dataMap = new Map<number, Record<string, number>>();
|
|
195
|
+
|
|
196
|
+
for (const point of points) {
|
|
197
|
+
const key = `${point.model}::${point.provider}`;
|
|
198
|
+
const bucket = dataMap.get(point.timestamp) ?? { timestamp: point.timestamp, total: 0 };
|
|
199
|
+
bucket.total += point.requests;
|
|
200
|
+
const seriesLabel = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other";
|
|
201
|
+
bucket[seriesLabel] = (bucket[seriesLabel] ?? 0) + point.requests;
|
|
202
|
+
dataMap.set(point.timestamp, bucket);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const series = topEntries.map(entry => labelByKey.get(entry.key) ?? entry.model);
|
|
206
|
+
if ([...dataMap.values()].some(row => (row.Other ?? 0) > 0)) {
|
|
207
|
+
series.push("Other");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const data = [...dataMap.values()]
|
|
211
|
+
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
|
|
212
|
+
.map(row => {
|
|
213
|
+
const total = row.total ?? 0;
|
|
214
|
+
for (const key of series) {
|
|
215
|
+
row[key] = total > 0 ? ((row[key] ?? 0) / total) * 100 : 0;
|
|
216
|
+
}
|
|
217
|
+
return row;
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
return { data, series };
|
|
221
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
BarElement,
|
|
3
|
+
CategoryScale,
|
|
4
|
+
Chart as ChartJS,
|
|
5
|
+
type ChartOptions,
|
|
6
|
+
Filler,
|
|
7
|
+
Legend,
|
|
8
|
+
LinearScale,
|
|
9
|
+
LineElement,
|
|
10
|
+
type Plugin,
|
|
11
|
+
PointElement,
|
|
12
|
+
Title,
|
|
13
|
+
Tooltip,
|
|
14
|
+
} from "chart.js";
|
|
15
|
+
import { useMemo, useState } from "react";
|
|
16
|
+
import { Bar, Line } from "react-chartjs-2";
|
|
17
|
+
import type { CostTimeSeriesPoint } from "../types";
|
|
18
|
+
import { useSystemTheme } from "../useSystemTheme";
|
|
19
|
+
import {
|
|
20
|
+
barDatasetStyle,
|
|
21
|
+
buildAggregateTimeSeries,
|
|
22
|
+
buildSharedPlugins,
|
|
23
|
+
buildSharedScales,
|
|
24
|
+
buildTopNByModelSeries,
|
|
25
|
+
CHART_THEMES,
|
|
26
|
+
ChartFrame,
|
|
27
|
+
type ChartSeries,
|
|
28
|
+
lineDatasetStyle,
|
|
29
|
+
MODEL_COLORS,
|
|
30
|
+
styleDatasets,
|
|
31
|
+
} from "./chart-shared";
|
|
32
|
+
|
|
33
|
+
ChartJS.register(CategoryScale, LinearScale, BarElement, LineElement, PointElement, Title, Tooltip, Legend, Filler);
|
|
34
|
+
|
|
35
|
+
/** Cost bar labels need a per-theme color that the generic chart theme doesn't carry. */
|
|
36
|
+
const BAR_LABEL_COLORS = {
|
|
37
|
+
dark: "rgba(248, 250, 252, 0.7)",
|
|
38
|
+
light: "rgba(15, 23, 42, 0.6)",
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
interface CostChartProps {
|
|
42
|
+
costSeries: CostTimeSeriesPoint[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Inline Chart.js plugin — draws cost value centered above each bar. */
|
|
46
|
+
function makeBarLabelPlugin(color: string): Plugin<"bar"> {
|
|
47
|
+
return {
|
|
48
|
+
id: "costBarLabels",
|
|
49
|
+
afterDatasetsDraw(chart) {
|
|
50
|
+
const { ctx } = chart;
|
|
51
|
+
const dataset = chart.data.datasets[0];
|
|
52
|
+
if (!dataset) return;
|
|
53
|
+
const meta = chart.getDatasetMeta(0);
|
|
54
|
+
ctx.save();
|
|
55
|
+
ctx.font = "11px system-ui, sans-serif";
|
|
56
|
+
ctx.fillStyle = color;
|
|
57
|
+
ctx.textAlign = "center";
|
|
58
|
+
ctx.textBaseline = "bottom";
|
|
59
|
+
for (const bar of meta.data) {
|
|
60
|
+
const value = (bar as unknown as { $context: { parsed: { y: number } } }).$context.parsed.y;
|
|
61
|
+
if (!value) continue;
|
|
62
|
+
const label = `$${Math.round(value)}`;
|
|
63
|
+
const { x, y } = bar.getProps(["x", "y"], true) as { x: number; y: number };
|
|
64
|
+
ctx.fillText(label, x, y - 3);
|
|
65
|
+
}
|
|
66
|
+
ctx.restore();
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function buildAggregateSeries(points: CostTimeSeriesPoint[]): ChartSeries {
|
|
72
|
+
return buildAggregateTimeSeries<CostTimeSeriesPoint, { total: number }>(points, "Cost", {
|
|
73
|
+
initBucket: () => ({ total: 0 }),
|
|
74
|
+
accumulate: (bucket, point) => {
|
|
75
|
+
bucket.total += point.cost;
|
|
76
|
+
},
|
|
77
|
+
bucketToValue: bucket => bucket.total,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function buildByModelSeries(points: CostTimeSeriesPoint[]): ChartSeries {
|
|
82
|
+
// Rank models by total cost; per-day buckets are simple cost sums.
|
|
83
|
+
return buildTopNByModelSeries<CostTimeSeriesPoint, { total: number }>(points, {
|
|
84
|
+
rankWeight: point => point.cost,
|
|
85
|
+
initBucket: () => ({ total: 0 }),
|
|
86
|
+
accumulate: (bucket, point) => {
|
|
87
|
+
bucket.total += point.cost;
|
|
88
|
+
},
|
|
89
|
+
bucketToValue: bucket => bucket.total,
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function CostChart({ costSeries }: CostChartProps) {
|
|
94
|
+
const [byModel, setByModel] = useState(false);
|
|
95
|
+
const theme = useSystemTheme();
|
|
96
|
+
const chartTheme = CHART_THEMES[theme];
|
|
97
|
+
|
|
98
|
+
const chartData = useMemo(
|
|
99
|
+
() => (byModel ? buildByModelSeries(costSeries) : buildAggregateSeries(costSeries)),
|
|
100
|
+
[costSeries, byModel],
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
const sharedPlugins = buildSharedPlugins({
|
|
104
|
+
chartTheme,
|
|
105
|
+
showLegend: byModel,
|
|
106
|
+
defaultLabel: "Cost",
|
|
107
|
+
formatValue: v => `$${Math.round(v)}`,
|
|
108
|
+
footer: items => {
|
|
109
|
+
if (!byModel || items.length < 2) return undefined;
|
|
110
|
+
const total = items.reduce((sum, item) => sum + (item.parsed.y ?? 0), 0);
|
|
111
|
+
return `Total: $${Math.round(total)}`;
|
|
112
|
+
},
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
const { sharedScaleBase, yScale } = buildSharedScales({
|
|
116
|
+
chartTheme,
|
|
117
|
+
formatY: v => `$${Math.round(v)}`,
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
let chartNode: React.ReactNode;
|
|
121
|
+
if (byModel) {
|
|
122
|
+
const lineData = {
|
|
123
|
+
labels: chartData.labels,
|
|
124
|
+
datasets: styleDatasets(chartData, i => lineDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const lineOptions: ChartOptions<"line"> = {
|
|
128
|
+
responsive: true,
|
|
129
|
+
maintainAspectRatio: false,
|
|
130
|
+
interaction: { mode: "index", intersect: false },
|
|
131
|
+
plugins: sharedPlugins,
|
|
132
|
+
scales: { x: sharedScaleBase, y: yScale },
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
chartNode = <Line data={lineData} options={lineOptions} />;
|
|
136
|
+
} else {
|
|
137
|
+
const barData = {
|
|
138
|
+
labels: chartData.labels,
|
|
139
|
+
datasets: styleDatasets(chartData, i => barDatasetStyle(MODEL_COLORS[i % MODEL_COLORS.length])),
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
const barLabelPlugin = makeBarLabelPlugin(BAR_LABEL_COLORS[theme]);
|
|
143
|
+
|
|
144
|
+
const barOptions: ChartOptions<"bar"> = {
|
|
145
|
+
responsive: true,
|
|
146
|
+
maintainAspectRatio: false,
|
|
147
|
+
interaction: { mode: "index", intersect: false },
|
|
148
|
+
plugins: { ...sharedPlugins, costBarLabels: {} } as ChartOptions<"bar">["plugins"],
|
|
149
|
+
scales: {
|
|
150
|
+
x: { ...sharedScaleBase, stacked: true },
|
|
151
|
+
y: { ...yScale, stacked: true },
|
|
152
|
+
},
|
|
153
|
+
layout: { padding: { top: 24 } },
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
chartNode = <Bar data={barData} options={barOptions} plugins={[barLabelPlugin]} />;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return (
|
|
160
|
+
<ChartFrame
|
|
161
|
+
title="Daily Cost"
|
|
162
|
+
subtitle="API spending over time"
|
|
163
|
+
empty={chartData.labels.length === 0}
|
|
164
|
+
emptyMessage="No cost data available"
|
|
165
|
+
byModel={byModel}
|
|
166
|
+
onByModelChange={setByModel}
|
|
167
|
+
>
|
|
168
|
+
{chartNode}
|
|
169
|
+
</ChartFrame>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { CostTimeSeriesPoint } from "../types";
|
|
2
|
+
|
|
3
|
+
interface CostSummaryProps {
|
|
4
|
+
costSeries: CostTimeSeriesPoint[];
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function formatCost(value: number): string {
|
|
8
|
+
return `$${Math.round(value)}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function CostSummary({ costSeries }: CostSummaryProps) {
|
|
12
|
+
const totalCost = costSeries.reduce((sum, p) => sum + p.cost, 0);
|
|
13
|
+
const dayBuckets = new Set(costSeries.map(p => p.timestamp)).size;
|
|
14
|
+
const avgDaily = dayBuckets > 0 ? totalCost / dayBuckets : 0;
|
|
15
|
+
|
|
16
|
+
// Most expensive model over the visible window
|
|
17
|
+
const modelTotals = new Map<string, number>();
|
|
18
|
+
for (const point of costSeries) {
|
|
19
|
+
modelTotals.set(point.model, (modelTotals.get(point.model) ?? 0) + point.cost);
|
|
20
|
+
}
|
|
21
|
+
let topModel = "";
|
|
22
|
+
let topModelCost = 0;
|
|
23
|
+
for (const [model, cost] of modelTotals) {
|
|
24
|
+
if (cost > topModelCost) {
|
|
25
|
+
topModel = model;
|
|
26
|
+
topModelCost = cost;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const cards = [
|
|
31
|
+
{ label: "Total", value: formatCost(totalCost) },
|
|
32
|
+
{ label: "Avg / day", value: formatCost(avgDaily) },
|
|
33
|
+
{
|
|
34
|
+
label: "Top model",
|
|
35
|
+
value: topModel || "—",
|
|
36
|
+
sub: topModel ? formatCost(topModelCost) : undefined,
|
|
37
|
+
},
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
42
|
+
{cards.map(card => (
|
|
43
|
+
<div key={card.label} className="surface px-4 py-3">
|
|
44
|
+
<p className="text-xs text-[var(--text-muted)] mb-1">{card.label}</p>
|
|
45
|
+
<p className="text-lg font-semibold text-[var(--text-primary)] truncate" title={card.value}>
|
|
46
|
+
{card.value}
|
|
47
|
+
</p>
|
|
48
|
+
{card.sub && <p className="text-xs text-[var(--text-muted)] mt-0.5">{card.sub}</p>}
|
|
49
|
+
</div>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
);
|
|
53
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { Activity, RefreshCw } from "lucide-react";
|
|
2
|
+
import type { TimeRange } from "../types";
|
|
3
|
+
|
|
4
|
+
type Tab = "overview" | "requests" | "errors" | "models" | "costs" | "behavior";
|
|
5
|
+
|
|
6
|
+
const tabs: Tab[] = ["overview", "requests", "errors", "models", "costs", "behavior"];
|
|
7
|
+
const timeRanges: { label: string; value: TimeRange }[] = [
|
|
8
|
+
{ label: "1h", value: "1h" },
|
|
9
|
+
{ label: "24h", value: "24h" },
|
|
10
|
+
{ label: "7d", value: "7d" },
|
|
11
|
+
{ label: "30d", value: "30d" },
|
|
12
|
+
{ label: "90d", value: "90d" },
|
|
13
|
+
{ label: "All", value: "all" },
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
interface HeaderProps {
|
|
17
|
+
activeTab: Tab;
|
|
18
|
+
onTabChange: (tab: Tab) => void;
|
|
19
|
+
onSync: () => void;
|
|
20
|
+
syncing: boolean;
|
|
21
|
+
timeRange: TimeRange;
|
|
22
|
+
onTimeRangeChange: (timeRange: TimeRange) => void;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function Header({ activeTab, onTabChange, onSync, syncing, timeRange, onTimeRangeChange }: HeaderProps) {
|
|
26
|
+
return (
|
|
27
|
+
<header className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 pb-6 mb-8 border-b border-[var(--border-subtle)]">
|
|
28
|
+
<div className="flex items-center gap-3">
|
|
29
|
+
<div className="w-10 h-10 rounded-[var(--radius-md)] bg-gradient-to-br from-[var(--accent-pink)] to-[var(--accent-cyan)] flex items-center justify-center shadow-lg">
|
|
30
|
+
<Activity className="w-5 h-5 text-white" />
|
|
31
|
+
</div>
|
|
32
|
+
<div>
|
|
33
|
+
<h1 className="text-xl font-semibold text-[var(--text-primary)]">AI Usage</h1>
|
|
34
|
+
<p className="text-sm text-[var(--text-muted)]">Statistics & Analytics</p>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
|
|
38
|
+
<div className="flex items-center gap-3">
|
|
39
|
+
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-md)] p-1 border border-[var(--border-subtle)]">
|
|
40
|
+
{tabs.map(tab => (
|
|
41
|
+
<button
|
|
42
|
+
key={tab}
|
|
43
|
+
type="button"
|
|
44
|
+
onClick={() => onTabChange(tab)}
|
|
45
|
+
className={`tab-btn capitalize ${activeTab === tab ? "active" : ""}`}
|
|
46
|
+
>
|
|
47
|
+
{tab}
|
|
48
|
+
</button>
|
|
49
|
+
))}
|
|
50
|
+
</div>
|
|
51
|
+
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-md)] p-1 border border-[var(--border-subtle)]">
|
|
52
|
+
{timeRanges.map(range => (
|
|
53
|
+
<button
|
|
54
|
+
key={range.value}
|
|
55
|
+
type="button"
|
|
56
|
+
onClick={() => onTimeRangeChange(range.value)}
|
|
57
|
+
className={`tab-btn ${timeRange === range.value ? "active" : ""}`}
|
|
58
|
+
title={range.value === "all" ? "All time" : `Last ${range.label}`}
|
|
59
|
+
>
|
|
60
|
+
{range.label}
|
|
61
|
+
</button>
|
|
62
|
+
))}
|
|
63
|
+
</div>
|
|
64
|
+
|
|
65
|
+
<button type="button" onClick={onSync} disabled={syncing} className="btn btn-primary">
|
|
66
|
+
<RefreshCw size={16} className={syncing ? "spin" : ""} />
|
|
67
|
+
{syncing ? "Syncing..." : "Sync"}
|
|
68
|
+
</button>
|
|
69
|
+
</div>
|
|
70
|
+
</header>
|
|
71
|
+
);
|
|
72
|
+
}
|