@oh-my-pi/omp-stats 12.1.0 → 12.2.0
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 +14 -7
- package/src/client/App.tsx +58 -555
- package/src/client/components/ChartsContainer.tsx +185 -0
- package/src/client/components/Header.tsx +48 -0
- package/src/client/components/ModelsTable.tsx +343 -0
- package/src/client/components/RequestDetail.tsx +105 -101
- package/src/client/components/RequestList.tsx +37 -112
- package/src/client/components/StatsGrid.tsx +88 -0
- package/src/client/index.tsx +1 -0
- package/src/client/styles.css +277 -0
- package/src/server.ts +39 -30
- package/src/client/components/StatCard.tsx +0 -32
|
@@ -0,0 +1,185 @@
|
|
|
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 { format } from "date-fns";
|
|
13
|
+
import { useMemo } from "react";
|
|
14
|
+
import { Line } from "react-chartjs-2";
|
|
15
|
+
import type { ModelTimeSeriesPoint } from "../types";
|
|
16
|
+
|
|
17
|
+
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend, Filler);
|
|
18
|
+
|
|
19
|
+
const MODEL_COLORS = [
|
|
20
|
+
"#a78bfa", // violet
|
|
21
|
+
"#22d3ee", // cyan
|
|
22
|
+
"#ec4899", // pink
|
|
23
|
+
"#4ade80", // green
|
|
24
|
+
"#fbbf24", // amber
|
|
25
|
+
"#f87171", // red
|
|
26
|
+
"#60a5fa", // blue
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
interface ChartsContainerProps {
|
|
30
|
+
modelSeries: ModelTimeSeriesPoint[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function ChartsContainer({ modelSeries }: ChartsContainerProps) {
|
|
34
|
+
const chartData = useMemo(() => buildModelPreferenceSeries(modelSeries), [modelSeries]);
|
|
35
|
+
|
|
36
|
+
const data = {
|
|
37
|
+
labels: chartData.data.map(d => format(new Date(d.timestamp), "MMM d")),
|
|
38
|
+
datasets: chartData.series.map((seriesName, index) => ({
|
|
39
|
+
label: seriesName,
|
|
40
|
+
data: chartData.data.map(d => d[seriesName] ?? 0),
|
|
41
|
+
borderColor: MODEL_COLORS[index % MODEL_COLORS.length],
|
|
42
|
+
backgroundColor: `${MODEL_COLORS[index % MODEL_COLORS.length]}20`,
|
|
43
|
+
fill: true,
|
|
44
|
+
tension: 0.4,
|
|
45
|
+
pointRadius: 0,
|
|
46
|
+
pointHoverRadius: 4,
|
|
47
|
+
borderWidth: 2,
|
|
48
|
+
})),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const options = {
|
|
52
|
+
responsive: true,
|
|
53
|
+
maintainAspectRatio: false,
|
|
54
|
+
interaction: {
|
|
55
|
+
mode: "index" as const,
|
|
56
|
+
intersect: false,
|
|
57
|
+
},
|
|
58
|
+
plugins: {
|
|
59
|
+
legend: {
|
|
60
|
+
position: "top" as const,
|
|
61
|
+
align: "start" as const,
|
|
62
|
+
labels: {
|
|
63
|
+
color: "var(--text-secondary)",
|
|
64
|
+
usePointStyle: true,
|
|
65
|
+
padding: 16,
|
|
66
|
+
font: { size: 12 },
|
|
67
|
+
boxWidth: 8,
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
tooltip: {
|
|
71
|
+
backgroundColor: "var(--bg-elevated)",
|
|
72
|
+
titleColor: "var(--text-primary)",
|
|
73
|
+
bodyColor: "var(--text-secondary)",
|
|
74
|
+
borderColor: "var(--border-default)",
|
|
75
|
+
borderWidth: 1,
|
|
76
|
+
padding: 12,
|
|
77
|
+
cornerRadius: 8,
|
|
78
|
+
callbacks: {
|
|
79
|
+
label: (context: { dataset: { label?: string }; parsed: { y: number | null } }) => {
|
|
80
|
+
const label = context.dataset.label ?? "";
|
|
81
|
+
const value = context.parsed.y;
|
|
82
|
+
return `${label}: ${(value ?? 0).toFixed(1)}%`;
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
scales: {
|
|
88
|
+
x: {
|
|
89
|
+
grid: {
|
|
90
|
+
color: "var(--border-subtle)",
|
|
91
|
+
drawBorder: false,
|
|
92
|
+
},
|
|
93
|
+
ticks: {
|
|
94
|
+
color: "var(--text-muted)",
|
|
95
|
+
font: { size: 11 },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
y: {
|
|
99
|
+
grid: {
|
|
100
|
+
color: "var(--border-subtle)",
|
|
101
|
+
drawBorder: false,
|
|
102
|
+
},
|
|
103
|
+
ticks: {
|
|
104
|
+
color: "var(--text-muted)",
|
|
105
|
+
font: { size: 11 },
|
|
106
|
+
callback: (value: number | string) => `${value}%`,
|
|
107
|
+
},
|
|
108
|
+
min: 0,
|
|
109
|
+
max: 100,
|
|
110
|
+
},
|
|
111
|
+
},
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return (
|
|
115
|
+
<div className="surface overflow-hidden">
|
|
116
|
+
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
|
|
117
|
+
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Model Preference</h3>
|
|
118
|
+
<p className="text-xs text-[var(--text-muted)] mt-1">Share of requests over the last 14 days</p>
|
|
119
|
+
</div>
|
|
120
|
+
<div className="p-5 min-h-[320px]">
|
|
121
|
+
{chartData.data.length === 0 ? (
|
|
122
|
+
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
|
|
123
|
+
No data available
|
|
124
|
+
</div>
|
|
125
|
+
) : (
|
|
126
|
+
<div className="h-[280px]">
|
|
127
|
+
<Line data={data} options={options} />
|
|
128
|
+
</div>
|
|
129
|
+
)}
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildModelPreferenceSeries(
|
|
136
|
+
points: ModelTimeSeriesPoint[],
|
|
137
|
+
topN = 5,
|
|
138
|
+
): {
|
|
139
|
+
data: Array<Record<string, number>>;
|
|
140
|
+
series: string[];
|
|
141
|
+
} {
|
|
142
|
+
if (points.length === 0) return { data: [], series: [] };
|
|
143
|
+
|
|
144
|
+
const totals = new Map<string, { label: string; total: number }>();
|
|
145
|
+
for (const point of points) {
|
|
146
|
+
const key = `${point.model}::${point.provider}`;
|
|
147
|
+
const label = `${point.model} (${point.provider})`;
|
|
148
|
+
const existing = totals.get(key);
|
|
149
|
+
if (existing) {
|
|
150
|
+
existing.total += point.requests;
|
|
151
|
+
} else {
|
|
152
|
+
totals.set(key, { label, total: point.requests });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const sorted = [...totals.values()].sort((a, b) => b.total - a.total);
|
|
157
|
+
const topLabels = sorted.slice(0, topN).map(entry => entry.label);
|
|
158
|
+
const dataMap = new Map<number, Record<string, number>>();
|
|
159
|
+
|
|
160
|
+
for (const point of points) {
|
|
161
|
+
const label = `${point.model} (${point.provider})`;
|
|
162
|
+
const bucket = dataMap.get(point.timestamp) ?? { timestamp: point.timestamp, total: 0 };
|
|
163
|
+
bucket.total += point.requests;
|
|
164
|
+
const key = topLabels.includes(label) ? label : "Other";
|
|
165
|
+
bucket[key] = (bucket[key] ?? 0) + point.requests;
|
|
166
|
+
dataMap.set(point.timestamp, bucket);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const series = [...topLabels];
|
|
170
|
+
if ([...dataMap.values()].some(row => (row.Other ?? 0) > 0)) {
|
|
171
|
+
series.push("Other");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const data = [...dataMap.values()]
|
|
175
|
+
.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0))
|
|
176
|
+
.map(row => {
|
|
177
|
+
const total = row.total ?? 0;
|
|
178
|
+
for (const key of series) {
|
|
179
|
+
row[key] = total > 0 ? ((row[key] ?? 0) / total) * 100 : 0;
|
|
180
|
+
}
|
|
181
|
+
return row;
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
return { data, series };
|
|
185
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { Activity, RefreshCw } from "lucide-react";
|
|
2
|
+
|
|
3
|
+
type Tab = "overview" | "requests" | "errors" | "models";
|
|
4
|
+
|
|
5
|
+
interface HeaderProps {
|
|
6
|
+
activeTab: Tab;
|
|
7
|
+
onTabChange: (tab: Tab) => void;
|
|
8
|
+
onSync: () => void;
|
|
9
|
+
syncing: boolean;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const tabs: Tab[] = ["overview", "requests", "errors", "models"];
|
|
13
|
+
|
|
14
|
+
export function Header({ activeTab, onTabChange, onSync, syncing }: HeaderProps) {
|
|
15
|
+
return (
|
|
16
|
+
<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)]">
|
|
17
|
+
<div className="flex items-center gap-3">
|
|
18
|
+
<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">
|
|
19
|
+
<Activity className="w-5 h-5 text-white" />
|
|
20
|
+
</div>
|
|
21
|
+
<div>
|
|
22
|
+
<h1 className="text-xl font-semibold text-[var(--text-primary)]">AI Usage</h1>
|
|
23
|
+
<p className="text-sm text-[var(--text-muted)]">Statistics & Analytics</p>
|
|
24
|
+
</div>
|
|
25
|
+
</div>
|
|
26
|
+
|
|
27
|
+
<div className="flex items-center gap-3">
|
|
28
|
+
<div className="flex bg-[var(--bg-surface)] rounded-[var(--radius-md)] p-1 border border-[var(--border-subtle)]">
|
|
29
|
+
{tabs.map(tab => (
|
|
30
|
+
<button
|
|
31
|
+
key={tab}
|
|
32
|
+
type="button"
|
|
33
|
+
onClick={() => onTabChange(tab)}
|
|
34
|
+
className={`tab-btn capitalize ${activeTab === tab ? "active" : ""}`}
|
|
35
|
+
>
|
|
36
|
+
{tab}
|
|
37
|
+
</button>
|
|
38
|
+
))}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<button type="button" onClick={onSync} disabled={syncing} className="btn btn-primary">
|
|
42
|
+
<RefreshCw size={16} className={syncing ? "spin" : ""} />
|
|
43
|
+
{syncing ? "Syncing..." : "Sync"}
|
|
44
|
+
</button>
|
|
45
|
+
</div>
|
|
46
|
+
</header>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
@@ -0,0 +1,343 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CategoryScale,
|
|
3
|
+
Chart as ChartJS,
|
|
4
|
+
Legend,
|
|
5
|
+
LinearScale,
|
|
6
|
+
LineElement,
|
|
7
|
+
PointElement,
|
|
8
|
+
Title,
|
|
9
|
+
Tooltip,
|
|
10
|
+
} from "chart.js";
|
|
11
|
+
import { format } from "date-fns";
|
|
12
|
+
import { ChevronDown, ChevronUp } from "lucide-react";
|
|
13
|
+
import { useMemo, useState } from "react";
|
|
14
|
+
import { Line } from "react-chartjs-2";
|
|
15
|
+
import type { ModelPerformancePoint, ModelStats } from "../types";
|
|
16
|
+
|
|
17
|
+
ChartJS.register(CategoryScale, LinearScale, PointElement, LineElement, Title, Tooltip, Legend);
|
|
18
|
+
|
|
19
|
+
const MODEL_COLORS = [
|
|
20
|
+
"#a78bfa", // violet
|
|
21
|
+
"#22d3ee", // cyan
|
|
22
|
+
"#ec4899", // pink
|
|
23
|
+
"#4ade80", // green
|
|
24
|
+
"#fbbf24", // amber
|
|
25
|
+
"#f87171", // red
|
|
26
|
+
"#60a5fa", // blue
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
interface ModelsTableProps {
|
|
30
|
+
models: ModelStats[];
|
|
31
|
+
performanceSeries: ModelPerformancePoint[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type ModelPerformanceSeries = {
|
|
35
|
+
label: string;
|
|
36
|
+
data: Array<{
|
|
37
|
+
timestamp: number;
|
|
38
|
+
avgTtftSeconds: number | null;
|
|
39
|
+
avgTokensPerSecond: number | null;
|
|
40
|
+
requests: number;
|
|
41
|
+
}>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export function ModelsTable({ models, performanceSeries }: ModelsTableProps) {
|
|
45
|
+
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
|
46
|
+
|
|
47
|
+
const performanceSeriesByKey = useMemo(() => buildModelPerformanceLookup(performanceSeries), [performanceSeries]);
|
|
48
|
+
const sortedModels = [...models].sort(
|
|
49
|
+
(a, b) => b.totalInputTokens + b.totalOutputTokens - (a.totalInputTokens + a.totalOutputTokens),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
return (
|
|
53
|
+
<div className="surface overflow-hidden">
|
|
54
|
+
<div className="px-5 py-4 border-b border-[var(--border-subtle)]">
|
|
55
|
+
<h3 className="text-sm font-semibold text-[var(--text-primary)]">Model Statistics</h3>
|
|
56
|
+
</div>
|
|
57
|
+
|
|
58
|
+
<div className="overflow-x-auto">
|
|
59
|
+
<div
|
|
60
|
+
className="grid gap-3 px-5 py-3 text-[var(--text-muted)] text-xs uppercase tracking-wider font-semibold"
|
|
61
|
+
style={{ gridTemplateColumns: "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px" }}
|
|
62
|
+
>
|
|
63
|
+
<div>Model</div>
|
|
64
|
+
<div className="text-right">Requests</div>
|
|
65
|
+
<div className="text-right">Cost</div>
|
|
66
|
+
<div className="text-right">Tokens</div>
|
|
67
|
+
<div className="text-right">Tokens/s</div>
|
|
68
|
+
<div className="text-right">TTFT</div>
|
|
69
|
+
<div className="text-center">14d Trend</div>
|
|
70
|
+
<div />
|
|
71
|
+
</div>
|
|
72
|
+
|
|
73
|
+
<div className="max-h-[calc(100vh-300px)] overflow-y-auto">
|
|
74
|
+
{sortedModels.map((model, index) => {
|
|
75
|
+
const key = `${model.model}::${model.provider}`;
|
|
76
|
+
const performance = performanceSeriesByKey.get(key);
|
|
77
|
+
const trendData = performance?.data ?? [];
|
|
78
|
+
const trendColor = MODEL_COLORS[index % MODEL_COLORS.length];
|
|
79
|
+
const isExpanded = expandedKey === key;
|
|
80
|
+
const errorRate = model.errorRate * 100;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div key={key} className="border-t border-[var(--border-subtle)]">
|
|
84
|
+
<button
|
|
85
|
+
type="button"
|
|
86
|
+
onClick={() => setExpandedKey(isExpanded ? null : key)}
|
|
87
|
+
className="w-full bg-transparent border-none text-left px-5 py-3 cursor-pointer hover:bg-[var(--bg-hover)] transition-colors"
|
|
88
|
+
>
|
|
89
|
+
<div
|
|
90
|
+
className="grid gap-3 items-center"
|
|
91
|
+
style={{ gridTemplateColumns: "2fr 0.9fr 0.9fr 1fr 0.8fr 0.8fr 140px 40px" }}
|
|
92
|
+
>
|
|
93
|
+
<div>
|
|
94
|
+
<div className="font-medium text-[var(--text-primary)]">{model.model}</div>
|
|
95
|
+
<div className="text-xs text-[var(--text-muted)]">{model.provider}</div>
|
|
96
|
+
</div>
|
|
97
|
+
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
98
|
+
{model.totalRequests.toLocaleString()}
|
|
99
|
+
</div>
|
|
100
|
+
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
101
|
+
${model.totalCost.toFixed(2)}
|
|
102
|
+
</div>
|
|
103
|
+
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
104
|
+
{(model.totalInputTokens + model.totalOutputTokens).toLocaleString()}
|
|
105
|
+
</div>
|
|
106
|
+
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
107
|
+
{model.avgTokensPerSecond?.toFixed(1) ?? "-"}
|
|
108
|
+
</div>
|
|
109
|
+
<div className="text-right text-[var(--text-secondary)] font-mono text-sm">
|
|
110
|
+
{model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
|
|
111
|
+
</div>
|
|
112
|
+
<div className="h-10">
|
|
113
|
+
{trendData.length === 0 ? (
|
|
114
|
+
<div className="text-[var(--text-muted)] text-center text-sm">-</div>
|
|
115
|
+
) : (
|
|
116
|
+
<TrendChart data={trendData} color={trendColor} />
|
|
117
|
+
)}
|
|
118
|
+
</div>
|
|
119
|
+
<div className="flex justify-center text-[var(--text-muted)]">
|
|
120
|
+
{isExpanded ? <ChevronUp size={16} /> : <ChevronDown size={16} />}
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
</button>
|
|
124
|
+
|
|
125
|
+
{isExpanded && (
|
|
126
|
+
<div className="px-5 py-4 bg-[var(--bg-elevated)] border-t border-[var(--border-subtle)]">
|
|
127
|
+
<div className="grid gap-4" style={{ gridTemplateColumns: "200px 1fr" }}>
|
|
128
|
+
<div className="space-y-4 text-sm">
|
|
129
|
+
<div>
|
|
130
|
+
<div className="text-[var(--text-primary)] font-medium mb-2">Quality</div>
|
|
131
|
+
<div className="space-y-1 text-[var(--text-secondary)]">
|
|
132
|
+
<div className="flex items-center justify-between">
|
|
133
|
+
<span>Error rate</span>
|
|
134
|
+
<span
|
|
135
|
+
className={
|
|
136
|
+
errorRate > 5
|
|
137
|
+
? "text-[var(--accent-red)]"
|
|
138
|
+
: "text-[var(--accent-green)]"
|
|
139
|
+
}
|
|
140
|
+
>
|
|
141
|
+
{errorRate.toFixed(1)}%
|
|
142
|
+
</span>
|
|
143
|
+
</div>
|
|
144
|
+
<div className="flex items-center justify-between">
|
|
145
|
+
<span>Cache rate</span>
|
|
146
|
+
<span className="text-[var(--accent-cyan)]">
|
|
147
|
+
{(model.cacheRate * 100).toFixed(1)}%
|
|
148
|
+
</span>
|
|
149
|
+
</div>
|
|
150
|
+
</div>
|
|
151
|
+
</div>
|
|
152
|
+
<div>
|
|
153
|
+
<div className="text-[var(--text-primary)] font-medium mb-2">Latency</div>
|
|
154
|
+
<div className="space-y-1 text-[var(--text-secondary)]">
|
|
155
|
+
<div className="flex items-center justify-between">
|
|
156
|
+
<span>Avg duration</span>
|
|
157
|
+
<span className="font-mono">
|
|
158
|
+
{model.avgDuration ? `${(model.avgDuration / 1000).toFixed(2)}s` : "-"}
|
|
159
|
+
</span>
|
|
160
|
+
</div>
|
|
161
|
+
<div className="flex items-center justify-between">
|
|
162
|
+
<span>Avg TTFT</span>
|
|
163
|
+
<span className="font-mono">
|
|
164
|
+
{model.avgTtft ? `${(model.avgTtft / 1000).toFixed(2)}s` : "-"}
|
|
165
|
+
</span>
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
<div className="h-[200px]">
|
|
171
|
+
{trendData.length === 0 ? (
|
|
172
|
+
<div className="h-full flex items-center justify-center text-[var(--text-muted)] text-sm">
|
|
173
|
+
No data available
|
|
174
|
+
</div>
|
|
175
|
+
) : (
|
|
176
|
+
<PerformanceChart data={trendData} color={trendColor} />
|
|
177
|
+
)}
|
|
178
|
+
</div>
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
</div>
|
|
183
|
+
);
|
|
184
|
+
})}
|
|
185
|
+
</div>
|
|
186
|
+
</div>
|
|
187
|
+
</div>
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function TrendChart({
|
|
192
|
+
data,
|
|
193
|
+
color,
|
|
194
|
+
}: {
|
|
195
|
+
data: Array<{ timestamp: number; avgTokensPerSecond: number | null }>;
|
|
196
|
+
color: string;
|
|
197
|
+
}) {
|
|
198
|
+
const chartData = {
|
|
199
|
+
labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
|
|
200
|
+
datasets: [
|
|
201
|
+
{
|
|
202
|
+
data: data.map(d => d.avgTokensPerSecond ?? 0),
|
|
203
|
+
borderColor: color,
|
|
204
|
+
backgroundColor: "transparent",
|
|
205
|
+
tension: 0.4,
|
|
206
|
+
pointRadius: 0,
|
|
207
|
+
borderWidth: 2,
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
const options = {
|
|
213
|
+
responsive: true,
|
|
214
|
+
maintainAspectRatio: false,
|
|
215
|
+
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
|
216
|
+
scales: {
|
|
217
|
+
x: { display: false },
|
|
218
|
+
y: { display: false, min: 0 },
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
return <Line data={chartData} options={options} />;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function PerformanceChart({
|
|
226
|
+
data,
|
|
227
|
+
color,
|
|
228
|
+
}: {
|
|
229
|
+
data: Array<{ timestamp: number; avgTtftSeconds: number | null; avgTokensPerSecond: number | null }>;
|
|
230
|
+
color: string;
|
|
231
|
+
}) {
|
|
232
|
+
const chartData = {
|
|
233
|
+
labels: data.map(d => format(new Date(d.timestamp), "MMM d")),
|
|
234
|
+
datasets: [
|
|
235
|
+
{
|
|
236
|
+
label: "TTFT",
|
|
237
|
+
data: data.map(d => d.avgTtftSeconds ?? null),
|
|
238
|
+
borderColor: "#fbbf24",
|
|
239
|
+
backgroundColor: "transparent",
|
|
240
|
+
tension: 0.4,
|
|
241
|
+
pointRadius: 0,
|
|
242
|
+
borderWidth: 2,
|
|
243
|
+
yAxisID: "y" as const,
|
|
244
|
+
},
|
|
245
|
+
{
|
|
246
|
+
label: "Tokens/s",
|
|
247
|
+
data: data.map(d => d.avgTokensPerSecond ?? null),
|
|
248
|
+
borderColor: color,
|
|
249
|
+
backgroundColor: "transparent",
|
|
250
|
+
tension: 0.4,
|
|
251
|
+
pointRadius: 0,
|
|
252
|
+
borderWidth: 2,
|
|
253
|
+
yAxisID: "y1" as const,
|
|
254
|
+
},
|
|
255
|
+
],
|
|
256
|
+
};
|
|
257
|
+
|
|
258
|
+
const options = {
|
|
259
|
+
responsive: true,
|
|
260
|
+
maintainAspectRatio: false,
|
|
261
|
+
plugins: {
|
|
262
|
+
legend: {
|
|
263
|
+
display: true,
|
|
264
|
+
position: "top" as const,
|
|
265
|
+
labels: {
|
|
266
|
+
color: "var(--text-secondary)",
|
|
267
|
+
usePointStyle: true,
|
|
268
|
+
padding: 12,
|
|
269
|
+
font: { size: 10 },
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
tooltip: {
|
|
273
|
+
backgroundColor: "var(--bg-elevated)",
|
|
274
|
+
titleColor: "var(--text-primary)",
|
|
275
|
+
bodyColor: "var(--text-secondary)",
|
|
276
|
+
borderColor: "var(--border-default)",
|
|
277
|
+
borderWidth: 1,
|
|
278
|
+
cornerRadius: 8,
|
|
279
|
+
},
|
|
280
|
+
},
|
|
281
|
+
scales: {
|
|
282
|
+
x: {
|
|
283
|
+
grid: { color: "var(--border-subtle)" },
|
|
284
|
+
ticks: { color: "var(--text-muted)", font: { size: 10 } },
|
|
285
|
+
},
|
|
286
|
+
y: {
|
|
287
|
+
type: "linear" as const,
|
|
288
|
+
display: true,
|
|
289
|
+
position: "left" as const,
|
|
290
|
+
grid: { color: "var(--border-subtle)" },
|
|
291
|
+
ticks: { color: "var(--text-muted)", font: { size: 10 } },
|
|
292
|
+
},
|
|
293
|
+
y1: {
|
|
294
|
+
type: "linear" as const,
|
|
295
|
+
display: true,
|
|
296
|
+
position: "right" as const,
|
|
297
|
+
grid: { drawOnChartArea: false },
|
|
298
|
+
ticks: { color: "var(--text-muted)", font: { size: 10 } },
|
|
299
|
+
},
|
|
300
|
+
},
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
return <Line data={chartData} options={options} />;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function buildModelPerformanceLookup(points: ModelPerformancePoint[], days = 14): Map<string, ModelPerformanceSeries> {
|
|
307
|
+
const dayMs = 24 * 60 * 60 * 1000;
|
|
308
|
+
const maxTimestamp = points.reduce((max, point) => Math.max(max, point.timestamp), 0);
|
|
309
|
+
const anchor = maxTimestamp > 0 ? maxTimestamp : Math.floor(Date.now() / dayMs) * dayMs;
|
|
310
|
+
const start = anchor - (days - 1) * dayMs;
|
|
311
|
+
const buckets = Array.from({ length: days }, (_, index) => start + index * dayMs);
|
|
312
|
+
const bucketIndex = new Map(buckets.map((timestamp, index) => [timestamp, index]));
|
|
313
|
+
const seriesByKey = new Map<string, ModelPerformanceSeries>();
|
|
314
|
+
|
|
315
|
+
for (const point of points) {
|
|
316
|
+
const key = `${point.model}::${point.provider}`;
|
|
317
|
+
let series = seriesByKey.get(key);
|
|
318
|
+
if (!series) {
|
|
319
|
+
series = {
|
|
320
|
+
label: `${point.model} (${point.provider})`,
|
|
321
|
+
data: buckets.map(timestamp => ({
|
|
322
|
+
timestamp,
|
|
323
|
+
avgTtftSeconds: null,
|
|
324
|
+
avgTokensPerSecond: null,
|
|
325
|
+
requests: 0,
|
|
326
|
+
})),
|
|
327
|
+
};
|
|
328
|
+
seriesByKey.set(key, series);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const index = bucketIndex.get(point.timestamp);
|
|
332
|
+
if (index === undefined) continue;
|
|
333
|
+
|
|
334
|
+
series.data[index] = {
|
|
335
|
+
timestamp: point.timestamp,
|
|
336
|
+
avgTtftSeconds: point.avgTtft !== null ? point.avgTtft / 1000 : null,
|
|
337
|
+
avgTokensPerSecond: point.avgTokensPerSecond,
|
|
338
|
+
requests: point.requests,
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
return seriesByKey;
|
|
343
|
+
}
|