@raingor/pi-web-switch 0.4.2 → 0.4.4
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/dist/index.html +18 -0
- package/index.html +2 -2
- package/package.json +7 -38
- package/public/apple-touch-icon.png +0 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/manifest.webmanifest +2 -2
- package/public/pi.svg +41 -6
- package/public/sw.js +51 -51
- package/server/pi-reader.ts +353 -237
- package/src/App.tsx +2 -0
- package/src/components/dashboard/DashboardPage.tsx +341 -56
- package/src/components/layout/AppShell.tsx +45 -13
- package/src/components/layout/Sidebar.tsx +78 -85
- package/src/components/providers/ProvidersModelsPage.tsx +125 -131
- package/src/components/sessions/MemoryPage.tsx +34 -13
- package/src/components/sessions/SessionsPage.tsx +20 -40
- package/src/components/settings/SettingsPage.tsx +6 -1
- package/src/components/speedtest/ModelSpeedTestPage.tsx +429 -0
- package/src/components/ui/EmptyState.tsx +7 -6
- package/src/components/ui/Modal.tsx +33 -25
- package/src/components/ui/StatCard.tsx +10 -13
- package/src/data/builtin-providers.test.ts +109 -0
- package/src/data/builtin-providers.ts +67 -44
- package/src/data/model-catalog.test.ts +122 -0
- package/src/data/model-catalog.ts +697 -478
- package/src/index.css +624 -210
- package/src/lib/translations/en.ts +88 -14
- package/src/lib/translations/ja.ts +88 -14
- package/src/lib/translations/zh-CN.ts +88 -14
- package/src/lib/translations/zh-TW.ts +87 -14
- package/src/main.tsx +97 -44
- package/src/types/index.ts +0 -1
- package/vite.config.ts +65 -164
- package/dist-electron/main/main.cjs +0 -2453
- package/src/data/mock-config.ts +0 -247
- package/src/data/mock-usage.ts +0 -151
package/src/App.tsx
CHANGED
|
@@ -6,6 +6,7 @@ import { MemoryPage } from "@/components/sessions/MemoryPage";
|
|
|
6
6
|
import { ProvidersModelsPage } from "@/components/providers/ProvidersModelsPage";
|
|
7
7
|
import { SubagentsPage } from "@/components/subagents/SubagentsPage";
|
|
8
8
|
import { SettingsPage } from "@/components/settings/SettingsPage";
|
|
9
|
+
import { ModelSpeedTestPage } from "@/components/speedtest/ModelSpeedTestPage";
|
|
9
10
|
|
|
10
11
|
export default function App() {
|
|
11
12
|
return (
|
|
@@ -19,6 +20,7 @@ export default function App() {
|
|
|
19
20
|
<Route path="/models" element={<ProvidersModelsPage />} />
|
|
20
21
|
<Route path="/subagents" element={<SubagentsPage />} />
|
|
21
22
|
<Route path="/settings" element={<SettingsPage />} />
|
|
23
|
+
<Route path="/speed-test" element={<ModelSpeedTestPage />} />
|
|
22
24
|
</Route>
|
|
23
25
|
</Routes>
|
|
24
26
|
</BrowserRouter>
|
|
@@ -1,14 +1,15 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback } from "react";
|
|
1
|
+
import { useState, useEffect, useCallback, useMemo } from "react";
|
|
2
2
|
import { useConfigStore } from "@/store/config-store";
|
|
3
3
|
import { useTranslation } from "@/lib/i18n";
|
|
4
4
|
import { useCurrency } from "@/lib/currency";
|
|
5
5
|
import { formatCost, formatNumber, cn, USD_TO_CNY } from "@/lib/utils";
|
|
6
6
|
import {
|
|
7
7
|
Activity, DollarSign, BarChart3, ArrowUp, ArrowDown, Database, DollarSignIcon, RefreshCw, Download,
|
|
8
|
+
Gauge, Layers3, Clock3, Zap, CircleDollarSign, Cpu, PieChart as PieChartIcon,
|
|
8
9
|
} from "lucide-react";
|
|
9
10
|
import {
|
|
10
11
|
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
|
11
|
-
CartesianGrid, Legend,
|
|
12
|
+
CartesianGrid, Legend, BarChart, Bar, PieChart, Pie, Cell,
|
|
12
13
|
} from "recharts";
|
|
13
14
|
|
|
14
15
|
// ─── Types ──────────────────────────────────────────────
|
|
@@ -70,21 +71,21 @@ interface UsageRangeData {
|
|
|
70
71
|
notice?: "no-config" | "api-error";
|
|
71
72
|
}
|
|
72
73
|
|
|
73
|
-
type SourceKey = "
|
|
74
|
+
type SourceKey = "pi" | "chatgpt";
|
|
74
75
|
type RangeKey = "today" | "7d" | "30d" | "custom";
|
|
75
76
|
type TabKey = "log" | "provider" | "model";
|
|
76
77
|
type SortDir = "asc" | "desc";
|
|
77
78
|
|
|
78
79
|
const RANGE_KEYS: RangeKey[] = ["today", "7d", "30d", "custom"];
|
|
79
80
|
|
|
80
|
-
const COLORS = ["#
|
|
81
|
+
const COLORS = ["#00d8ff", "#9ef01a", "#ffb84d", "#9f8cff", "#ff5c7a"];
|
|
81
82
|
|
|
82
83
|
const CHART_LINE_COLORS: Record<string, string> = {
|
|
83
|
-
input: "#
|
|
84
|
-
output: "#
|
|
85
|
-
cacheRead: "#
|
|
86
|
-
cacheWrite: "#
|
|
87
|
-
cost: "#
|
|
84
|
+
input: "#00d8ff",
|
|
85
|
+
output: "#9ef01a",
|
|
86
|
+
cacheRead: "#9f8cff",
|
|
87
|
+
cacheWrite: "#ffb84d",
|
|
88
|
+
cost: "#ff5c7a",
|
|
88
89
|
};
|
|
89
90
|
|
|
90
91
|
const LOG_PAGE_SIZE = 20;
|
|
@@ -199,7 +200,7 @@ function StatCard({
|
|
|
199
200
|
className?: string;
|
|
200
201
|
}) {
|
|
201
202
|
return (
|
|
202
|
-
<div className={cn("
|
|
203
|
+
<div className={cn("tech-panel dashboard-stat-card p-5", className)} style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
|
|
203
204
|
<div className="flex items-start justify-between mb-3">
|
|
204
205
|
<p className="text-xs font-medium uppercase tracking-wider" style={{ color: "var(--muted-text)" }}>{title}</p>
|
|
205
206
|
<div className="rounded-lg p-2" style={{ backgroundColor: "var(--accent-bg)" }}>
|
|
@@ -269,7 +270,7 @@ function SortableTh({
|
|
|
269
270
|
return (
|
|
270
271
|
<th
|
|
271
272
|
className="px-4 py-3 text-right font-medium cursor-pointer select-none"
|
|
272
|
-
style={{ color: active ? "
|
|
273
|
+
style={{ color: active ? "var(--signal-cyan)" : "var(--muted-text)" }}
|
|
273
274
|
onClick={() => onSort(sortKey)}
|
|
274
275
|
>
|
|
275
276
|
<span className="inline-flex items-center gap-0.5">
|
|
@@ -280,6 +281,67 @@ function SortableTh({
|
|
|
280
281
|
);
|
|
281
282
|
}
|
|
282
283
|
|
|
284
|
+
// ─── Analytics Components ───────────────────────────────
|
|
285
|
+
|
|
286
|
+
function AnalyticsMetric({
|
|
287
|
+
label,
|
|
288
|
+
value,
|
|
289
|
+
detail,
|
|
290
|
+
icon,
|
|
291
|
+
accent = "var(--signal-cyan)",
|
|
292
|
+
}: {
|
|
293
|
+
label: string;
|
|
294
|
+
value: string;
|
|
295
|
+
detail: string;
|
|
296
|
+
icon: React.ReactNode;
|
|
297
|
+
accent?: string;
|
|
298
|
+
}) {
|
|
299
|
+
return (
|
|
300
|
+
<div className="analytics-metric">
|
|
301
|
+
<div className="analytics-metric-icon" style={{ color: accent, borderColor: `${accent}38`, backgroundColor: `${accent}12` }}>
|
|
302
|
+
{icon}
|
|
303
|
+
</div>
|
|
304
|
+
<div className="min-w-0">
|
|
305
|
+
<p className="analytics-metric-label">{label}</p>
|
|
306
|
+
<p className="analytics-metric-value">{value}</p>
|
|
307
|
+
<p className="analytics-metric-detail">{detail}</p>
|
|
308
|
+
</div>
|
|
309
|
+
</div>
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
function DistributionRow({
|
|
314
|
+
name,
|
|
315
|
+
meta,
|
|
316
|
+
value,
|
|
317
|
+
percentage,
|
|
318
|
+
color,
|
|
319
|
+
valueLabel,
|
|
320
|
+
}: {
|
|
321
|
+
name: string;
|
|
322
|
+
meta: string;
|
|
323
|
+
value: number;
|
|
324
|
+
percentage: number;
|
|
325
|
+
color: string;
|
|
326
|
+
valueLabel: string;
|
|
327
|
+
}) {
|
|
328
|
+
return (
|
|
329
|
+
<div className="distribution-row">
|
|
330
|
+
<div className="distribution-row-head">
|
|
331
|
+
<div className="min-w-0">
|
|
332
|
+
<span className="distribution-name">{name}</span>
|
|
333
|
+
<span className="distribution-meta">{meta}</span>
|
|
334
|
+
</div>
|
|
335
|
+
<span className="distribution-value">{valueLabel}</span>
|
|
336
|
+
</div>
|
|
337
|
+
<div className="distribution-track">
|
|
338
|
+
<span style={{ width: `${Math.max(percentage, percentage > 0 ? 1.5 : 0)}%`, backgroundColor: color }} />
|
|
339
|
+
</div>
|
|
340
|
+
<span className="distribution-percent">{percentage.toFixed(1)}%</span>
|
|
341
|
+
</div>
|
|
342
|
+
);
|
|
343
|
+
}
|
|
344
|
+
|
|
283
345
|
// ─── Main Component ─────────────────────────────────────
|
|
284
346
|
|
|
285
347
|
export function DashboardPage() {
|
|
@@ -307,16 +369,9 @@ export function DashboardPage() {
|
|
|
307
369
|
|
|
308
370
|
const fetchData = useCallback((force = false) => {
|
|
309
371
|
if (!initialized || customInvalid) return;
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
else if (source === "claude") baseUrl = "/api/pi/claude-usage-range";
|
|
314
|
-
else if (source === "codex") baseUrl = "/api/pi/codex-usage-range";
|
|
315
|
-
else if (source === "opencode") baseUrl = "/api/pi/opencode-usage-range";
|
|
316
|
-
else if (source === "gemini") baseUrl = "/api/pi/gemini-usage-range";
|
|
317
|
-
else if (source === "grok") baseUrl = "/api/pi/grok-usage-range";
|
|
318
|
-
else if (source === "atomcode") baseUrl = "/api/pi/atomcode-usage-range";
|
|
319
|
-
else if (source === "copilot") baseUrl = "/api/pi/copilot-usage-range";
|
|
372
|
+
const baseUrl = source === "chatgpt"
|
|
373
|
+
? "/api/pi/chatgpt-usage-range"
|
|
374
|
+
: "/api/pi/usage-range";
|
|
320
375
|
// force=true adds refresh=1 so the API rescan bypasses its 30s session cache
|
|
321
376
|
let url = `${baseUrl}?range=${range}${force ? "&refresh=1" : ""}`;
|
|
322
377
|
if (range === "custom" && customFrom) {
|
|
@@ -391,6 +446,65 @@ export function DashboardPage() {
|
|
|
391
446
|
const currentLogPage = Math.min(logPage, totalLogPages);
|
|
392
447
|
const pagedLog = (data?.requestLog ?? []).slice((currentLogPage - 1) * LOG_PAGE_SIZE, currentLogPage * LOG_PAGE_SIZE);
|
|
393
448
|
|
|
449
|
+
const analytics = useMemo(() => {
|
|
450
|
+
const current = data ?? {
|
|
451
|
+
totalTokens: 0,
|
|
452
|
+
totalInput: 0,
|
|
453
|
+
totalOutput: 0,
|
|
454
|
+
totalCacheRead: 0,
|
|
455
|
+
totalCacheWrite: 0,
|
|
456
|
+
totalCost: 0,
|
|
457
|
+
totalRequests: 0,
|
|
458
|
+
cacheHitRate: 0,
|
|
459
|
+
dailyBreakdown: [],
|
|
460
|
+
hourlyBreakdown: [],
|
|
461
|
+
requestLog: [],
|
|
462
|
+
providerStats: [],
|
|
463
|
+
modelStats: [],
|
|
464
|
+
};
|
|
465
|
+
const totalTokens = Math.max(current.totalTokens, 0);
|
|
466
|
+
const totalRequests = Math.max(current.totalRequests, 0);
|
|
467
|
+
const cacheTokens = current.totalCacheRead + current.totalCacheWrite;
|
|
468
|
+
const activitySource = range === "today" ? current.hourlyBreakdown : current.dailyBreakdown;
|
|
469
|
+
const peak = activitySource.reduce<{ label: string; requests: number; tokens: number } | null>((best, row: any) => {
|
|
470
|
+
const requests = row.requests ?? 0;
|
|
471
|
+
const tokens = (row.input ?? 0) + (row.output ?? 0) + (row.cacheRead ?? 0) + (row.cacheWrite ?? 0);
|
|
472
|
+
const label = range === "today" ? String(row.hour ?? "").slice(-5) : formatDateShort(row.date || row.hour || "");
|
|
473
|
+
if (!best || requests > best.requests || (requests === best.requests && tokens > best.tokens)) {
|
|
474
|
+
return { label, requests, tokens };
|
|
475
|
+
}
|
|
476
|
+
return best;
|
|
477
|
+
}, null);
|
|
478
|
+
const providers = current.providerStats.filter((provider) => provider.totalRequests > 0).sort((a, b) => b.totalTokens - a.totalTokens);
|
|
479
|
+
const models = current.modelStats.filter((model) => model.totalRequests > 0).sort((a, b) => b.totalTokens - a.totalTokens);
|
|
480
|
+
const providerTokenTotal = providers.reduce((sum, provider) => sum + provider.totalTokens, 0);
|
|
481
|
+
const composition = [
|
|
482
|
+
{ key: "input", value: current.totalInput, color: CHART_LINE_COLORS.input },
|
|
483
|
+
{ key: "output", value: current.totalOutput, color: CHART_LINE_COLORS.output },
|
|
484
|
+
{ key: "cacheRead", value: current.totalCacheRead, color: CHART_LINE_COLORS.cacheRead },
|
|
485
|
+
{ key: "cacheWrite", value: current.totalCacheWrite, color: CHART_LINE_COLORS.cacheWrite },
|
|
486
|
+
];
|
|
487
|
+
return {
|
|
488
|
+
totalTokens,
|
|
489
|
+
avgTokens: totalRequests > 0 ? totalTokens / totalRequests : 0,
|
|
490
|
+
avgCost: totalRequests > 0 ? current.totalCost / totalRequests : 0,
|
|
491
|
+
cacheTokens,
|
|
492
|
+
cacheShare: totalTokens > 0 ? (cacheTokens / totalTokens) * 100 : 0,
|
|
493
|
+
activeProviders: providers.length,
|
|
494
|
+
activeModels: models.length,
|
|
495
|
+
peak,
|
|
496
|
+
providers,
|
|
497
|
+
models,
|
|
498
|
+
providerTokenTotal,
|
|
499
|
+
composition,
|
|
500
|
+
activity: activitySource.map((row: any) => ({
|
|
501
|
+
label: range === "today" ? String(row.hour ?? "").slice(-5) : formatDateShort(row.date || row.hour || ""),
|
|
502
|
+
requests: row.requests ?? 0,
|
|
503
|
+
tokens: Math.round(((row.input ?? 0) + (row.output ?? 0) + (row.cacheRead ?? 0) + (row.cacheWrite ?? 0)) / 1000),
|
|
504
|
+
})),
|
|
505
|
+
};
|
|
506
|
+
}, [data, range]);
|
|
507
|
+
|
|
394
508
|
const fmtCostCell = (v: number) => (currency === "CNY" ? `¥${(v * USD_TO_CNY).toFixed(4)}` : formatCostShort(v));
|
|
395
509
|
|
|
396
510
|
const toggleSort = (setter: typeof setProviderSort) => (key: string) =>
|
|
@@ -414,39 +528,11 @@ export function DashboardPage() {
|
|
|
414
528
|
};
|
|
415
529
|
|
|
416
530
|
return (
|
|
417
|
-
<div className="space-y-5">
|
|
418
|
-
{/* Source Selector: Pi / Cindy-Pi */}
|
|
419
|
-
<div className="flex items-center gap-1 rounded-lg border p-0.5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--page-bg)" }}>
|
|
420
|
-
{([
|
|
421
|
-
{ key: "all" as SourceKey, label: "dashboard.source_all", icon: "📊" },
|
|
422
|
-
{ key: "pi" as SourceKey, label: "dashboard.source_pi", icon: "🖥" },
|
|
423
|
-
{ key: "cindy-pi" as SourceKey, label: "dashboard.source_cindy_pi", icon: "🤖" },
|
|
424
|
-
{ key: "claude" as SourceKey, label: "dashboard.source_claude", icon: "🧠" },
|
|
425
|
-
{ key: "codex" as SourceKey, label: "dashboard.source_codex", icon: "⚡" },
|
|
426
|
-
{ key: "opencode" as SourceKey, label: "dashboard.source_opencode", icon: "🔷" },
|
|
427
|
-
{ key: "gemini" as SourceKey, label: "dashboard.source_gemini", icon: "✨" },
|
|
428
|
-
{ key: "grok" as SourceKey, label: "dashboard.source_grok", icon: "🌀" },
|
|
429
|
-
{ key: "atomcode" as SourceKey, label: "dashboard.source_atomcode", icon: "⚛️" },
|
|
430
|
-
{ key: "copilot" as SourceKey, label: "dashboard.source_copilot", icon: "🐙" },
|
|
431
|
-
]).map((s) => (
|
|
432
|
-
<button
|
|
433
|
-
key={s.key}
|
|
434
|
-
onClick={() => setSource(s.key)}
|
|
435
|
-
className={cn(
|
|
436
|
-
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors flex items-center gap-1.5",
|
|
437
|
-
source === s.key ? "text-white" : "hover:bg-gray-800/30"
|
|
438
|
-
)}
|
|
439
|
-
style={source === s.key ? { backgroundColor: "#3b82f6", color: "#fff" } : { color: "var(--muted-text)" }}
|
|
440
|
-
>
|
|
441
|
-
<span>{s.icon}</span>
|
|
442
|
-
<span>{t(s.label)}</span>
|
|
443
|
-
</button>
|
|
444
|
-
))}
|
|
445
|
-
</div>
|
|
446
|
-
|
|
531
|
+
<div className="dashboard-page space-y-5">
|
|
447
532
|
{/* Title + Time Range Selector + Currency Toggle */}
|
|
448
|
-
<div className="flex items-center justify-between flex-wrap gap-3">
|
|
533
|
+
<div className="dashboard-command-header flex items-center justify-between flex-wrap gap-3">
|
|
449
534
|
<div>
|
|
535
|
+
<div className="page-kicker"><span /> TELEMETRY // LIVE OPERATIONS</div>
|
|
450
536
|
<h1 className="text-xl font-bold" style={{ color: "var(--page-text)" }}>{t("dashboard.title")}</h1>
|
|
451
537
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-text)" }}>
|
|
452
538
|
{data ? t("dashboard.requests_count", String(data.requestLog.length), formatCost(data.totalCost, currency)) : ""}
|
|
@@ -555,6 +641,26 @@ export function DashboardPage() {
|
|
|
555
641
|
</div>
|
|
556
642
|
</div>
|
|
557
643
|
|
|
644
|
+
<div className="dashboard-source-strip tech-panel">
|
|
645
|
+
<span className="dashboard-source-label">{t("dashboard.data_source")}</span>
|
|
646
|
+
<div className="dashboard-source-options">
|
|
647
|
+
{([
|
|
648
|
+
["pi", "dashboard.source_pi"],
|
|
649
|
+
["chatgpt", "dashboard.source_chatgpt"],
|
|
650
|
+
] as const).map(([key, label]) => (
|
|
651
|
+
<button
|
|
652
|
+
key={key}
|
|
653
|
+
onClick={() => setSource(key)}
|
|
654
|
+
className={cn("dashboard-source-option", source === key && "is-active")}
|
|
655
|
+
>
|
|
656
|
+
<span className="dashboard-source-dot" />
|
|
657
|
+
{t(label)}
|
|
658
|
+
</button>
|
|
659
|
+
))}
|
|
660
|
+
</div>
|
|
661
|
+
{source === "chatgpt" && <span className="dashboard-source-note">{t("dashboard.source_chatgpt_note")}</span>}
|
|
662
|
+
</div>
|
|
663
|
+
|
|
558
664
|
{/* Custom Date Picker */}
|
|
559
665
|
{range === "custom" && (
|
|
560
666
|
<div className="flex items-center gap-2 flex-wrap">
|
|
@@ -628,8 +734,187 @@ export function DashboardPage() {
|
|
|
628
734
|
/>
|
|
629
735
|
</div>
|
|
630
736
|
|
|
737
|
+
{/* Rich analytics overview */}
|
|
738
|
+
<div className="dashboard-analytics-grid">
|
|
739
|
+
<section className="tech-panel analytics-overview-panel">
|
|
740
|
+
<div className="analytics-panel-header">
|
|
741
|
+
<div>
|
|
742
|
+
<span className="analytics-panel-kicker">SYSTEM READOUT // 01</span>
|
|
743
|
+
<h3>{t("dashboard.operational_overview")}</h3>
|
|
744
|
+
</div>
|
|
745
|
+
<Gauge className="h-4 w-4" style={{ color: "var(--signal-cyan)" }} />
|
|
746
|
+
</div>
|
|
747
|
+
<div className="analytics-metrics-grid">
|
|
748
|
+
<AnalyticsMetric
|
|
749
|
+
label={t("dashboard.avg_tokens_request")}
|
|
750
|
+
value={formatTokensShort(Math.round(analytics.avgTokens), lang)}
|
|
751
|
+
detail={t("dashboard.avg_tokens_request_detail", String(analytics.activeModels))}
|
|
752
|
+
icon={<Activity className="h-4 w-4" />}
|
|
753
|
+
/>
|
|
754
|
+
<AnalyticsMetric
|
|
755
|
+
label={t("dashboard.avg_cost_request")}
|
|
756
|
+
value={fmtCostCell(analytics.avgCost)}
|
|
757
|
+
detail={t("dashboard.avg_cost_request_detail", String(analytics.activeProviders))}
|
|
758
|
+
icon={<CircleDollarSign className="h-4 w-4" />}
|
|
759
|
+
accent="var(--signal-amber)"
|
|
760
|
+
/>
|
|
761
|
+
<AnalyticsMetric
|
|
762
|
+
label={t("dashboard.peak_activity")}
|
|
763
|
+
value={analytics.peak?.label || "—"}
|
|
764
|
+
detail={analytics.peak ? t("dashboard.peak_activity_detail", String(analytics.peak.requests)) : t("dashboard.no_data")}
|
|
765
|
+
icon={<Zap className="h-4 w-4" />}
|
|
766
|
+
accent="var(--signal-lime)"
|
|
767
|
+
/>
|
|
768
|
+
<AnalyticsMetric
|
|
769
|
+
label={t("dashboard.active_models")}
|
|
770
|
+
value={String(analytics.activeModels)}
|
|
771
|
+
detail={t("dashboard.active_models_detail", String(analytics.activeProviders))}
|
|
772
|
+
icon={<Cpu className="h-4 w-4" />}
|
|
773
|
+
accent="var(--signal-violet)"
|
|
774
|
+
/>
|
|
775
|
+
</div>
|
|
776
|
+
</section>
|
|
777
|
+
|
|
778
|
+
<section className="tech-panel composition-panel">
|
|
779
|
+
<div className="analytics-panel-header">
|
|
780
|
+
<div>
|
|
781
|
+
<span className="analytics-panel-kicker">TOKEN FLOW // 02</span>
|
|
782
|
+
<h3>{t("dashboard.token_composition")}</h3>
|
|
783
|
+
</div>
|
|
784
|
+
<PieChartIcon className="h-4 w-4" style={{ color: "var(--signal-violet)" }} />
|
|
785
|
+
</div>
|
|
786
|
+
<div className="composition-layout">
|
|
787
|
+
<div className="composition-chart">
|
|
788
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
789
|
+
<PieChart>
|
|
790
|
+
<Pie
|
|
791
|
+
data={analytics.composition}
|
|
792
|
+
dataKey="value"
|
|
793
|
+
nameKey="key"
|
|
794
|
+
innerRadius="62%"
|
|
795
|
+
outerRadius="86%"
|
|
796
|
+
paddingAngle={3}
|
|
797
|
+
stroke="none"
|
|
798
|
+
>
|
|
799
|
+
{analytics.composition.map((entry) => <Cell key={entry.key} fill={entry.color} />)}
|
|
800
|
+
</Pie>
|
|
801
|
+
<Tooltip
|
|
802
|
+
formatter={(value: any) => formatTokensShort(Number(Array.isArray(value) ? value[0] : value ?? 0), lang)}
|
|
803
|
+
contentStyle={{ backgroundColor: "var(--card-bg-solid)", border: "1px solid var(--card-border)", borderRadius: "8px", fontSize: "11px" }}
|
|
804
|
+
/>
|
|
805
|
+
</PieChart>
|
|
806
|
+
</ResponsiveContainer>
|
|
807
|
+
<div className="composition-center">
|
|
808
|
+
<strong>{formatTokensShort(analytics.cacheTokens, lang)}</strong>
|
|
809
|
+
<span>{t("dashboard.cache_total")}</span>
|
|
810
|
+
</div>
|
|
811
|
+
</div>
|
|
812
|
+
<div className="composition-legend">
|
|
813
|
+
{analytics.composition.map((entry) => {
|
|
814
|
+
const pct = analytics.totalTokens > 0 ? (entry.value / analytics.totalTokens) * 100 : 0;
|
|
815
|
+
const labels: Record<string, string> = {
|
|
816
|
+
input: t("dashboard.input"),
|
|
817
|
+
output: t("dashboard.output"),
|
|
818
|
+
cacheRead: t("dashboard.cache_hit"),
|
|
819
|
+
cacheWrite: t("dashboard.cache_create"),
|
|
820
|
+
};
|
|
821
|
+
return (
|
|
822
|
+
<div key={entry.key} className="composition-legend-row">
|
|
823
|
+
<span className="legend-dot" style={{ backgroundColor: entry.color }} />
|
|
824
|
+
<span>{labels[entry.key]}</span>
|
|
825
|
+
<strong>{pct.toFixed(1)}%</strong>
|
|
826
|
+
</div>
|
|
827
|
+
);
|
|
828
|
+
})}
|
|
829
|
+
<div className="composition-summary">
|
|
830
|
+
<span>{t("dashboard.cache_hit_rate")}</span>
|
|
831
|
+
<strong>{analytics.cacheShare.toFixed(1)}%</strong>
|
|
832
|
+
</div>
|
|
833
|
+
</div>
|
|
834
|
+
</div>
|
|
835
|
+
</section>
|
|
836
|
+
|
|
837
|
+
<section className="tech-panel distribution-panel">
|
|
838
|
+
<div className="analytics-panel-header">
|
|
839
|
+
<div>
|
|
840
|
+
<span className="analytics-panel-kicker">ROUTING MATRIX // 03</span>
|
|
841
|
+
<h3>{t("dashboard.provider_mix")}</h3>
|
|
842
|
+
</div>
|
|
843
|
+
<Layers3 className="h-4 w-4" style={{ color: "var(--signal-amber)" }} />
|
|
844
|
+
</div>
|
|
845
|
+
<div className="distribution-list">
|
|
846
|
+
{analytics.providers.length === 0 ? (
|
|
847
|
+
<p className="analytics-no-data">{t("dashboard.no_data")}</p>
|
|
848
|
+
) : analytics.providers.slice(0, 5).map((provider, index) => (
|
|
849
|
+
<DistributionRow
|
|
850
|
+
key={provider.providerId}
|
|
851
|
+
name={provider.providerId}
|
|
852
|
+
meta={t("dashboard.provider_models", String(provider.modelCount))}
|
|
853
|
+
value={provider.totalTokens}
|
|
854
|
+
percentage={analytics.providerTokenTotal > 0 ? (provider.totalTokens / analytics.providerTokenTotal) * 100 : 0}
|
|
855
|
+
valueLabel={formatTokensShort(provider.totalTokens, lang)}
|
|
856
|
+
color={COLORS[index % COLORS.length] ?? COLORS[0]!}
|
|
857
|
+
/>
|
|
858
|
+
))}
|
|
859
|
+
</div>
|
|
860
|
+
</section>
|
|
861
|
+
|
|
862
|
+
<section className="tech-panel activity-panel">
|
|
863
|
+
<div className="analytics-panel-header">
|
|
864
|
+
<div>
|
|
865
|
+
<span className="analytics-panel-kicker">REQUEST PULSE // 04</span>
|
|
866
|
+
<h3>{t("dashboard.request_activity")}</h3>
|
|
867
|
+
</div>
|
|
868
|
+
<Clock3 className="h-4 w-4" style={{ color: "var(--signal-lime)" }} />
|
|
869
|
+
</div>
|
|
870
|
+
<div className="activity-chart">
|
|
871
|
+
<ResponsiveContainer width="100%" height="100%">
|
|
872
|
+
<BarChart data={analytics.activity} margin={{ top: 8, right: 4, left: -22, bottom: 0 }}>
|
|
873
|
+
<CartesianGrid vertical={false} stroke="var(--card-border)" strokeDasharray="3 3" />
|
|
874
|
+
<XAxis dataKey="label" tick={{ fontSize: 9, fill: "var(--muted-text)" }} axisLine={false} tickLine={false} interval="preserveStartEnd" />
|
|
875
|
+
<YAxis allowDecimals={false} tick={{ fontSize: 9, fill: "var(--muted-text)" }} axisLine={false} tickLine={false} />
|
|
876
|
+
<Tooltip
|
|
877
|
+
cursor={{ fill: "color-mix(in srgb, var(--signal-cyan) 6%, transparent)" }}
|
|
878
|
+
formatter={(value: any) => [Number(Array.isArray(value) ? value[0] : value ?? 0), t("dashboard.requests")]}
|
|
879
|
+
contentStyle={{ backgroundColor: "var(--card-bg-solid)", border: "1px solid var(--card-border)", borderRadius: "8px", fontSize: "11px" }}
|
|
880
|
+
/>
|
|
881
|
+
<Bar dataKey="requests" fill="var(--signal-cyan)" radius={[3, 3, 0, 0]} maxBarSize={18} />
|
|
882
|
+
</BarChart>
|
|
883
|
+
</ResponsiveContainer>
|
|
884
|
+
</div>
|
|
885
|
+
<div className="activity-footer">
|
|
886
|
+
<span>{t("dashboard.total_requests")}</span>
|
|
887
|
+
<strong>{formatNumber(data.totalRequests)}</strong>
|
|
888
|
+
</div>
|
|
889
|
+
</section>
|
|
890
|
+
|
|
891
|
+
<section className="tech-panel model-leaderboard-panel">
|
|
892
|
+
<div className="analytics-panel-header">
|
|
893
|
+
<div>
|
|
894
|
+
<span className="analytics-panel-kicker">MODEL LOAD // 05</span>
|
|
895
|
+
<h3>{t("dashboard.model_leaderboard")}</h3>
|
|
896
|
+
</div>
|
|
897
|
+
<Cpu className="h-4 w-4" style={{ color: "var(--signal-violet)" }} />
|
|
898
|
+
</div>
|
|
899
|
+
<div className="leaderboard-list">
|
|
900
|
+
{analytics.models.length === 0 ? (
|
|
901
|
+
<p className="analytics-no-data">{t("dashboard.no_data")}</p>
|
|
902
|
+
) : analytics.models.slice(0, 5).map((model, index) => (
|
|
903
|
+
<div key={`${model.providerId}/${model.modelId}`} className="leaderboard-row">
|
|
904
|
+
<span className="leaderboard-rank">0{index + 1}</span>
|
|
905
|
+
<div className="min-w-0 flex-1">
|
|
906
|
+
<div className="leaderboard-name">{model.modelId}</div>
|
|
907
|
+
<div className="leaderboard-meta">{model.providerId} · {formatNumber(model.totalRequests)} {t("dashboard.requests")}</div>
|
|
908
|
+
</div>
|
|
909
|
+
<span className="leaderboard-value">{formatTokensShort(model.totalTokens, lang)}</span>
|
|
910
|
+
</div>
|
|
911
|
+
))}
|
|
912
|
+
</div>
|
|
913
|
+
</section>
|
|
914
|
+
</div>
|
|
915
|
+
|
|
631
916
|
{/* Usage Trend Chart */}
|
|
632
|
-
<div className="
|
|
917
|
+
<div className="tech-panel telemetry-chart p-5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
|
|
633
918
|
<h3 className="text-sm font-semibold mb-1" style={{ color: "var(--page-text)" }}>{t("dashboard.usage_trend")}</h3>
|
|
634
919
|
<p className="text-xs mb-4" style={{ color: "var(--muted-text)" }}>
|
|
635
920
|
{range === "today" ? t("dashboard.range.today") : `${formatDateShort(chartData[0]?.rawDate || "")} - ${formatDateShort(chartData[chartData.length - 1]?.rawDate || "")}`}
|
|
@@ -662,7 +947,7 @@ export function DashboardPage() {
|
|
|
662
947
|
</div>
|
|
663
948
|
|
|
664
949
|
{/* Tabs: Request Log / Provider / Model */}
|
|
665
|
-
<div className="
|
|
950
|
+
<div className="tech-panel data-console overflow-hidden" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
|
|
666
951
|
{/* Tab Header */}
|
|
667
952
|
<div className="flex items-center border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
668
953
|
{([
|
|
@@ -678,8 +963,8 @@ export function DashboardPage() {
|
|
|
678
963
|
tab === tabItem.key ? "" : "border-transparent"
|
|
679
964
|
)}
|
|
680
965
|
style={{
|
|
681
|
-
color: tab === tabItem.key ? "
|
|
682
|
-
borderBottomColor: tab === tabItem.key ? "
|
|
966
|
+
color: tab === tabItem.key ? "var(--signal-cyan)" : "var(--muted-text)",
|
|
967
|
+
borderBottomColor: tab === tabItem.key ? "var(--signal-cyan)" : "transparent",
|
|
683
968
|
}}
|
|
684
969
|
>
|
|
685
970
|
{t(tabItem.label)}
|
|
@@ -1,25 +1,57 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
1
2
|
import { Outlet, useLocation } from "react-router-dom";
|
|
3
|
+
import { Menu, RadioTower } from "lucide-react";
|
|
2
4
|
import { Sidebar } from "./Sidebar";
|
|
3
5
|
|
|
4
6
|
export function AppShell() {
|
|
5
7
|
const location = useLocation();
|
|
8
|
+
const [mobileNavOpen, setMobileNavOpen] = useState(false);
|
|
6
9
|
const isFullHeightPage = location.pathname.startsWith("/chat");
|
|
7
10
|
|
|
8
11
|
return (
|
|
9
|
-
<div className="
|
|
10
|
-
<
|
|
11
|
-
|
|
12
|
-
className=
|
|
13
|
-
|
|
14
|
-
>
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
12
|
+
<div className="app-shell">
|
|
13
|
+
<div className="app-atmosphere" aria-hidden="true">
|
|
14
|
+
<span className="app-orbit app-orbit-one" />
|
|
15
|
+
<span className="app-orbit app-orbit-two" />
|
|
16
|
+
<span className="app-scanline" />
|
|
17
|
+
</div>
|
|
18
|
+
|
|
19
|
+
<Sidebar mobileOpen={mobileNavOpen} onClose={() => setMobileNavOpen(false)} />
|
|
20
|
+
|
|
21
|
+
{mobileNavOpen && (
|
|
22
|
+
<button
|
|
23
|
+
aria-label="Close navigation"
|
|
24
|
+
className="sidebar-scrim"
|
|
25
|
+
onClick={() => setMobileNavOpen(false)}
|
|
26
|
+
/>
|
|
27
|
+
)}
|
|
28
|
+
|
|
29
|
+
<div className="app-stage">
|
|
30
|
+
<header className="mobile-command-bar">
|
|
31
|
+
<button
|
|
32
|
+
className="command-icon-button"
|
|
33
|
+
aria-label="Open navigation"
|
|
34
|
+
onClick={() => setMobileNavOpen(true)}
|
|
35
|
+
>
|
|
36
|
+
<Menu className="h-5 w-5" />
|
|
37
|
+
</button>
|
|
38
|
+
<div className="mobile-brand">
|
|
39
|
+
<RadioTower className="h-4 w-4" />
|
|
40
|
+
<span>PI // CONTROL</span>
|
|
20
41
|
</div>
|
|
21
|
-
|
|
22
|
-
|
|
42
|
+
<span className="system-pulse" aria-hidden="true" />
|
|
43
|
+
</header>
|
|
44
|
+
|
|
45
|
+
<main className={isFullHeightPage ? "app-main app-main-full" : "app-main"}>
|
|
46
|
+
{isFullHeightPage ? (
|
|
47
|
+
<Outlet />
|
|
48
|
+
) : (
|
|
49
|
+
<div className="app-canvas">
|
|
50
|
+
<Outlet />
|
|
51
|
+
</div>
|
|
52
|
+
)}
|
|
53
|
+
</main>
|
|
54
|
+
</div>
|
|
23
55
|
</div>
|
|
24
56
|
);
|
|
25
57
|
}
|