@raingor/pi-web-switch 0.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/README.ja.md +137 -0
- package/README.md +277 -0
- package/README.zh-CN.md +176 -0
- package/index.html +13 -0
- package/package.json +44 -0
- package/pi-package/index.ts +100 -0
- package/pi-package/skills/pi-web-switch/SKILL.md +60 -0
- package/public/pi.svg +4 -0
- package/server/pi-reader.ts +678 -0
- package/src/App.tsx +25 -0
- package/src/components/dashboard/DashboardPage.tsx +607 -0
- package/src/components/layout/AppShell.tsx +18 -0
- package/src/components/layout/Sidebar.tsx +116 -0
- package/src/components/models/ModelsPage.tsx +570 -0
- package/src/components/providers/ProvidersPage.tsx +466 -0
- package/src/components/sessions/MemoryPage.tsx +177 -0
- package/src/components/sessions/SessionsPage.tsx +347 -0
- package/src/components/settings/SettingsPage.tsx +351 -0
- package/src/components/ui/Badge.tsx +29 -0
- package/src/components/ui/EmptyState.tsx +20 -0
- package/src/components/ui/Modal.tsx +41 -0
- package/src/components/ui/StatCard.tsx +37 -0
- package/src/data/builtin-providers.ts +148 -0
- package/src/data/mock-config.ts +261 -0
- package/src/data/mock-usage.ts +153 -0
- package/src/index.css +217 -0
- package/src/lib/config.ts +56 -0
- package/src/lib/currency.ts +48 -0
- package/src/lib/i18n.tsx +98 -0
- package/src/lib/translations/en.ts +168 -0
- package/src/lib/translations/index.ts +14 -0
- package/src/lib/translations/ja.ts +158 -0
- package/src/lib/translations/zh-CN.ts +158 -0
- package/src/lib/translations/zh-TW.ts +158 -0
- package/src/lib/utils.ts +51 -0
- package/src/main.tsx +106 -0
- package/src/store/config-store.ts +459 -0
- package/src/types/index.ts +187 -0
- package/src/vite-env.d.ts +1 -0
- package/tsconfig.json +24 -0
- package/vite.config.ts +172 -0
|
@@ -0,0 +1,607 @@
|
|
|
1
|
+
import { useState, useEffect, useCallback } from "react";
|
|
2
|
+
import { useConfigStore } from "@/store/config-store";
|
|
3
|
+
import { useTranslation } from "@/lib/i18n";
|
|
4
|
+
import { useCurrency } from "@/lib/currency";
|
|
5
|
+
import { formatTokens, formatCost, formatNumber, cn, USD_TO_CNY } from "@/lib/utils";
|
|
6
|
+
import {
|
|
7
|
+
Activity, DollarSign, BarChart3, ArrowUp, ArrowDown, Database, DollarSignIcon, RefreshCw,
|
|
8
|
+
} from "lucide-react";
|
|
9
|
+
import {
|
|
10
|
+
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
|
11
|
+
CartesianGrid, Legend,
|
|
12
|
+
} from "recharts";
|
|
13
|
+
|
|
14
|
+
// ─── Types ──────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
interface UsageRangeData {
|
|
17
|
+
totalTokens: number;
|
|
18
|
+
totalInput: number;
|
|
19
|
+
totalOutput: number;
|
|
20
|
+
totalCacheRead: number;
|
|
21
|
+
totalCacheWrite: number;
|
|
22
|
+
totalCost: number;
|
|
23
|
+
totalRequests: number;
|
|
24
|
+
cacheHitRate: number;
|
|
25
|
+
dailyBreakdown: {
|
|
26
|
+
date: string;
|
|
27
|
+
input: number;
|
|
28
|
+
output: number;
|
|
29
|
+
cacheRead: number;
|
|
30
|
+
cacheWrite: number;
|
|
31
|
+
cost: number;
|
|
32
|
+
requests: number;
|
|
33
|
+
}[];
|
|
34
|
+
hourlyBreakdown: {
|
|
35
|
+
hour: string;
|
|
36
|
+
input: number;
|
|
37
|
+
output: number;
|
|
38
|
+
cacheRead: number;
|
|
39
|
+
cacheWrite: number;
|
|
40
|
+
cost: number;
|
|
41
|
+
requests: number;
|
|
42
|
+
}[];
|
|
43
|
+
requestLog: {
|
|
44
|
+
timestamp: string;
|
|
45
|
+
providerId: string;
|
|
46
|
+
modelId: string;
|
|
47
|
+
input: number;
|
|
48
|
+
output: number;
|
|
49
|
+
cost: number;
|
|
50
|
+
requests: number;
|
|
51
|
+
}[];
|
|
52
|
+
providerStats: {
|
|
53
|
+
providerId: string;
|
|
54
|
+
totalTokens: number;
|
|
55
|
+
totalInput: number;
|
|
56
|
+
totalOutput: number;
|
|
57
|
+
totalCost: number;
|
|
58
|
+
totalRequests: number;
|
|
59
|
+
modelCount: number;
|
|
60
|
+
}[];
|
|
61
|
+
modelStats: {
|
|
62
|
+
modelId: string;
|
|
63
|
+
providerId: string;
|
|
64
|
+
totalTokens: number;
|
|
65
|
+
totalInput: number;
|
|
66
|
+
totalOutput: number;
|
|
67
|
+
totalCost: number;
|
|
68
|
+
totalRequests: number;
|
|
69
|
+
}[];
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type RangeKey = "today" | "7d" | "30d" | "custom";
|
|
73
|
+
type TabKey = "log" | "provider" | "model";
|
|
74
|
+
|
|
75
|
+
const RANGE_OPTIONS: { key: RangeKey; label: string }[] = [
|
|
76
|
+
{ key: "today", label: "Today" },
|
|
77
|
+
{ key: "7d", label: "7 Days" },
|
|
78
|
+
{ key: "30d", label: "30 Days" },
|
|
79
|
+
{ key: "custom", label: "Custom" },
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
const COLORS = ["#3b82f6", "#10b981", "#8b5cf6", "#f59e0b", "#ef4444"];
|
|
83
|
+
|
|
84
|
+
const CHART_LINE_COLORS: Record<string, string> = {
|
|
85
|
+
input: "#3b82f6",
|
|
86
|
+
output: "#10b981",
|
|
87
|
+
cacheRead: "#8b5cf6",
|
|
88
|
+
cacheWrite: "#f59e0b",
|
|
89
|
+
cost: "#ef4444",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
const CHART_LABELS: Record<string, string> = {
|
|
93
|
+
input: "Input",
|
|
94
|
+
output: "Output",
|
|
95
|
+
cacheRead: "Cache Hit",
|
|
96
|
+
cacheWrite: "Cache Create",
|
|
97
|
+
cost: "Cost",
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
// ─── Helpers ────────────────────────────────────────────
|
|
101
|
+
|
|
102
|
+
function formatTokensShort(n: number): string {
|
|
103
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
104
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
|
|
105
|
+
return n.toString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function formatCostShort(n: number): string {
|
|
109
|
+
if (n >= 1) return `$${n.toFixed(2)}`;
|
|
110
|
+
if (n >= 0.01) return `¢${(n * 100).toFixed(1)}`;
|
|
111
|
+
return `$${n.toFixed(4)}`;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function formatCostShortCNY(n: number): string {
|
|
115
|
+
const cny = n * USD_TO_CNY;
|
|
116
|
+
if (cny >= 1) return `¥${cny.toFixed(2)}`;
|
|
117
|
+
return `¥${cny.toFixed(4)}`;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function formatDateShort(dateStr: string): string {
|
|
121
|
+
const d = new Date(dateStr + "T00:00:00");
|
|
122
|
+
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Stat Card ──────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function StatCard({
|
|
128
|
+
title,
|
|
129
|
+
value,
|
|
130
|
+
subtitle,
|
|
131
|
+
icon,
|
|
132
|
+
trend,
|
|
133
|
+
trendLabel,
|
|
134
|
+
progress,
|
|
135
|
+
children,
|
|
136
|
+
className,
|
|
137
|
+
}: {
|
|
138
|
+
title: string;
|
|
139
|
+
value: string;
|
|
140
|
+
subtitle?: string;
|
|
141
|
+
icon: React.ReactNode;
|
|
142
|
+
trend?: number;
|
|
143
|
+
trendLabel?: string;
|
|
144
|
+
progress?: number;
|
|
145
|
+
children?: React.ReactNode;
|
|
146
|
+
className?: string;
|
|
147
|
+
}) {
|
|
148
|
+
return (
|
|
149
|
+
<div className={cn("rounded-xl border p-5", className)} style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
|
|
150
|
+
<div className="flex items-start justify-between mb-3">
|
|
151
|
+
<p className="text-xs font-medium uppercase tracking-wider" style={{ color: "var(--muted-text)" }}>{title}</p>
|
|
152
|
+
<div className="rounded-lg p-2" style={{ backgroundColor: "var(--accent-bg)" }}>
|
|
153
|
+
{icon}
|
|
154
|
+
</div>
|
|
155
|
+
</div>
|
|
156
|
+
<p className="text-2xl font-bold tracking-tight" style={{ color: "var(--page-text)" }}>{value}</p>
|
|
157
|
+
{subtitle && <p className="text-xs mt-1" style={{ color: "var(--subtle-text)" }}>{subtitle}</p>}
|
|
158
|
+
{trend !== undefined && (
|
|
159
|
+
<div className="flex items-center gap-1 mt-2">
|
|
160
|
+
{trend >= 0 ? (
|
|
161
|
+
<ArrowUp className="h-3 w-3 text-emerald-400" />
|
|
162
|
+
) : (
|
|
163
|
+
<ArrowDown className="h-3 w-3 text-red-400" />
|
|
164
|
+
)}
|
|
165
|
+
<span className={cn("text-xs font-medium", trend >= 0 ? "text-emerald-400" : "text-red-400")}>
|
|
166
|
+
{Math.abs(trend).toFixed(1)}%
|
|
167
|
+
</span>
|
|
168
|
+
{trendLabel && <span className="text-xs" style={{ color: "var(--subtle-text)" }}>{trendLabel}</span>}
|
|
169
|
+
</div>
|
|
170
|
+
)}
|
|
171
|
+
{progress !== undefined && (
|
|
172
|
+
<div className="mt-2 h-1.5 w-full rounded-full" style={{ backgroundColor: "var(--card-border)" }}>
|
|
173
|
+
<div
|
|
174
|
+
className="h-full rounded-full transition-all"
|
|
175
|
+
style={{ width: `${Math.min(progress, 100)}%`, backgroundColor: progress > 90 ? "#10b981" : "#3b82f6" }}
|
|
176
|
+
/>
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
{children}
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ─── Breakdown Row ──────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
function BreakdownRow({ label, value, total, color }: { label: string; value: number; total: number; color: string }) {
|
|
187
|
+
const pct = total > 0 ? (value / total) * 100 : 0;
|
|
188
|
+
return (
|
|
189
|
+
<div className="flex items-center justify-between py-1.5">
|
|
190
|
+
<div className="flex items-center gap-2">
|
|
191
|
+
<div className="h-2.5 w-2.5 rounded-full" style={{ backgroundColor: color }} />
|
|
192
|
+
<span className="text-xs" style={{ color: "var(--muted-text)" }}>{label}</span>
|
|
193
|
+
</div>
|
|
194
|
+
<div className="flex items-center gap-2">
|
|
195
|
+
<span className="text-xs font-medium" style={{ color: "var(--page-text)" }}>{formatTokensShort(value)}</span>
|
|
196
|
+
<span className="text-xs" style={{ color: "var(--subtle-text)" }}>({pct.toFixed(1)}%)</span>
|
|
197
|
+
</div>
|
|
198
|
+
</div>
|
|
199
|
+
);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Main Component ─────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
export function DashboardPage() {
|
|
205
|
+
const { t } = useTranslation();
|
|
206
|
+
const { currency, toggle: toggleCurrency } = useCurrency();
|
|
207
|
+
const { initialized } = useConfigStore();
|
|
208
|
+
const [range, setRange] = useState<RangeKey>("today");
|
|
209
|
+
const [customFrom, setCustomFrom] = useState("");
|
|
210
|
+
const [customTo, setCustomTo] = useState("");
|
|
211
|
+
const [tab, setTab] = useState<TabKey>("log");
|
|
212
|
+
const [data, setData] = useState<UsageRangeData | null>(null);
|
|
213
|
+
const [loading, setLoading] = useState(true);
|
|
214
|
+
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
215
|
+
const [refreshInterval, setRefreshInterval] = useState(30);
|
|
216
|
+
const [showIntervalPicker, setShowIntervalPicker] = useState(false);
|
|
217
|
+
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
|
218
|
+
|
|
219
|
+
const fetchData = useCallback(() => {
|
|
220
|
+
if (!initialized) return;
|
|
221
|
+
let url = "/api/pi/usage-range?range=" + range;
|
|
222
|
+
if (range === "custom" && customFrom) {
|
|
223
|
+
url += `&from=${customFrom}&to=${customTo || customFrom}`;
|
|
224
|
+
}
|
|
225
|
+
fetch(url)
|
|
226
|
+
.then((r) => r.json())
|
|
227
|
+
.then((d) => { setData(d); setLoading(false); setLastUpdated(new Date().toLocaleTimeString()); })
|
|
228
|
+
.catch(() => setLoading(false));
|
|
229
|
+
}, [initialized, range, customFrom, customTo]);
|
|
230
|
+
|
|
231
|
+
useEffect(() => { fetchData(); }, [fetchData]);
|
|
232
|
+
|
|
233
|
+
// Auto-refresh with configurable interval (seconds)
|
|
234
|
+
useEffect(() => {
|
|
235
|
+
if (!autoRefresh || refreshInterval <= 0) return;
|
|
236
|
+
const id = setInterval(fetchData, refreshInterval * 1000);
|
|
237
|
+
return () => clearInterval(id);
|
|
238
|
+
}, [autoRefresh, refreshInterval, fetchData]);
|
|
239
|
+
|
|
240
|
+
const today = new Date().toISOString().split("T")[0];
|
|
241
|
+
|
|
242
|
+
// Chart data: hourly for "today", daily for 7d/30d/custom
|
|
243
|
+
const rawBreakdown = range === "today" ? data?.hourlyBreakdown : data?.dailyBreakdown;
|
|
244
|
+
const chartData = (rawBreakdown ?? []).map((d: any) => ({
|
|
245
|
+
date: range === "today" ? d.hour?.slice(-5) : formatDateShort(d.date || d.hour),
|
|
246
|
+
rawDate: d.date || d.hour,
|
|
247
|
+
input: Math.round(d.input / 1000),
|
|
248
|
+
output: Math.round(d.output / 1000),
|
|
249
|
+
cacheRead: Math.round(d.cacheRead / 1000),
|
|
250
|
+
cacheWrite: Math.round(d.cacheWrite / 1000),
|
|
251
|
+
cost: parseFloat(d.cost.toFixed(4)),
|
|
252
|
+
requests: d.requests,
|
|
253
|
+
}));
|
|
254
|
+
|
|
255
|
+
return (
|
|
256
|
+
<div className="space-y-5">
|
|
257
|
+
{/* Title + Time Range Selector + Currency Toggle */}
|
|
258
|
+
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
259
|
+
<div>
|
|
260
|
+
<h1 className="text-xl font-bold" style={{ color: "var(--page-text)" }}>{t("dashboard.title")}</h1>
|
|
261
|
+
<p className="text-xs mt-0.5" style={{ color: "var(--muted-text)" }}>
|
|
262
|
+
{data ? t("dashboard.requests_count", String(data.requestLog.length), formatCost(data.totalCost, currency)) : ""}
|
|
263
|
+
{lastUpdated && <span className="ml-2">· Last updated {lastUpdated}</span>}
|
|
264
|
+
</p>
|
|
265
|
+
</div>
|
|
266
|
+
<div className="flex items-center gap-2">
|
|
267
|
+
<button
|
|
268
|
+
onClick={toggleCurrency}
|
|
269
|
+
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
|
|
270
|
+
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
|
|
271
|
+
title={currency === "USD" ? "Switch to CNY" : "Switch to USD"}
|
|
272
|
+
>
|
|
273
|
+
<DollarSignIcon className="h-3.5 w-3.5" />
|
|
274
|
+
{currency}
|
|
275
|
+
</button>
|
|
276
|
+
<button
|
|
277
|
+
onClick={() => { fetchData(); }}
|
|
278
|
+
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors hover:bg-gray-800/30"
|
|
279
|
+
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
|
|
280
|
+
title="Refresh now"
|
|
281
|
+
>
|
|
282
|
+
<RefreshCw className="h-3.5 w-3.5" />
|
|
283
|
+
</button>
|
|
284
|
+
<div className="relative">
|
|
285
|
+
<button
|
|
286
|
+
onClick={() => setShowIntervalPicker(!showIntervalPicker)}
|
|
287
|
+
onBlur={() => setTimeout(() => setShowIntervalPicker(false), 200)}
|
|
288
|
+
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
|
|
289
|
+
style={{
|
|
290
|
+
borderColor: "var(--card-border)",
|
|
291
|
+
color: autoRefresh ? "#10b981" : "var(--muted-text)",
|
|
292
|
+
backgroundColor: "var(--card-bg)",
|
|
293
|
+
}}
|
|
294
|
+
>
|
|
295
|
+
<span className="inline-block h-2 w-2 rounded-full"
|
|
296
|
+
style={{ backgroundColor: autoRefresh ? "#10b981" : "var(--subtle-text)" }} />
|
|
297
|
+
{autoRefresh ? `${refreshInterval}s` : "Off"}
|
|
298
|
+
</button>
|
|
299
|
+
{showIntervalPicker && (
|
|
300
|
+
<div
|
|
301
|
+
className="absolute right-0 top-full mt-1 z-50 w-24 rounded-xl border shadow-2xl overflow-hidden"
|
|
302
|
+
style={{
|
|
303
|
+
backgroundColor: "var(--card-bg)",
|
|
304
|
+
borderColor: "var(--card-border)",
|
|
305
|
+
}}
|
|
306
|
+
>
|
|
307
|
+
<div className="px-4 py-2 text-xs font-medium border-b" style={{ color: "var(--muted-text)", borderColor: "var(--card-border)" }}>
|
|
308
|
+
{t("dashboard.range.today")}
|
|
309
|
+
</div>
|
|
310
|
+
{[5, 10, 30, 60].map((s) => (
|
|
311
|
+
<button
|
|
312
|
+
key={s}
|
|
313
|
+
onMouseDown={(e) => {
|
|
314
|
+
e.preventDefault();
|
|
315
|
+
setRefreshInterval(s);
|
|
316
|
+
setAutoRefresh(true);
|
|
317
|
+
setShowIntervalPicker(false);
|
|
318
|
+
}}
|
|
319
|
+
className="flex w-full items-center justify-between px-4 py-2 text-xs transition-colors"
|
|
320
|
+
style={{
|
|
321
|
+
backgroundColor: refreshInterval === s && autoRefresh ? "var(--hover-bg)" : "transparent",
|
|
322
|
+
color: "var(--page-text)",
|
|
323
|
+
}}
|
|
324
|
+
>
|
|
325
|
+
<span className={refreshInterval === s && autoRefresh ? "font-medium" : ""}>{s}s</span>
|
|
326
|
+
{refreshInterval === s && autoRefresh && (
|
|
327
|
+
<svg className="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="#10b981" strokeWidth={3}>
|
|
328
|
+
<path d="M20 6L9 17l-5-5" />
|
|
329
|
+
</svg>
|
|
330
|
+
)}
|
|
331
|
+
</button>
|
|
332
|
+
))}
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
</div>
|
|
336
|
+
<div className="flex items-center gap-1 rounded-lg border p-0.5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--page-bg)" }}>
|
|
337
|
+
{RANGE_OPTIONS.map((opt) => (
|
|
338
|
+
<button
|
|
339
|
+
key={opt.key}
|
|
340
|
+
onClick={() => setRange(opt.key)}
|
|
341
|
+
className={cn(
|
|
342
|
+
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
|
343
|
+
range === opt.key
|
|
344
|
+
? "text-white"
|
|
345
|
+
: "hover:bg-gray-800/30"
|
|
346
|
+
)}
|
|
347
|
+
style={range === opt.key ? { backgroundColor: "#3b82f6", color: "#fff" } : { color: "var(--muted-text)" }}
|
|
348
|
+
>
|
|
349
|
+
{t("dashboard.range." + opt.key)}
|
|
350
|
+
</button>
|
|
351
|
+
))}
|
|
352
|
+
</div>
|
|
353
|
+
</div>
|
|
354
|
+
</div>
|
|
355
|
+
|
|
356
|
+
{/* Custom Date Picker */}
|
|
357
|
+
{range === "custom" && (
|
|
358
|
+
<div className="flex items-center gap-2">
|
|
359
|
+
<input
|
|
360
|
+
type="date"
|
|
361
|
+
value={customFrom || today}
|
|
362
|
+
onChange={(e) => setCustomFrom(e.target.value)}
|
|
363
|
+
className="rounded-lg border px-3 py-1.5 text-xs"
|
|
364
|
+
style={{ backgroundColor: "var(--input-bg)", borderColor: "var(--input-border)", color: "var(--input-text)" }}
|
|
365
|
+
/>
|
|
366
|
+
<span className="text-xs" style={{ color: "var(--muted-text)" }}>{t("dashboard.to")}</span>
|
|
367
|
+
<input
|
|
368
|
+
type="date"
|
|
369
|
+
value={customTo || today}
|
|
370
|
+
onChange={(e) => setCustomTo(e.target.value)}
|
|
371
|
+
className="rounded-lg border px-3 py-1.5 text-xs"
|
|
372
|
+
style={{ backgroundColor: "var(--input-bg)", borderColor: "var(--input-border)", color: "var(--input-text)" }}
|
|
373
|
+
/>
|
|
374
|
+
</div>
|
|
375
|
+
)}
|
|
376
|
+
|
|
377
|
+
{/* Loading State */}
|
|
378
|
+
{loading && (
|
|
379
|
+
<div className="flex items-center justify-center h-64">
|
|
380
|
+
<div className="h-8 w-8 animate-spin rounded-full border-2 border-gray-600 border-t-blue-500" />
|
|
381
|
+
</div>
|
|
382
|
+
)}
|
|
383
|
+
|
|
384
|
+
{!loading && data && (
|
|
385
|
+
<>
|
|
386
|
+
{/* Overview Cards */}
|
|
387
|
+
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
388
|
+
<StatCard
|
|
389
|
+
title={t("dashboard.total_tokens")}
|
|
390
|
+
value={data.totalTokens.toLocaleString("en-US")}
|
|
391
|
+
icon={<Activity className="h-4 w-4" style={{ color: "#3b82f6" }} />}
|
|
392
|
+
subtitle={`≈ ${formatTokensShort(data.totalTokens)}`}
|
|
393
|
+
>
|
|
394
|
+
<div className="mt-3 space-y-0.5 border-t pt-3" style={{ borderColor: "var(--card-border)" }}>
|
|
395
|
+
<BreakdownRow label={t("dashboard.input")} value={data.totalInput} total={data.totalTokens} color="#3b82f6" />
|
|
396
|
+
<BreakdownRow label={t("dashboard.output")} value={data.totalOutput} total={data.totalTokens} color="#10b981" />
|
|
397
|
+
<BreakdownRow label={t("dashboard.cache_create")} value={data.totalCacheWrite} total={data.totalTokens} color="#f59e0b" />
|
|
398
|
+
<BreakdownRow label={t("dashboard.cache_hit")} value={data.totalCacheRead} total={data.totalTokens} color="#8b5cf6" />
|
|
399
|
+
</div>
|
|
400
|
+
</StatCard>
|
|
401
|
+
|
|
402
|
+
<StatCard
|
|
403
|
+
title={t("dashboard.total_requests")}
|
|
404
|
+
value={formatNumber(data.totalRequests)}
|
|
405
|
+
icon={<BarChart3 className="h-4 w-4" style={{ color: "#10b981" }} />}
|
|
406
|
+
subtitle={t("dashboard.api_calls")}
|
|
407
|
+
/>
|
|
408
|
+
|
|
409
|
+
<StatCard
|
|
410
|
+
title={t("dashboard.total_cost")}
|
|
411
|
+
value={formatCost(data.totalCost)}
|
|
412
|
+
icon={<DollarSign className="h-4 w-4" style={{ color: "#f59e0b" }} />}
|
|
413
|
+
subtitle={`${currency === "CNY" ? `¥${(data.totalCost * USD_TO_CNY).toFixed(4)}` : `$${data.totalCost.toFixed(4)}`} ${currency}`}
|
|
414
|
+
/>
|
|
415
|
+
|
|
416
|
+
<StatCard
|
|
417
|
+
title={t("dashboard.cache_hit_rate")}
|
|
418
|
+
value={`${data.cacheHitRate}%`}
|
|
419
|
+
icon={<Database className="h-4 w-4" style={{ color: "#8b5cf6" }} />}
|
|
420
|
+
progress={data.cacheHitRate}
|
|
421
|
+
/>
|
|
422
|
+
</div>
|
|
423
|
+
|
|
424
|
+
{/* Usage Trend Chart */}
|
|
425
|
+
<div className="rounded-xl border p-5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
|
|
426
|
+
<h3 className="text-sm font-semibold mb-1" style={{ color: "var(--page-text)" }}>{t("dashboard.usage_trend")}</h3>
|
|
427
|
+
<p className="text-xs mb-4" style={{ color: "var(--muted-text)" }}>
|
|
428
|
+
{range === "today" ? t("dashboard.range.today") : `${formatDateShort(chartData[0]?.rawDate || "")} - ${formatDateShort(chartData[chartData.length - 1]?.rawDate || "")}`}
|
|
429
|
+
</p>
|
|
430
|
+
<ResponsiveContainer width="100%" height={300}>
|
|
431
|
+
<AreaChart data={chartData}>
|
|
432
|
+
<CartesianGrid strokeDasharray="3 3" stroke="var(--card-border)" />
|
|
433
|
+
<XAxis dataKey="date" tick={{ fontSize: 11, fill: "var(--muted-text)" }} axisLine={false} tickLine={false} />
|
|
434
|
+
<YAxis yAxisId="tokens" tick={{ fontSize: 11, fill: "var(--muted-text)" }} axisLine={false} tickLine={false} label={{ value: t("dashboard.tokens_k"), angle: -90, position: "insideLeft", style: { fill: "var(--muted-text)", fontSize: 11 } }} />
|
|
435
|
+
<YAxis yAxisId="cost" orientation="right" tick={{ fontSize: 11, fill: "var(--muted-text)" }} axisLine={false} tickLine={false} label={{ value: t("dashboard.cost_label"), angle: 90, position: "insideRight", style: { fill: "var(--muted-text)", fontSize: 11 } }} />
|
|
436
|
+
<Tooltip
|
|
437
|
+
contentStyle={{
|
|
438
|
+
backgroundColor: "var(--card-bg)",
|
|
439
|
+
border: "1px solid var(--card-border)",
|
|
440
|
+
borderRadius: "8px",
|
|
441
|
+
color: "var(--page-text)",
|
|
442
|
+
fontSize: "12px",
|
|
443
|
+
}}
|
|
444
|
+
/>
|
|
445
|
+
<Area yAxisId="tokens" type="monotone" dataKey="input" stroke={CHART_LINE_COLORS.input} fill="none" strokeWidth={2} dot={false} name={t("dashboard.input")} />
|
|
446
|
+
<Area yAxisId="tokens" type="monotone" dataKey="output" stroke={CHART_LINE_COLORS.output} fill="none" strokeWidth={2} dot={false} name={t("dashboard.output")} />
|
|
447
|
+
<Area yAxisId="tokens" type="monotone" dataKey="cacheRead" stroke={CHART_LINE_COLORS.cacheRead} fill="none" strokeWidth={2} strokeDasharray="4 2" dot={false} name={t("dashboard.cache_hit")} />
|
|
448
|
+
<Area yAxisId="tokens" type="monotone" dataKey="cacheWrite" stroke={CHART_LINE_COLORS.cacheWrite} fill="none" strokeWidth={2} strokeDasharray="2 2" dot={false} name={t("dashboard.cache_create")} />
|
|
449
|
+
<Area yAxisId="cost" type="monotone" dataKey="cost" stroke={CHART_LINE_COLORS.cost} fill="none" strokeWidth={2} strokeDasharray="6 3" dot={false} name={t("dashboard.cost")} />
|
|
450
|
+
<Legend
|
|
451
|
+
wrapperStyle={{ fontSize: "11px", color: "var(--muted-text)", paddingTop: "8px" }}
|
|
452
|
+
/>
|
|
453
|
+
</AreaChart>
|
|
454
|
+
</ResponsiveContainer>
|
|
455
|
+
</div>
|
|
456
|
+
|
|
457
|
+
{/* Tabs: Request Log / Provider / Model */}
|
|
458
|
+
<div className="rounded-xl border overflow-hidden" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
|
|
459
|
+
{/* Tab Header */}
|
|
460
|
+
<div className="flex border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
461
|
+
{([
|
|
462
|
+
{ key: "log" as TabKey, label: "dashboard.request_log" },
|
|
463
|
+
{ key: "provider" as TabKey, label: "dashboard.provider_stats" },
|
|
464
|
+
{ key: "model" as TabKey, label: "dashboard.model_stats" },
|
|
465
|
+
]).map((tabItem) => (
|
|
466
|
+
<button
|
|
467
|
+
key={tabItem.key}
|
|
468
|
+
onClick={() => setTab(tabItem.key)}
|
|
469
|
+
className={cn(
|
|
470
|
+
"px-5 py-3 text-xs font-medium border-b-2 transition-colors",
|
|
471
|
+
tab === tabItem.key ? "" : "border-transparent"
|
|
472
|
+
)}
|
|
473
|
+
style={{
|
|
474
|
+
color: tab === tabItem.key ? "#3b82f6" : "var(--muted-text)",
|
|
475
|
+
borderBottomColor: tab === tabItem.key ? "#3b82f6" : "transparent",
|
|
476
|
+
}}
|
|
477
|
+
>
|
|
478
|
+
{t(tabItem.label)}
|
|
479
|
+
</button>
|
|
480
|
+
))}
|
|
481
|
+
</div>
|
|
482
|
+
|
|
483
|
+
{/* Tab Content */}
|
|
484
|
+
<div className="overflow-x-auto">
|
|
485
|
+
{tab === "log" && (
|
|
486
|
+
<table className="w-full text-xs">
|
|
487
|
+
<thead>
|
|
488
|
+
<tr className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
489
|
+
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.time")}</th>
|
|
490
|
+
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.provider")}</th>
|
|
491
|
+
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.model")}</th>
|
|
492
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.input")}</th>
|
|
493
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.output")}</th>
|
|
494
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.cost")}</th>
|
|
495
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.requests")}</th>
|
|
496
|
+
<th className="px-4 py-3 text-center font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.status")}</th>
|
|
497
|
+
</tr>
|
|
498
|
+
</thead>
|
|
499
|
+
<tbody>
|
|
500
|
+
{data.requestLog.length === 0 ? (
|
|
501
|
+
<tr>
|
|
502
|
+
<td colSpan={8} className="px-4 py-8 text-center" style={{ color: "var(--subtle-text)" }}>
|
|
503
|
+
{t("dashboard.no_data")}
|
|
504
|
+
</td>
|
|
505
|
+
</tr>
|
|
506
|
+
) : (
|
|
507
|
+
data.requestLog.slice(0, 100).map((r, i) => (
|
|
508
|
+
<tr key={i} className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
509
|
+
<td className="px-4 py-2.5 whitespace-nowrap" style={{ color: "var(--page-text)" }}>{r.timestamp}</td>
|
|
510
|
+
<td className="px-4 py-2.5" style={{ color: "var(--page-text)" }}>{r.providerId}</td>
|
|
511
|
+
<td className="px-4 py-2.5" style={{ color: "var(--page-text)" }}>{r.modelId}</td>
|
|
512
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(r.input)}</td>
|
|
513
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(r.output)}</td>
|
|
514
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{currency === "CNY" ? `¥${(r.cost * USD_TO_CNY).toFixed(4)}` : formatCostShort(r.cost)}</td>
|
|
515
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{r.requests}</td>
|
|
516
|
+
<td className="px-4 py-2.5 text-center">
|
|
517
|
+
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium text-emerald-400" style={{ backgroundColor: "rgba(16,185,129,0.1)" }}>
|
|
518
|
+
200
|
|
519
|
+
</span>
|
|
520
|
+
</td>
|
|
521
|
+
</tr>
|
|
522
|
+
))
|
|
523
|
+
)}
|
|
524
|
+
</tbody>
|
|
525
|
+
</table>
|
|
526
|
+
)}
|
|
527
|
+
|
|
528
|
+
{tab === "provider" && (
|
|
529
|
+
<table className="w-full text-xs">
|
|
530
|
+
<thead>
|
|
531
|
+
<tr className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
532
|
+
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.provider")}</th>
|
|
533
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>Tokens</th>
|
|
534
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.input")}</th>
|
|
535
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.output")}</th>
|
|
536
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.cost")}</th>
|
|
537
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.requests")}</th>
|
|
538
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>Models</th>
|
|
539
|
+
</tr>
|
|
540
|
+
</thead>
|
|
541
|
+
<tbody>
|
|
542
|
+
{data.providerStats.length === 0 ? (
|
|
543
|
+
<tr><td colSpan={7} className="px-4 py-8 text-center" style={{ color: "var(--subtle-text)" }}>{t("dashboard.no_data")}</td></tr>
|
|
544
|
+
) : (
|
|
545
|
+
data.providerStats.map((p, i) => (
|
|
546
|
+
<tr key={i} className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
547
|
+
<td className="px-4 py-2.5 font-medium" style={{ color: "var(--page-text)" }}>{p.providerId}</td>
|
|
548
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(p.totalTokens)}</td>
|
|
549
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(p.totalInput)}</td>
|
|
550
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(p.totalOutput)}</td>
|
|
551
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{currency === "CNY" ? `¥${(p.totalCost * USD_TO_CNY).toFixed(4)}` : formatCostShort(p.totalCost)}</td>
|
|
552
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{p.totalRequests}</td>
|
|
553
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{p.modelCount}</td>
|
|
554
|
+
</tr>
|
|
555
|
+
))
|
|
556
|
+
)}
|
|
557
|
+
</tbody>
|
|
558
|
+
</table>
|
|
559
|
+
)}
|
|
560
|
+
|
|
561
|
+
{tab === "model" && (
|
|
562
|
+
<table className="w-full text-xs">
|
|
563
|
+
<thead>
|
|
564
|
+
<tr className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
565
|
+
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.model")}</th>
|
|
566
|
+
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.provider")}</th>
|
|
567
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>Tokens</th>
|
|
568
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.input")}</th>
|
|
569
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.output")}</th>
|
|
570
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.cost")}</th>
|
|
571
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.requests")}</th>
|
|
572
|
+
</tr>
|
|
573
|
+
</thead>
|
|
574
|
+
<tbody>
|
|
575
|
+
{data.modelStats.length === 0 ? (
|
|
576
|
+
<tr><td colSpan={7} className="px-4 py-8 text-center" style={{ color: "var(--subtle-text)" }}>{t("dashboard.no_data")}</td></tr>
|
|
577
|
+
) : (
|
|
578
|
+
data.modelStats.map((m, i) => (
|
|
579
|
+
<tr key={i} className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
580
|
+
<td className="px-4 py-2.5 font-medium" style={{ color: "var(--page-text)" }}>{m.modelId}</td>
|
|
581
|
+
<td className="px-4 py-2.5" style={{ color: "var(--page-text)" }}>{m.providerId}</td>
|
|
582
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(m.totalTokens)}</td>
|
|
583
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(m.totalInput)}</td>
|
|
584
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(m.totalOutput)}</td>
|
|
585
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{currency === "CNY" ? `¥${(m.totalCost * USD_TO_CNY).toFixed(4)}` : formatCostShort(m.totalCost)}</td>
|
|
586
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{m.totalRequests}</td>
|
|
587
|
+
</tr>
|
|
588
|
+
))
|
|
589
|
+
)}
|
|
590
|
+
</tbody>
|
|
591
|
+
</table>
|
|
592
|
+
)}
|
|
593
|
+
</div>
|
|
594
|
+
</div>
|
|
595
|
+
</>
|
|
596
|
+
)}
|
|
597
|
+
</div>
|
|
598
|
+
);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
function TablePlaceholder({ message }: { message: string }) {
|
|
602
|
+
return (
|
|
603
|
+
<div className="flex items-center justify-center py-12">
|
|
604
|
+
<p className="text-sm" style={{ color: "var(--subtle-text)" }}>{message}</p>
|
|
605
|
+
</div>
|
|
606
|
+
);
|
|
607
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { Outlet } from "react-router-dom";
|
|
2
|
+
import { Sidebar } from "./Sidebar";
|
|
3
|
+
|
|
4
|
+
export function AppShell() {
|
|
5
|
+
return (
|
|
6
|
+
<div className="flex h-screen overflow-hidden">
|
|
7
|
+
<Sidebar />
|
|
8
|
+
<main
|
|
9
|
+
className="flex-1 overflow-y-auto"
|
|
10
|
+
style={{ backgroundColor: "var(--page-bg)" }}
|
|
11
|
+
>
|
|
12
|
+
<div className="mx-auto max-w-7xl px-8 py-8">
|
|
13
|
+
<Outlet />
|
|
14
|
+
</div>
|
|
15
|
+
</main>
|
|
16
|
+
</div>
|
|
17
|
+
);
|
|
18
|
+
}
|