@oh-my-pi/omp-stats 15.0.0 → 15.0.2
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/package.json +6 -6
- package/src/client/components/BehaviorChart.tsx +85 -270
- package/src/client/components/BehaviorModelsTable.tsx +151 -274
- package/src/client/components/CostChart.tsx +85 -246
- package/src/client/components/ModelsTable.tsx +130 -246
- package/src/client/components/RequestDetail.tsx +0 -2
- package/src/client/components/chart-shared.tsx +320 -0
- package/src/client/components/models-table-shared.tsx +275 -0
- package/src/client/types.ts +21 -121
- package/src/db.ts +41 -1
- package/src/parser.ts +44 -5
- package/src/shared-types.ts +204 -0
- package/src/types.ts +16 -201
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared chart primitives for the dashboard timeline charts (BehaviorChart,
|
|
3
|
+
* CostChart). Each chart owns its data shape and metric labels — this module
|
|
4
|
+
* owns the layout, theme, legend/tooltip plumbing, and the top-N-by-model
|
|
5
|
+
* bucketing scaffold that's identical between cost and behavior series.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { format } from "date-fns";
|
|
9
|
+
|
|
10
|
+
export const MODEL_COLORS = [
|
|
11
|
+
"#a78bfa", // violet
|
|
12
|
+
"#22d3ee", // cyan
|
|
13
|
+
"#ec4899", // pink
|
|
14
|
+
"#4ade80", // green
|
|
15
|
+
"#fbbf24", // amber
|
|
16
|
+
"#f87171", // red
|
|
17
|
+
"#60a5fa", // blue
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
export const CHART_THEMES = {
|
|
21
|
+
dark: {
|
|
22
|
+
legendLabel: "#94a3b8",
|
|
23
|
+
tooltipBackground: "#16161e",
|
|
24
|
+
tooltipTitle: "#f8fafc",
|
|
25
|
+
tooltipBody: "#94a3b8",
|
|
26
|
+
tooltipBorder: "rgba(255, 255, 255, 0.1)",
|
|
27
|
+
grid: "rgba(255, 255, 255, 0.06)",
|
|
28
|
+
tick: "#64748b",
|
|
29
|
+
},
|
|
30
|
+
light: {
|
|
31
|
+
legendLabel: "#475569",
|
|
32
|
+
tooltipBackground: "#ffffff",
|
|
33
|
+
tooltipTitle: "#0f172a",
|
|
34
|
+
tooltipBody: "#334155",
|
|
35
|
+
tooltipBorder: "rgba(15, 23, 42, 0.18)",
|
|
36
|
+
grid: "rgba(15, 23, 42, 0.08)",
|
|
37
|
+
tick: "#64748b",
|
|
38
|
+
},
|
|
39
|
+
} as const;
|
|
40
|
+
|
|
41
|
+
export type ChartTheme = (typeof CHART_THEMES)[keyof typeof CHART_THEMES];
|
|
42
|
+
|
|
43
|
+
export interface ChartSeries {
|
|
44
|
+
labels: string[];
|
|
45
|
+
datasets: Array<{ label: string; data: number[] }>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TooltipItem {
|
|
49
|
+
parsed: { y: number | null };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Tooltip + legend config common to bar and line variants of the time charts. */
|
|
53
|
+
export function buildSharedPlugins(opts: {
|
|
54
|
+
chartTheme: ChartTheme;
|
|
55
|
+
showLegend: boolean;
|
|
56
|
+
defaultLabel: string;
|
|
57
|
+
formatValue: (n: number) => string;
|
|
58
|
+
footer?: (items: TooltipItem[]) => string | undefined;
|
|
59
|
+
}) {
|
|
60
|
+
const { chartTheme, showLegend, defaultLabel, formatValue, footer } = opts;
|
|
61
|
+
return {
|
|
62
|
+
legend: {
|
|
63
|
+
display: showLegend,
|
|
64
|
+
position: "top" as const,
|
|
65
|
+
align: "start" as const,
|
|
66
|
+
labels: {
|
|
67
|
+
color: chartTheme.legendLabel,
|
|
68
|
+
usePointStyle: true,
|
|
69
|
+
padding: 16,
|
|
70
|
+
font: { size: 12 },
|
|
71
|
+
boxWidth: 8,
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
tooltip: {
|
|
75
|
+
backgroundColor: chartTheme.tooltipBackground,
|
|
76
|
+
titleColor: chartTheme.tooltipTitle,
|
|
77
|
+
bodyColor: chartTheme.tooltipBody,
|
|
78
|
+
borderColor: chartTheme.tooltipBorder,
|
|
79
|
+
borderWidth: 1,
|
|
80
|
+
padding: 12,
|
|
81
|
+
cornerRadius: 8,
|
|
82
|
+
callbacks: {
|
|
83
|
+
label: (ctx: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
|
84
|
+
const label = ctx.dataset.label ?? defaultLabel;
|
|
85
|
+
const value = ctx.parsed.y ?? 0;
|
|
86
|
+
return `${label}: ${formatValue(value)}`;
|
|
87
|
+
},
|
|
88
|
+
...(footer ? { footer } : {}),
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Y-axis tick formatter + grid/tick styling shared by both charts. */
|
|
95
|
+
export function buildSharedScales(opts: { chartTheme: ChartTheme; formatY: (n: number) => string }) {
|
|
96
|
+
const { chartTheme, formatY } = opts;
|
|
97
|
+
const sharedScaleBase = {
|
|
98
|
+
grid: { color: chartTheme.grid, drawBorder: false },
|
|
99
|
+
ticks: { color: chartTheme.tick, font: { size: 11 } },
|
|
100
|
+
};
|
|
101
|
+
const yScale = {
|
|
102
|
+
...sharedScaleBase,
|
|
103
|
+
ticks: {
|
|
104
|
+
...sharedScaleBase.ticks,
|
|
105
|
+
callback: (value: number | string) => formatY(Number(value)),
|
|
106
|
+
},
|
|
107
|
+
min: 0,
|
|
108
|
+
};
|
|
109
|
+
return { sharedScaleBase, yScale };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Stylistic defaults for a single line dataset in a stacked/by-model chart. */
|
|
113
|
+
export function lineDatasetStyle(color: string) {
|
|
114
|
+
return {
|
|
115
|
+
borderColor: color,
|
|
116
|
+
backgroundColor: `${color}20`,
|
|
117
|
+
fill: true,
|
|
118
|
+
tension: 0,
|
|
119
|
+
pointRadius: 3,
|
|
120
|
+
pointHoverRadius: 4,
|
|
121
|
+
borderWidth: 2,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Stylistic defaults for a single bar dataset in a stacked chart. */
|
|
126
|
+
export function barDatasetStyle(color: string) {
|
|
127
|
+
return {
|
|
128
|
+
backgroundColor: color,
|
|
129
|
+
borderColor: color,
|
|
130
|
+
borderWidth: 0,
|
|
131
|
+
borderRadius: 3,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Map a generic ChartSeries' datasets through a per-index style function so
|
|
137
|
+
* callers can supply line or bar styling without repeating the label/data
|
|
138
|
+
* spread at every chart site.
|
|
139
|
+
*/
|
|
140
|
+
export function styleDatasets(series: ChartSeries, styleFor: (index: number) => Record<string, unknown>) {
|
|
141
|
+
return series.datasets.map((ds, index) => ({
|
|
142
|
+
label: ds.label,
|
|
143
|
+
data: ds.data,
|
|
144
|
+
...styleFor(index),
|
|
145
|
+
}));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Bucket points by day into a single aggregate series. Caller supplies the
|
|
150
|
+
* per-bucket accumulator + final value extractor; mirrors the shape of
|
|
151
|
+
* `buildTopNByModelSeries` for the non-by-model variant of each time chart.
|
|
152
|
+
*/
|
|
153
|
+
export function buildAggregateTimeSeries<T extends { timestamp: number }, B>(
|
|
154
|
+
points: T[],
|
|
155
|
+
label: string,
|
|
156
|
+
opts: {
|
|
157
|
+
initBucket: () => B;
|
|
158
|
+
accumulate: (bucket: B, point: T) => void;
|
|
159
|
+
bucketToValue: (bucket: B) => number;
|
|
160
|
+
},
|
|
161
|
+
): ChartSeries {
|
|
162
|
+
if (points.length === 0) return { labels: [], datasets: [] };
|
|
163
|
+
const { initBucket, accumulate, bucketToValue } = opts;
|
|
164
|
+
const byDay = new Map<number, B>();
|
|
165
|
+
for (const point of points) {
|
|
166
|
+
const bucket = byDay.get(point.timestamp) ?? initBucket();
|
|
167
|
+
accumulate(bucket, point);
|
|
168
|
+
byDay.set(point.timestamp, bucket);
|
|
169
|
+
}
|
|
170
|
+
const sorted = [...byDay.entries()].sort((a, b) => a[0] - b[0]);
|
|
171
|
+
return {
|
|
172
|
+
labels: sorted.map(([ts]) => format(new Date(ts), "MMM d")),
|
|
173
|
+
datasets: [{ label, data: sorted.map(([, bucket]) => bucketToValue(bucket)) }],
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
interface ModelKeyedPoint {
|
|
178
|
+
timestamp: number;
|
|
179
|
+
model: string;
|
|
180
|
+
provider: string;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Bucket points by day and by top-N model (with an "Other" rollup), producing
|
|
185
|
+
* a ChartSeries. Caller controls how points contribute to ranking and to each
|
|
186
|
+
* day-bucket value via the `rankWeight`/`accumulate`/`bucketToValue` callbacks
|
|
187
|
+
* — keeps the behavior chart's rate math separate from the cost chart's sum.
|
|
188
|
+
*/
|
|
189
|
+
export function buildTopNByModelSeries<T extends ModelKeyedPoint, B>(
|
|
190
|
+
points: T[],
|
|
191
|
+
opts: {
|
|
192
|
+
topN?: number;
|
|
193
|
+
rankWeight: (point: T) => number;
|
|
194
|
+
initBucket: () => B;
|
|
195
|
+
accumulate: (bucket: B, point: T) => void;
|
|
196
|
+
bucketToValue: (bucket: B) => number;
|
|
197
|
+
},
|
|
198
|
+
): ChartSeries {
|
|
199
|
+
if (points.length === 0) return { labels: [], datasets: [] };
|
|
200
|
+
const { topN = 5, rankWeight, initBucket, accumulate, bucketToValue } = opts;
|
|
201
|
+
|
|
202
|
+
const totals = new Map<string, { model: string; provider: string; weight: number }>();
|
|
203
|
+
for (const point of points) {
|
|
204
|
+
const key = `${point.model}::${point.provider}`;
|
|
205
|
+
const existing = totals.get(key);
|
|
206
|
+
if (existing) {
|
|
207
|
+
existing.weight += rankWeight(point);
|
|
208
|
+
} else {
|
|
209
|
+
totals.set(key, { model: point.model, provider: point.provider, weight: rankWeight(point) });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const sorted = [...totals.entries()].sort((a, b) => b[1].weight - a[1].weight);
|
|
214
|
+
const topEntries = sorted.slice(0, topN);
|
|
215
|
+
const topKeys = new Set(topEntries.map(([key]) => key));
|
|
216
|
+
|
|
217
|
+
const modelCount = new Map<string, number>();
|
|
218
|
+
for (const [, { model }] of topEntries) {
|
|
219
|
+
modelCount.set(model, (modelCount.get(model) ?? 0) + 1);
|
|
220
|
+
}
|
|
221
|
+
const labelByKey = new Map<string, string>();
|
|
222
|
+
for (const [key, { model, provider }] of topEntries) {
|
|
223
|
+
labelByKey.set(key, (modelCount.get(model) ?? 0) > 1 ? `${model} (${provider})` : model);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const allDays = [...new Set(points.map(p => p.timestamp))].sort((a, b) => a - b);
|
|
227
|
+
const seriesNames = topEntries.map(([key]) => labelByKey.get(key) ?? key);
|
|
228
|
+
const hasOther = points.some(p => !topKeys.has(`${p.model}::${p.provider}`));
|
|
229
|
+
if (hasOther) seriesNames.push("Other");
|
|
230
|
+
|
|
231
|
+
const dayMap = new Map<number, Record<string, B>>();
|
|
232
|
+
for (const day of allDays) dayMap.set(day, {});
|
|
233
|
+
for (const point of points) {
|
|
234
|
+
const key = `${point.model}::${point.provider}`;
|
|
235
|
+
const label = topKeys.has(key) ? (labelByKey.get(key) ?? point.model) : "Other";
|
|
236
|
+
const row = dayMap.get(point.timestamp);
|
|
237
|
+
if (!row) continue;
|
|
238
|
+
const bucket = row[label] ?? initBucket();
|
|
239
|
+
accumulate(bucket, point);
|
|
240
|
+
row[label] = bucket;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
labels: allDays.map(ts => format(new Date(ts), "MMM d")),
|
|
245
|
+
datasets: seriesNames.map(name => ({
|
|
246
|
+
label: name,
|
|
247
|
+
data: allDays.map(day => {
|
|
248
|
+
const bucket = dayMap.get(day)?.[name];
|
|
249
|
+
return bucket ? bucketToValue(bucket) : 0;
|
|
250
|
+
}),
|
|
251
|
+
})),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** All Models / By Model segmented toggle — identical UI in every time chart. */
|
|
256
|
+
export function ByModelToggle({ byModel, onChange }: { byModel: boolean; onChange: (v: boolean) => void }) {
|
|
257
|
+
return (
|
|
258
|
+
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-sm)] p-0.5 border border-[var(--border-subtle)]">
|
|
259
|
+
<button
|
|
260
|
+
type="button"
|
|
261
|
+
onClick={() => onChange(false)}
|
|
262
|
+
className={`tab-btn text-xs ${!byModel ? "active" : ""}`}
|
|
263
|
+
>
|
|
264
|
+
All Models
|
|
265
|
+
</button>
|
|
266
|
+
<button type="button" onClick={() => onChange(true)} className={`tab-btn text-xs ${byModel ? "active" : ""}`}>
|
|
267
|
+
By Model
|
|
268
|
+
</button>
|
|
269
|
+
</div>
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Outer surface card used by both time charts. `controls` slot covers
|
|
275
|
+
* chart-specific tabs (e.g. behavior metric picker); the by-model toggle and
|
|
276
|
+
* empty-state are part of the frame so callers don't redeclare them.
|
|
277
|
+
*/
|
|
278
|
+
export function ChartFrame({
|
|
279
|
+
title,
|
|
280
|
+
subtitle,
|
|
281
|
+
empty,
|
|
282
|
+
emptyMessage,
|
|
283
|
+
controls,
|
|
284
|
+
byModel,
|
|
285
|
+
onByModelChange,
|
|
286
|
+
children,
|
|
287
|
+
}: {
|
|
288
|
+
title: string;
|
|
289
|
+
subtitle: string;
|
|
290
|
+
empty: boolean;
|
|
291
|
+
emptyMessage: string;
|
|
292
|
+
controls?: React.ReactNode;
|
|
293
|
+
byModel: boolean;
|
|
294
|
+
onByModelChange: (v: boolean) => void;
|
|
295
|
+
children: React.ReactNode;
|
|
296
|
+
}) {
|
|
297
|
+
return (
|
|
298
|
+
<div className="surface overflow-hidden">
|
|
299
|
+
<div className="px-5 py-4 border-b border-[var(--border-subtle)] flex items-center justify-between gap-4 flex-wrap">
|
|
300
|
+
<div>
|
|
301
|
+
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
|
|
302
|
+
<p className="text-xs text-[var(--text-muted)] mt-1">{subtitle}</p>
|
|
303
|
+
</div>
|
|
304
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
305
|
+
{controls}
|
|
306
|
+
<ByModelToggle byModel={byModel} onChange={onByModelChange} />
|
|
307
|
+
</div>
|
|
308
|
+
</div>
|
|
309
|
+
<div className="p-5 min-h-[320px]">
|
|
310
|
+
{empty ? (
|
|
311
|
+
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
|
|
312
|
+
{emptyMessage}
|
|
313
|
+
</div>
|
|
314
|
+
) : (
|
|
315
|
+
<div className="h-[280px]">{children}</div>
|
|
316
|
+
)}
|
|
317
|
+
</div>
|
|
318
|
+
</div>
|
|
319
|
+
);
|
|
320
|
+
}
|
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared primitives for the per-model breakdown tables (ModelsTable,
|
|
3
|
+
* BehaviorModelsTable). Each table still owns its column definitions, sort
|
|
4
|
+
* order, sidebar contents and chart type — this module owns the surface
|
|
5
|
+
* chrome, expand-row plumbing, theme palette, and the mini-sparkline plus
|
|
6
|
+
* the shared plugin/scale config consumed by multi-line detail charts.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { format } from "date-fns";
|
|
10
|
+
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
11
|
+
import { Line } from "react-chartjs-2";
|
|
12
|
+
|
|
13
|
+
export { MODEL_COLORS } from "./chart-shared";
|
|
14
|
+
|
|
15
|
+
export const TABLE_CHART_THEMES = {
|
|
16
|
+
dark: {
|
|
17
|
+
legendLabel: "#cbd5e1",
|
|
18
|
+
tooltipBackground: "#16161e",
|
|
19
|
+
tooltipTitle: "#f8fafc",
|
|
20
|
+
tooltipBody: "#94a3b8",
|
|
21
|
+
tooltipBorder: "rgba(255, 255, 255, 0.1)",
|
|
22
|
+
grid: "rgba(255, 255, 255, 0.06)",
|
|
23
|
+
tick: "#94a3b8",
|
|
24
|
+
},
|
|
25
|
+
light: {
|
|
26
|
+
legendLabel: "#334155",
|
|
27
|
+
tooltipBackground: "#ffffff",
|
|
28
|
+
tooltipTitle: "#0f172a",
|
|
29
|
+
tooltipBody: "#334155",
|
|
30
|
+
tooltipBorder: "rgba(15, 23, 42, 0.18)",
|
|
31
|
+
grid: "rgba(15, 23, 42, 0.08)",
|
|
32
|
+
tick: "#475569",
|
|
33
|
+
},
|
|
34
|
+
} as const;
|
|
35
|
+
|
|
36
|
+
export type TableChartTheme = (typeof TABLE_CHART_THEMES)[keyof typeof TABLE_CHART_THEMES];
|
|
37
|
+
|
|
38
|
+
/** Style defaults for one line in a non-stacked detail chart. */
|
|
39
|
+
export function lineSeriesStyle(color: string) {
|
|
40
|
+
return {
|
|
41
|
+
borderColor: color,
|
|
42
|
+
backgroundColor: "transparent",
|
|
43
|
+
tension: 0.4,
|
|
44
|
+
pointRadius: 0,
|
|
45
|
+
borderWidth: 2,
|
|
46
|
+
};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* No-axis, no-legend single-series sparkline used in the trend cell of every
|
|
51
|
+
* model row. Caller supplies the already-extracted numeric series so this
|
|
52
|
+
* stays agnostic of the row's underlying data shape.
|
|
53
|
+
*/
|
|
54
|
+
export function MiniSparkline({
|
|
55
|
+
timestamps,
|
|
56
|
+
values,
|
|
57
|
+
color,
|
|
58
|
+
}: {
|
|
59
|
+
timestamps: number[];
|
|
60
|
+
values: number[];
|
|
61
|
+
color: string;
|
|
62
|
+
}) {
|
|
63
|
+
const chartData = {
|
|
64
|
+
labels: timestamps.map(ts => format(new Date(ts), "MMM d")),
|
|
65
|
+
datasets: [{ data: values, ...lineSeriesStyle(color) }],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
const options = {
|
|
69
|
+
responsive: true,
|
|
70
|
+
maintainAspectRatio: false,
|
|
71
|
+
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
|
72
|
+
scales: {
|
|
73
|
+
x: { display: false },
|
|
74
|
+
y: { display: false, min: 0 },
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return <Line data={chartData} options={options} />;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Plugin block (legend + tooltip) shared by every multi-series detail chart
|
|
83
|
+
* in the table expanded views.
|
|
84
|
+
*/
|
|
85
|
+
export function detailChartPlugins(chartTheme: TableChartTheme) {
|
|
86
|
+
return {
|
|
87
|
+
legend: {
|
|
88
|
+
display: true,
|
|
89
|
+
position: "top" as const,
|
|
90
|
+
labels: {
|
|
91
|
+
color: chartTheme.legendLabel,
|
|
92
|
+
usePointStyle: true,
|
|
93
|
+
padding: 16,
|
|
94
|
+
font: { size: 12 },
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
tooltip: {
|
|
98
|
+
backgroundColor: chartTheme.tooltipBackground,
|
|
99
|
+
titleColor: chartTheme.tooltipTitle,
|
|
100
|
+
bodyColor: chartTheme.tooltipBody,
|
|
101
|
+
borderColor: chartTheme.tooltipBorder,
|
|
102
|
+
borderWidth: 1,
|
|
103
|
+
cornerRadius: 8,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Single-Y-axis scales for a detail chart (used when every series shares a
|
|
110
|
+
* unit, e.g. behavior counts). Min anchored at 0.
|
|
111
|
+
*/
|
|
112
|
+
export function detailChartScalesSingleAxis(chartTheme: TableChartTheme) {
|
|
113
|
+
return {
|
|
114
|
+
x: {
|
|
115
|
+
grid: { color: chartTheme.grid },
|
|
116
|
+
ticks: { color: chartTheme.tick, font: { size: 11 } },
|
|
117
|
+
},
|
|
118
|
+
y: {
|
|
119
|
+
grid: { color: chartTheme.grid },
|
|
120
|
+
ticks: { color: chartTheme.tick, font: { size: 11 } },
|
|
121
|
+
min: 0,
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Dual-Y-axis scales for a detail chart with mixed units (e.g. TTFT seconds
|
|
128
|
+
* on left, tokens/s on right). Right-axis grid is suppressed so it doesn't
|
|
129
|
+
* collide with the left.
|
|
130
|
+
*/
|
|
131
|
+
export function detailChartScalesDualAxis(chartTheme: TableChartTheme) {
|
|
132
|
+
return {
|
|
133
|
+
x: {
|
|
134
|
+
grid: { color: chartTheme.grid },
|
|
135
|
+
ticks: { color: chartTheme.tick, font: { size: 11 } },
|
|
136
|
+
},
|
|
137
|
+
y: {
|
|
138
|
+
type: "linear" as const,
|
|
139
|
+
display: true,
|
|
140
|
+
position: "left" as const,
|
|
141
|
+
grid: { color: chartTheme.grid },
|
|
142
|
+
ticks: { color: chartTheme.tick, font: { size: 11 } },
|
|
143
|
+
},
|
|
144
|
+
y1: {
|
|
145
|
+
type: "linear" as const,
|
|
146
|
+
display: true,
|
|
147
|
+
position: "right" as const,
|
|
148
|
+
grid: { drawOnChartArea: false },
|
|
149
|
+
ticks: { color: chartTheme.tick, font: { size: 11 } },
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export interface TableColumn {
|
|
155
|
+
label: string;
|
|
156
|
+
align?: "left" | "right" | "center";
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Outer card + section title used by every model table. */
|
|
160
|
+
export function ModelTableShell({
|
|
161
|
+
title,
|
|
162
|
+
subtitle,
|
|
163
|
+
children,
|
|
164
|
+
}: {
|
|
165
|
+
title: string;
|
|
166
|
+
subtitle?: string;
|
|
167
|
+
children: React.ReactNode;
|
|
168
|
+
}) {
|
|
169
|
+
return (
|
|
170
|
+
<div className="surface overflow-hidden">
|
|
171
|
+
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
|
|
172
|
+
<h3 className="text-sm font-semibold text-[var(--text-primary)]">{title}</h3>
|
|
173
|
+
{subtitle ? <p className="text-xs text-[var(--text-muted)] mt-1">{subtitle}</p> : null}
|
|
174
|
+
</div>
|
|
175
|
+
<div className="overflow-x-auto">{children}</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function alignClass(align: TableColumn["align"]): string {
|
|
181
|
+
if (align === "right") return "text-right";
|
|
182
|
+
if (align === "center") return "text-center";
|
|
183
|
+
return "";
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Sticky column-header row for a model table. */
|
|
187
|
+
export function ModelTableHeader({ columns, gridTemplate }: { columns: TableColumn[]; gridTemplate: string }) {
|
|
188
|
+
return (
|
|
189
|
+
<div
|
|
190
|
+
className="grid gap-3 px-5 py-3 text-[var(--text-muted)] text-xs uppercase tracking-wider font-semibold"
|
|
191
|
+
style={{ gridTemplateColumns: gridTemplate }}
|
|
192
|
+
>
|
|
193
|
+
{columns.map(col => (
|
|
194
|
+
<div key={col.label} className={alignClass(col.align)}>
|
|
195
|
+
{col.label}
|
|
196
|
+
</div>
|
|
197
|
+
))}
|
|
198
|
+
{/* trailing chevron column has no header label */}
|
|
199
|
+
<div />
|
|
200
|
+
</div>
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** Scroll wrapper for the row stack — capped to fit the dashboard viewport. */
|
|
205
|
+
export function ModelTableBody({ children }: { children: React.ReactNode }) {
|
|
206
|
+
return <div className="max-h-[calc(100vh-300px)] overflow-y-auto">{children}</div>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Two-line model identity cell (model name + provider) shared by every
|
|
211
|
+
* per-model table. Kept as a stable named contract so callers don't restate
|
|
212
|
+
* the same two divs and font-utility classes.
|
|
213
|
+
*/
|
|
214
|
+
export function ModelNameCell({ model, provider }: { model: string; provider: string }) {
|
|
215
|
+
return (
|
|
216
|
+
<div>
|
|
217
|
+
<div className="font-medium text-[var(--text-primary)]">{model}</div>
|
|
218
|
+
<div className="text-xs text-[var(--text-muted)]">{provider}</div>
|
|
219
|
+
</div>
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* One expandable model row. `cells` matches the column order from
|
|
225
|
+
* `ModelTableHeader` plus the trend cell at the end (caller controls the
|
|
226
|
+
* sparkline / placeholder). `expandedContent` is the panel revealed on toggle.
|
|
227
|
+
*/
|
|
228
|
+
export function ExpandableModelRow({
|
|
229
|
+
gridTemplate,
|
|
230
|
+
cells,
|
|
231
|
+
trendCell,
|
|
232
|
+
isExpanded,
|
|
233
|
+
onToggle,
|
|
234
|
+
expandedContent,
|
|
235
|
+
}: {
|
|
236
|
+
gridTemplate: string;
|
|
237
|
+
cells: React.ReactNode[];
|
|
238
|
+
trendCell: React.ReactNode;
|
|
239
|
+
isExpanded: boolean;
|
|
240
|
+
onToggle: () => void;
|
|
241
|
+
expandedContent: React.ReactNode;
|
|
242
|
+
}) {
|
|
243
|
+
return (
|
|
244
|
+
<div className="border-t border-[var(--border-subtle)]">
|
|
245
|
+
<button
|
|
246
|
+
type="button"
|
|
247
|
+
onClick={onToggle}
|
|
248
|
+
className="w-full bg-transparent border-none text-left px-5 py-3 cursor-pointer hover:bg-[var(--bg-hover)] transition-colors"
|
|
249
|
+
>
|
|
250
|
+
<div className="grid gap-3 items-center" style={{ gridTemplateColumns: gridTemplate }}>
|
|
251
|
+
{cells}
|
|
252
|
+
<div className="h-10">{trendCell}</div>
|
|
253
|
+
<div className="flex justify-center text-[var(--text-muted)]">
|
|
254
|
+
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
255
|
+
</div>
|
|
256
|
+
</div>
|
|
257
|
+
</button>
|
|
258
|
+
{isExpanded ? (
|
|
259
|
+
<div className="px-5 py-4 bg-[var(--bg-elevated)] border-t border-[var(--border-subtle)]">
|
|
260
|
+
{expandedContent}
|
|
261
|
+
</div>
|
|
262
|
+
) : null}
|
|
263
|
+
</div>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** Placeholder shown in the trend cell when a model has no time-series data. */
|
|
268
|
+
export function TrendEmpty() {
|
|
269
|
+
return <div className="text-[var(--text-muted)] text-center text-sm">-</div>;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Placeholder shown in the expanded detail-chart slot when data is missing. */
|
|
273
|
+
export function DetailChartEmpty({ message = "No data available" }: { message?: string }) {
|
|
274
|
+
return <div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">{message}</div>;
|
|
275
|
+
}
|