@raingor/pi-web-switch 0.4.3 → 0.5.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/dist/index.html +4 -4
- package/index.html +2 -2
- package/package.json +5 -3
- package/public/manifest.webmanifest +2 -2
- package/public/sw.js +51 -51
- package/server/pi-reader.ts +326 -19
- package/src/App.tsx +2 -0
- package/src/components/dashboard/DashboardPage.tsx +341 -27
- package/src/components/layout/AppShell.tsx +45 -13
- package/src/components/layout/Sidebar.tsx +78 -74
- package/src/components/providers/ProvidersModelsPage.tsx +125 -131
- package/src/components/sessions/MemoryPage.tsx +32 -12
- package/src/components/sessions/SessionsPage.tsx +18 -4
- 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 -9
- package/src/lib/translations/ja.ts +88 -9
- package/src/lib/translations/zh-CN.ts +88 -9
- package/src/lib/translations/zh-TW.ts +87 -9
- package/src/main.tsx +99 -22
- package/vite.config.ts +62 -137
- package/src/data/mock-config.ts +0 -247
- package/src/data/mock-usage.ts +0 -151
|
@@ -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,10 +528,11 @@ export function DashboardPage() {
|
|
|
414
528
|
};
|
|
415
529
|
|
|
416
530
|
return (
|
|
417
|
-
<div className="space-y-5">
|
|
531
|
+
<div className="dashboard-page space-y-5">
|
|
418
532
|
{/* Title + Time Range Selector + Currency Toggle */}
|
|
419
|
-
<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">
|
|
420
534
|
<div>
|
|
535
|
+
<div className="page-kicker"><span /> TELEMETRY // LIVE OPERATIONS</div>
|
|
421
536
|
<h1 className="text-xl font-bold" style={{ color: "var(--page-text)" }}>{t("dashboard.title")}</h1>
|
|
422
537
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-text)" }}>
|
|
423
538
|
{data ? t("dashboard.requests_count", String(data.requestLog.length), formatCost(data.totalCost, currency)) : ""}
|
|
@@ -526,6 +641,26 @@ export function DashboardPage() {
|
|
|
526
641
|
</div>
|
|
527
642
|
</div>
|
|
528
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
|
+
|
|
529
664
|
{/* Custom Date Picker */}
|
|
530
665
|
{range === "custom" && (
|
|
531
666
|
<div className="flex items-center gap-2 flex-wrap">
|
|
@@ -599,8 +734,187 @@ export function DashboardPage() {
|
|
|
599
734
|
/>
|
|
600
735
|
</div>
|
|
601
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
|
+
|
|
602
916
|
{/* Usage Trend Chart */}
|
|
603
|
-
<div className="
|
|
917
|
+
<div className="tech-panel telemetry-chart p-5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
|
|
604
918
|
<h3 className="text-sm font-semibold mb-1" style={{ color: "var(--page-text)" }}>{t("dashboard.usage_trend")}</h3>
|
|
605
919
|
<p className="text-xs mb-4" style={{ color: "var(--muted-text)" }}>
|
|
606
920
|
{range === "today" ? t("dashboard.range.today") : `${formatDateShort(chartData[0]?.rawDate || "")} - ${formatDateShort(chartData[chartData.length - 1]?.rawDate || "")}`}
|
|
@@ -633,7 +947,7 @@ export function DashboardPage() {
|
|
|
633
947
|
</div>
|
|
634
948
|
|
|
635
949
|
{/* Tabs: Request Log / Provider / Model */}
|
|
636
|
-
<div className="
|
|
950
|
+
<div className="tech-panel data-console overflow-hidden" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
|
|
637
951
|
{/* Tab Header */}
|
|
638
952
|
<div className="flex items-center border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
639
953
|
{([
|
|
@@ -649,8 +963,8 @@ export function DashboardPage() {
|
|
|
649
963
|
tab === tabItem.key ? "" : "border-transparent"
|
|
650
964
|
)}
|
|
651
965
|
style={{
|
|
652
|
-
color: tab === tabItem.key ? "
|
|
653
|
-
borderBottomColor: tab === tabItem.key ? "
|
|
966
|
+
color: tab === tabItem.key ? "var(--signal-cyan)" : "var(--muted-text)",
|
|
967
|
+
borderBottomColor: tab === tabItem.key ? "var(--signal-cyan)" : "transparent",
|
|
654
968
|
}}
|
|
655
969
|
>
|
|
656
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
|
}
|