@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.
Files changed (41) hide show
  1. package/README.ja.md +137 -0
  2. package/README.md +277 -0
  3. package/README.zh-CN.md +176 -0
  4. package/index.html +13 -0
  5. package/package.json +44 -0
  6. package/pi-package/index.ts +100 -0
  7. package/pi-package/skills/pi-web-switch/SKILL.md +60 -0
  8. package/public/pi.svg +4 -0
  9. package/server/pi-reader.ts +678 -0
  10. package/src/App.tsx +25 -0
  11. package/src/components/dashboard/DashboardPage.tsx +607 -0
  12. package/src/components/layout/AppShell.tsx +18 -0
  13. package/src/components/layout/Sidebar.tsx +116 -0
  14. package/src/components/models/ModelsPage.tsx +570 -0
  15. package/src/components/providers/ProvidersPage.tsx +466 -0
  16. package/src/components/sessions/MemoryPage.tsx +177 -0
  17. package/src/components/sessions/SessionsPage.tsx +347 -0
  18. package/src/components/settings/SettingsPage.tsx +351 -0
  19. package/src/components/ui/Badge.tsx +29 -0
  20. package/src/components/ui/EmptyState.tsx +20 -0
  21. package/src/components/ui/Modal.tsx +41 -0
  22. package/src/components/ui/StatCard.tsx +37 -0
  23. package/src/data/builtin-providers.ts +148 -0
  24. package/src/data/mock-config.ts +261 -0
  25. package/src/data/mock-usage.ts +153 -0
  26. package/src/index.css +217 -0
  27. package/src/lib/config.ts +56 -0
  28. package/src/lib/currency.ts +48 -0
  29. package/src/lib/i18n.tsx +98 -0
  30. package/src/lib/translations/en.ts +168 -0
  31. package/src/lib/translations/index.ts +14 -0
  32. package/src/lib/translations/ja.ts +158 -0
  33. package/src/lib/translations/zh-CN.ts +158 -0
  34. package/src/lib/translations/zh-TW.ts +158 -0
  35. package/src/lib/utils.ts +51 -0
  36. package/src/main.tsx +106 -0
  37. package/src/store/config-store.ts +459 -0
  38. package/src/types/index.ts +187 -0
  39. package/src/vite-env.d.ts +1 -0
  40. package/tsconfig.json +24 -0
  41. 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
+ }