@raingor/pi-web-switch 0.3.1 → 0.3.2
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.md +0 -1
- package/README.zh-CN.md +0 -1
- package/package.json +1 -1
- package/server/pi-reader.ts +403 -4
- package/src/App.tsx +3 -0
- package/src/components/dashboard/DashboardPage.tsx +275 -117
- package/src/components/layout/Sidebar.tsx +2 -0
- package/src/components/providers/ProvidersModelsPage.tsx +1387 -0
- package/src/components/sessions/MemoryPage.tsx +480 -85
- package/src/components/sessions/SessionsPage.tsx +473 -89
- package/src/components/settings/SettingsPage.tsx +555 -243
- package/src/data/builtin-providers.ts +0 -12
- package/src/data/mock-config.ts +1 -15
- package/src/data/mock-usage.ts +0 -2
- package/src/lib/translations/en.ts +114 -23
- package/src/lib/translations/ja.ts +114 -23
- package/src/lib/translations/zh-CN.ts +114 -23
- package/src/lib/translations/zh-TW.ts +114 -23
- package/src/main.tsx +9 -0
- package/src/store/config-store.ts +18 -12
- package/src/types/index.ts +14 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/vite.config.ts +115 -3
- package/src/components/models/ModelsPage.tsx +0 -570
- package/src/components/providers/ProvidersPage.tsx +0 -466
|
@@ -2,9 +2,9 @@ import { useState, useEffect, useCallback } 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
|
-
import {
|
|
5
|
+
import { formatCost, formatNumber, cn, USD_TO_CNY } from "@/lib/utils";
|
|
6
6
|
import {
|
|
7
|
-
Activity, DollarSign, BarChart3, ArrowUp, ArrowDown, Database, DollarSignIcon, RefreshCw,
|
|
7
|
+
Activity, DollarSign, BarChart3, ArrowUp, ArrowDown, Database, DollarSignIcon, RefreshCw, Download,
|
|
8
8
|
} from "lucide-react";
|
|
9
9
|
import {
|
|
10
10
|
AreaChart, Area, XAxis, YAxis, Tooltip, ResponsiveContainer,
|
|
@@ -71,13 +71,9 @@ interface UsageRangeData {
|
|
|
71
71
|
|
|
72
72
|
type RangeKey = "today" | "7d" | "30d" | "custom";
|
|
73
73
|
type TabKey = "log" | "provider" | "model";
|
|
74
|
+
type SortDir = "asc" | "desc";
|
|
74
75
|
|
|
75
|
-
const
|
|
76
|
-
{ key: "today", label: "Today" },
|
|
77
|
-
{ key: "7d", label: "7 Days" },
|
|
78
|
-
{ key: "30d", label: "30 Days" },
|
|
79
|
-
{ key: "custom", label: "Custom" },
|
|
80
|
-
];
|
|
76
|
+
const RANGE_KEYS: RangeKey[] = ["today", "7d", "30d", "custom"];
|
|
81
77
|
|
|
82
78
|
const COLORS = ["#3b82f6", "#10b981", "#8b5cf6", "#f59e0b", "#ef4444"];
|
|
83
79
|
|
|
@@ -89,13 +85,7 @@ const CHART_LINE_COLORS: Record<string, string> = {
|
|
|
89
85
|
cost: "#ef4444",
|
|
90
86
|
};
|
|
91
87
|
|
|
92
|
-
const
|
|
93
|
-
input: "Input",
|
|
94
|
-
output: "Output",
|
|
95
|
-
cacheRead: "Cache Hit",
|
|
96
|
-
cacheWrite: "Cache Create",
|
|
97
|
-
cost: "Cost",
|
|
98
|
-
};
|
|
88
|
+
const LOG_PAGE_SIZE = 20;
|
|
99
89
|
|
|
100
90
|
// ─── Helpers ────────────────────────────────────────────
|
|
101
91
|
|
|
@@ -124,17 +114,51 @@ function formatCostShort(n: number): string {
|
|
|
124
114
|
return `$${n.toFixed(4)}`;
|
|
125
115
|
}
|
|
126
116
|
|
|
127
|
-
function formatCostShortCNY(n: number): string {
|
|
128
|
-
const cny = n * USD_TO_CNY;
|
|
129
|
-
if (cny >= 1) return `¥${cny.toFixed(2)}`;
|
|
130
|
-
return `¥${cny.toFixed(4)}`;
|
|
131
|
-
}
|
|
132
|
-
|
|
133
117
|
function formatDateShort(dateStr: string): string {
|
|
134
118
|
const d = new Date(dateStr + "T00:00:00");
|
|
135
119
|
return d.toLocaleDateString("en-US", { month: "short", day: "numeric" });
|
|
136
120
|
}
|
|
137
121
|
|
|
122
|
+
function localDateStr(d: Date): string {
|
|
123
|
+
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Previous period of equal length, for period-over-period trends. */
|
|
127
|
+
function getPrevRange(range: RangeKey): { from: string; to: string } | null {
|
|
128
|
+
const now = new Date();
|
|
129
|
+
const shift = (days: number) => {
|
|
130
|
+
const d = new Date(now);
|
|
131
|
+
d.setDate(d.getDate() - days);
|
|
132
|
+
return localDateStr(d);
|
|
133
|
+
};
|
|
134
|
+
if (range === "today") return { from: shift(1), to: shift(1) };
|
|
135
|
+
if (range === "7d") return { from: shift(13), to: shift(7) };
|
|
136
|
+
if (range === "30d") return { from: shift(59), to: shift(30) };
|
|
137
|
+
return null; // custom: no comparable previous period
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function sortRows<T extends Record<string, unknown>>(rows: T[], key: string, dir: SortDir): T[] {
|
|
141
|
+
return [...rows].sort((a, b) => {
|
|
142
|
+
const av = Number(a[key] ?? 0);
|
|
143
|
+
const bv = Number(b[key] ?? 0);
|
|
144
|
+
return dir === "desc" ? bv - av : av - bv;
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function downloadCsv(filename: string, headers: string[], rows: (string | number)[][]) {
|
|
149
|
+
const esc = (v: string | number) => {
|
|
150
|
+
const s = String(v);
|
|
151
|
+
return /[",\n]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s;
|
|
152
|
+
};
|
|
153
|
+
const csv = [headers, ...rows].map((r) => r.map(esc).join(",")).join("\n");
|
|
154
|
+
const blob = new Blob(["\uFEFF" + csv], { type: "text/csv;charset=utf-8" });
|
|
155
|
+
const a = document.createElement("a");
|
|
156
|
+
a.href = URL.createObjectURL(blob);
|
|
157
|
+
a.download = filename;
|
|
158
|
+
a.click();
|
|
159
|
+
URL.revokeObjectURL(a.href);
|
|
160
|
+
}
|
|
161
|
+
|
|
138
162
|
// ─── Stat Card ──────────────────────────────────────────
|
|
139
163
|
|
|
140
164
|
function StatCard({
|
|
@@ -212,6 +236,34 @@ function BreakdownRow({ label, value, total, color, lang = "en" }: { label: stri
|
|
|
212
236
|
);
|
|
213
237
|
}
|
|
214
238
|
|
|
239
|
+
// ─── Sortable Table Header ──────────────────────────────
|
|
240
|
+
|
|
241
|
+
function SortableTh({
|
|
242
|
+
label,
|
|
243
|
+
sortKey,
|
|
244
|
+
sort,
|
|
245
|
+
onSort,
|
|
246
|
+
}: {
|
|
247
|
+
label: string;
|
|
248
|
+
sortKey: string;
|
|
249
|
+
sort: { key: string; dir: SortDir };
|
|
250
|
+
onSort: (key: string) => void;
|
|
251
|
+
}) {
|
|
252
|
+
const active = sort.key === sortKey;
|
|
253
|
+
return (
|
|
254
|
+
<th
|
|
255
|
+
className="px-4 py-3 text-right font-medium cursor-pointer select-none"
|
|
256
|
+
style={{ color: active ? "#3b82f6" : "var(--muted-text)" }}
|
|
257
|
+
onClick={() => onSort(sortKey)}
|
|
258
|
+
>
|
|
259
|
+
<span className="inline-flex items-center gap-0.5">
|
|
260
|
+
{label}
|
|
261
|
+
{active && (sort.dir === "desc" ? <ArrowDown className="h-3 w-3" /> : <ArrowUp className="h-3 w-3" />)}
|
|
262
|
+
</span>
|
|
263
|
+
</th>
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
|
|
215
267
|
// ─── Main Component ─────────────────────────────────────
|
|
216
268
|
|
|
217
269
|
export function DashboardPage() {
|
|
@@ -224,25 +276,52 @@ export function DashboardPage() {
|
|
|
224
276
|
const [tab, setTab] = useState<TabKey>("log");
|
|
225
277
|
const [data, setData] = useState<UsageRangeData | null>(null);
|
|
226
278
|
const [loading, setLoading] = useState(true);
|
|
279
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
227
280
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
228
281
|
const [refreshInterval, setRefreshInterval] = useState(30);
|
|
229
282
|
const [showIntervalPicker, setShowIntervalPicker] = useState(false);
|
|
230
283
|
const [lastUpdated, setLastUpdated] = useState<string | null>(null);
|
|
284
|
+
const [logPage, setLogPage] = useState(1);
|
|
285
|
+
const [providerSort, setProviderSort] = useState<{ key: string; dir: SortDir }>({ key: "totalCost", dir: "desc" });
|
|
286
|
+
const [modelSort, setModelSort] = useState<{ key: string; dir: SortDir }>({ key: "totalCost", dir: "desc" });
|
|
287
|
+
const [prevTotals, setPrevTotals] = useState<{ tokens: number; cost: number } | null>(null);
|
|
288
|
+
|
|
289
|
+
const customInvalid = range === "custom" && !!customFrom && !!customTo && customFrom > customTo;
|
|
231
290
|
|
|
232
291
|
const fetchData = useCallback(() => {
|
|
233
|
-
if (!initialized) return;
|
|
292
|
+
if (!initialized || customInvalid) return;
|
|
234
293
|
let url = "/api/pi/usage-range?range=" + range;
|
|
235
294
|
if (range === "custom" && customFrom) {
|
|
236
295
|
url += `&from=${customFrom}&to=${customTo || customFrom}`;
|
|
237
296
|
}
|
|
297
|
+
setRefreshing(true);
|
|
238
298
|
fetch(url)
|
|
239
299
|
.then((r) => r.json())
|
|
240
|
-
.then((d) => {
|
|
241
|
-
|
|
242
|
-
|
|
300
|
+
.then((d) => {
|
|
301
|
+
setData(d);
|
|
302
|
+
setLoading(false);
|
|
303
|
+
setRefreshing(false);
|
|
304
|
+
setLastUpdated(new Date().toLocaleTimeString());
|
|
305
|
+
})
|
|
306
|
+
.catch(() => { setLoading(false); setRefreshing(false); });
|
|
307
|
+
|
|
308
|
+
// Previous period of equal length → period-over-period trend on stat cards
|
|
309
|
+
const prev = getPrevRange(range);
|
|
310
|
+
if (prev) {
|
|
311
|
+
fetch(`/api/pi/usage-range?range=custom&from=${prev.from}&to=${prev.to}`)
|
|
312
|
+
.then((r) => r.json())
|
|
313
|
+
.then((p) => setPrevTotals({ tokens: p.totalTokens ?? 0, cost: p.totalCost ?? 0 }))
|
|
314
|
+
.catch(() => setPrevTotals(null));
|
|
315
|
+
} else {
|
|
316
|
+
setPrevTotals(null);
|
|
317
|
+
}
|
|
318
|
+
}, [initialized, range, customFrom, customTo, customInvalid]);
|
|
243
319
|
|
|
244
320
|
useEffect(() => { fetchData(); }, [fetchData]);
|
|
245
321
|
|
|
322
|
+
// Reset request-log pagination when the queried range changes
|
|
323
|
+
useEffect(() => { setLogPage(1); }, [range, customFrom, customTo]);
|
|
324
|
+
|
|
246
325
|
// Auto-refresh with configurable interval (seconds)
|
|
247
326
|
useEffect(() => {
|
|
248
327
|
if (!autoRefresh || refreshInterval <= 0) return;
|
|
@@ -265,6 +344,44 @@ export function DashboardPage() {
|
|
|
265
344
|
requests: d.requests,
|
|
266
345
|
}));
|
|
267
346
|
|
|
347
|
+
// Period-over-period trends (undefined → hidden)
|
|
348
|
+
const tokenTrend = data && prevTotals && prevTotals.tokens > 0
|
|
349
|
+
? ((data.totalTokens - prevTotals.tokens) / prevTotals.tokens) * 100
|
|
350
|
+
: undefined;
|
|
351
|
+
const costTrend = data && prevTotals && prevTotals.cost > 0
|
|
352
|
+
? ((data.totalCost - prevTotals.cost) / prevTotals.cost) * 100
|
|
353
|
+
: undefined;
|
|
354
|
+
|
|
355
|
+
// Sorted stats + request-log pagination
|
|
356
|
+
const sortedProviders = sortRows(data?.providerStats ?? [], providerSort.key, providerSort.dir);
|
|
357
|
+
const sortedModels = sortRows(data?.modelStats ?? [], modelSort.key, modelSort.dir);
|
|
358
|
+
const providerTotalCost = (data?.providerStats ?? []).reduce((s, p) => s + p.totalCost, 0);
|
|
359
|
+
const totalLogPages = Math.max(1, Math.ceil((data?.requestLog.length ?? 0) / LOG_PAGE_SIZE));
|
|
360
|
+
const currentLogPage = Math.min(logPage, totalLogPages);
|
|
361
|
+
const pagedLog = (data?.requestLog ?? []).slice((currentLogPage - 1) * LOG_PAGE_SIZE, currentLogPage * LOG_PAGE_SIZE);
|
|
362
|
+
|
|
363
|
+
const fmtCostCell = (v: number) => (currency === "CNY" ? `¥${(v * USD_TO_CNY).toFixed(4)}` : formatCostShort(v));
|
|
364
|
+
|
|
365
|
+
const toggleSort = (setter: typeof setProviderSort) => (key: string) =>
|
|
366
|
+
setter((s) => (s.key === key ? { key, dir: s.dir === "desc" ? "asc" : "desc" } : { key, dir: "desc" }));
|
|
367
|
+
|
|
368
|
+
const handleExport = () => {
|
|
369
|
+
if (!data) return;
|
|
370
|
+
if (tab === "log") {
|
|
371
|
+
downloadCsv(`pi-usage-log-${range}.csv`,
|
|
372
|
+
["time", "provider", "model", "input", "output", "cost_usd", "requests"],
|
|
373
|
+
data.requestLog.map((r) => [r.timestamp, r.providerId, r.modelId, r.input, r.output, r.cost, r.requests]));
|
|
374
|
+
} else if (tab === "provider") {
|
|
375
|
+
downloadCsv(`pi-usage-provider-${range}.csv`,
|
|
376
|
+
["provider", "tokens", "input", "output", "cost_usd", "requests", "models"],
|
|
377
|
+
sortedProviders.map((p) => [p.providerId, p.totalTokens, p.totalInput, p.totalOutput, p.totalCost, p.totalRequests, p.modelCount]));
|
|
378
|
+
} else {
|
|
379
|
+
downloadCsv(`pi-usage-model-${range}.csv`,
|
|
380
|
+
["model", "provider", "tokens", "input", "output", "cost_usd", "requests"],
|
|
381
|
+
sortedModels.map((m) => [m.modelId, m.providerId, m.totalTokens, m.totalInput, m.totalOutput, m.totalCost, m.totalRequests]));
|
|
382
|
+
}
|
|
383
|
+
};
|
|
384
|
+
|
|
268
385
|
return (
|
|
269
386
|
<div className="space-y-5">
|
|
270
387
|
{/* Title + Time Range Selector + Currency Toggle */}
|
|
@@ -273,7 +390,7 @@ export function DashboardPage() {
|
|
|
273
390
|
<h1 className="text-xl font-bold" style={{ color: "var(--page-text)" }}>{t("dashboard.title")}</h1>
|
|
274
391
|
<p className="text-xs mt-0.5" style={{ color: "var(--muted-text)" }}>
|
|
275
392
|
{data ? t("dashboard.requests_count", String(data.requestLog.length), formatCost(data.totalCost, currency)) : ""}
|
|
276
|
-
{lastUpdated && <span className="ml-2">·
|
|
393
|
+
{lastUpdated && <span className="ml-2">· {t("dashboard.last_updated", lastUpdated)}</span>}
|
|
277
394
|
</p>
|
|
278
395
|
</div>
|
|
279
396
|
<div className="flex items-center gap-2">
|
|
@@ -281,7 +398,7 @@ export function DashboardPage() {
|
|
|
281
398
|
onClick={toggleCurrency}
|
|
282
399
|
className="flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-colors"
|
|
283
400
|
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
|
|
284
|
-
title={
|
|
401
|
+
title={t("dashboard.switch_currency")}
|
|
285
402
|
>
|
|
286
403
|
<DollarSignIcon className="h-3.5 w-3.5" />
|
|
287
404
|
{currency}
|
|
@@ -290,9 +407,9 @@ export function DashboardPage() {
|
|
|
290
407
|
onClick={() => { fetchData(); }}
|
|
291
408
|
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"
|
|
292
409
|
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)", backgroundColor: "var(--card-bg)" }}
|
|
293
|
-
title="
|
|
410
|
+
title={t("dashboard.refresh_now")}
|
|
294
411
|
>
|
|
295
|
-
<RefreshCw className="h-3.5 w-3.5" />
|
|
412
|
+
<RefreshCw className={cn("h-3.5 w-3.5", refreshing && "animate-spin")} />
|
|
296
413
|
</button>
|
|
297
414
|
<div className="relative">
|
|
298
415
|
<button
|
|
@@ -307,7 +424,7 @@ export function DashboardPage() {
|
|
|
307
424
|
>
|
|
308
425
|
<span className="inline-block h-2 w-2 rounded-full"
|
|
309
426
|
style={{ backgroundColor: autoRefresh ? "#10b981" : "var(--subtle-text)" }} />
|
|
310
|
-
{autoRefresh ? `${refreshInterval}s` : "
|
|
427
|
+
{autoRefresh ? `${refreshInterval}s` : t("dashboard.off")}
|
|
311
428
|
</button>
|
|
312
429
|
{showIntervalPicker && (
|
|
313
430
|
<div
|
|
@@ -318,7 +435,7 @@ export function DashboardPage() {
|
|
|
318
435
|
}}
|
|
319
436
|
>
|
|
320
437
|
<div className="px-4 py-2 text-xs font-medium border-b" style={{ color: "var(--muted-text)", borderColor: "var(--card-border)" }}>
|
|
321
|
-
{t("dashboard.
|
|
438
|
+
{t("dashboard.refresh_interval")}
|
|
322
439
|
</div>
|
|
323
440
|
{[5, 10, 30, 60].map((s) => (
|
|
324
441
|
<button
|
|
@@ -347,19 +464,19 @@ export function DashboardPage() {
|
|
|
347
464
|
)}
|
|
348
465
|
</div>
|
|
349
466
|
<div className="flex items-center gap-1 rounded-lg border p-0.5" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--page-bg)" }}>
|
|
350
|
-
{
|
|
467
|
+
{RANGE_KEYS.map((key) => (
|
|
351
468
|
<button
|
|
352
|
-
key={
|
|
353
|
-
onClick={() => setRange(
|
|
469
|
+
key={key}
|
|
470
|
+
onClick={() => setRange(key)}
|
|
354
471
|
className={cn(
|
|
355
472
|
"rounded-md px-3 py-1.5 text-xs font-medium transition-colors",
|
|
356
|
-
range ===
|
|
473
|
+
range === key
|
|
357
474
|
? "text-white"
|
|
358
475
|
: "hover:bg-gray-800/30"
|
|
359
476
|
)}
|
|
360
|
-
style={range ===
|
|
477
|
+
style={range === key ? { backgroundColor: "#3b82f6", color: "#fff" } : { color: "var(--muted-text)" }}
|
|
361
478
|
>
|
|
362
|
-
{t("dashboard.range." +
|
|
479
|
+
{t("dashboard.range." + key)}
|
|
363
480
|
</button>
|
|
364
481
|
))}
|
|
365
482
|
</div>
|
|
@@ -368,13 +485,13 @@ export function DashboardPage() {
|
|
|
368
485
|
|
|
369
486
|
{/* Custom Date Picker */}
|
|
370
487
|
{range === "custom" && (
|
|
371
|
-
<div className="flex items-center gap-2">
|
|
488
|
+
<div className="flex items-center gap-2 flex-wrap">
|
|
372
489
|
<input
|
|
373
490
|
type="date"
|
|
374
491
|
value={customFrom || today}
|
|
375
492
|
onChange={(e) => setCustomFrom(e.target.value)}
|
|
376
493
|
className="rounded-lg border px-3 py-1.5 text-xs"
|
|
377
|
-
style={{ backgroundColor: "var(--input-bg)", borderColor: "var(--input-border)", color: "var(--input-text)" }}
|
|
494
|
+
style={{ backgroundColor: "var(--input-bg)", borderColor: customInvalid ? "#ef4444" : "var(--input-border)", color: "var(--input-text)" }}
|
|
378
495
|
/>
|
|
379
496
|
<span className="text-xs" style={{ color: "var(--muted-text)" }}>{t("dashboard.to")}</span>
|
|
380
497
|
<input
|
|
@@ -382,8 +499,9 @@ export function DashboardPage() {
|
|
|
382
499
|
value={customTo || today}
|
|
383
500
|
onChange={(e) => setCustomTo(e.target.value)}
|
|
384
501
|
className="rounded-lg border px-3 py-1.5 text-xs"
|
|
385
|
-
style={{ backgroundColor: "var(--input-bg)", borderColor: "var(--input-border)", color: "var(--input-text)" }}
|
|
502
|
+
style={{ backgroundColor: "var(--input-bg)", borderColor: customInvalid ? "#ef4444" : "var(--input-border)", color: "var(--input-text)" }}
|
|
386
503
|
/>
|
|
504
|
+
{customInvalid && <span className="text-xs text-red-400">{t("dashboard.invalid_range")}</span>}
|
|
387
505
|
</div>
|
|
388
506
|
)}
|
|
389
507
|
|
|
@@ -395,7 +513,7 @@ export function DashboardPage() {
|
|
|
395
513
|
)}
|
|
396
514
|
|
|
397
515
|
{!loading && data && (
|
|
398
|
-
|
|
516
|
+
<div className={cn("space-y-5 transition-opacity", refreshing && "opacity-60")}>
|
|
399
517
|
{/* Overview Cards */}
|
|
400
518
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
401
519
|
<StatCard
|
|
@@ -403,6 +521,8 @@ export function DashboardPage() {
|
|
|
403
521
|
value={data.totalTokens.toLocaleString("en-US")}
|
|
404
522
|
icon={<Activity className="h-4 w-4" style={{ color: "#3b82f6" }} />}
|
|
405
523
|
subtitle={`≈ ${formatTokensShort(data.totalTokens, lang)}`}
|
|
524
|
+
trend={tokenTrend}
|
|
525
|
+
trendLabel={tokenTrend !== undefined ? t("dashboard.vs_prev") : undefined}
|
|
406
526
|
>
|
|
407
527
|
<div className="mt-3 space-y-0.5 border-t pt-3" style={{ borderColor: "var(--card-border)" }}>
|
|
408
528
|
<BreakdownRow label={t("dashboard.input")} value={data.totalInput} total={data.totalTokens} color="#3b82f6" lang={lang} />
|
|
@@ -421,9 +541,11 @@ export function DashboardPage() {
|
|
|
421
541
|
|
|
422
542
|
<StatCard
|
|
423
543
|
title={t("dashboard.total_cost")}
|
|
424
|
-
value={formatCost(data.totalCost)}
|
|
544
|
+
value={formatCost(data.totalCost, currency)}
|
|
425
545
|
icon={<DollarSign className="h-4 w-4" style={{ color: "#f59e0b" }} />}
|
|
426
546
|
subtitle={`${currency === "CNY" ? `¥${(data.totalCost * USD_TO_CNY).toFixed(4)}` : `$${data.totalCost.toFixed(4)}`} ${currency}`}
|
|
547
|
+
trend={costTrend}
|
|
548
|
+
trendLabel={costTrend !== undefined ? t("dashboard.vs_prev") : undefined}
|
|
427
549
|
/>
|
|
428
550
|
|
|
429
551
|
<StatCard
|
|
@@ -470,7 +592,7 @@ export function DashboardPage() {
|
|
|
470
592
|
{/* Tabs: Request Log / Provider / Model */}
|
|
471
593
|
<div className="rounded-xl border overflow-hidden" style={{ borderColor: "var(--card-border)", backgroundColor: "var(--card-bg)" }}>
|
|
472
594
|
{/* Tab Header */}
|
|
473
|
-
<div className="flex border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
595
|
+
<div className="flex items-center border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
474
596
|
{([
|
|
475
597
|
{ key: "log" as TabKey, label: "dashboard.request_log" },
|
|
476
598
|
{ key: "provider" as TabKey, label: "dashboard.provider_stats" },
|
|
@@ -491,51 +613,83 @@ export function DashboardPage() {
|
|
|
491
613
|
{t(tabItem.label)}
|
|
492
614
|
</button>
|
|
493
615
|
))}
|
|
616
|
+
<button
|
|
617
|
+
onClick={handleExport}
|
|
618
|
+
className="ml-auto mr-3 flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors hover:bg-gray-800/30"
|
|
619
|
+
style={{ color: "var(--muted-text)" }}
|
|
620
|
+
>
|
|
621
|
+
<Download className="h-3.5 w-3.5" />
|
|
622
|
+
{t("dashboard.export_csv")}
|
|
623
|
+
</button>
|
|
494
624
|
</div>
|
|
495
625
|
|
|
496
626
|
{/* Tab Content */}
|
|
497
627
|
<div className="overflow-x-auto">
|
|
498
628
|
{tab === "log" && (
|
|
499
|
-
|
|
500
|
-
<
|
|
501
|
-
<
|
|
502
|
-
<
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
</tr>
|
|
511
|
-
</thead>
|
|
512
|
-
<tbody>
|
|
513
|
-
{data.requestLog.length === 0 ? (
|
|
514
|
-
<tr>
|
|
515
|
-
<td colSpan={8} className="px-4 py-8 text-center" style={{ color: "var(--subtle-text)" }}>
|
|
516
|
-
{t("dashboard.no_data")}
|
|
517
|
-
</td>
|
|
629
|
+
<>
|
|
630
|
+
<table className="w-full text-xs">
|
|
631
|
+
<thead>
|
|
632
|
+
<tr className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
633
|
+
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.time")}</th>
|
|
634
|
+
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.provider")}</th>
|
|
635
|
+
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.model")}</th>
|
|
636
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.input")}</th>
|
|
637
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.output")}</th>
|
|
638
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.cost")}</th>
|
|
639
|
+
<th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.requests")}</th>
|
|
518
640
|
</tr>
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
<td className="px-4 py-
|
|
524
|
-
|
|
525
|
-
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(r.input, lang)}</td>
|
|
526
|
-
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(r.output, lang)}</td>
|
|
527
|
-
<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>
|
|
528
|
-
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{r.requests}</td>
|
|
529
|
-
<td className="px-4 py-2.5 text-center">
|
|
530
|
-
<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)" }}>
|
|
531
|
-
200
|
|
532
|
-
</span>
|
|
641
|
+
</thead>
|
|
642
|
+
<tbody>
|
|
643
|
+
{pagedLog.length === 0 ? (
|
|
644
|
+
<tr>
|
|
645
|
+
<td colSpan={7} className="px-4 py-8 text-center" style={{ color: "var(--subtle-text)" }}>
|
|
646
|
+
{t("dashboard.no_data")}
|
|
533
647
|
</td>
|
|
534
648
|
</tr>
|
|
535
|
-
)
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
649
|
+
) : (
|
|
650
|
+
pagedLog.map((r, i) => (
|
|
651
|
+
<tr key={i} className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
652
|
+
<td className="px-4 py-2.5 whitespace-nowrap" style={{ color: "var(--page-text)" }}>{r.timestamp}</td>
|
|
653
|
+
<td className="px-4 py-2.5" style={{ color: "var(--page-text)" }}>{r.providerId}</td>
|
|
654
|
+
<td className="px-4 py-2.5" style={{ color: "var(--page-text)" }}>{r.modelId}</td>
|
|
655
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(r.input, lang)}</td>
|
|
656
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(r.output, lang)}</td>
|
|
657
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{fmtCostCell(r.cost)}</td>
|
|
658
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{r.requests}</td>
|
|
659
|
+
</tr>
|
|
660
|
+
))
|
|
661
|
+
)}
|
|
662
|
+
</tbody>
|
|
663
|
+
</table>
|
|
664
|
+
{data.requestLog.length > LOG_PAGE_SIZE && (
|
|
665
|
+
<div className="flex items-center justify-between px-4 py-3">
|
|
666
|
+
<span className="text-xs" style={{ color: "var(--subtle-text)" }}>
|
|
667
|
+
{t("dashboard.total_items", String(data.requestLog.length))}
|
|
668
|
+
</span>
|
|
669
|
+
<div className="flex items-center gap-2">
|
|
670
|
+
<button
|
|
671
|
+
onClick={() => setLogPage((p) => Math.max(1, p - 1))}
|
|
672
|
+
disabled={currentLogPage <= 1}
|
|
673
|
+
className="rounded-lg border px-3 py-1 text-xs disabled:opacity-40"
|
|
674
|
+
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)" }}
|
|
675
|
+
>
|
|
676
|
+
{t("dashboard.prev_page")}
|
|
677
|
+
</button>
|
|
678
|
+
<span className="text-xs" style={{ color: "var(--muted-text)" }}>
|
|
679
|
+
{currentLogPage} / {totalLogPages}
|
|
680
|
+
</span>
|
|
681
|
+
<button
|
|
682
|
+
onClick={() => setLogPage((p) => Math.min(totalLogPages, p + 1))}
|
|
683
|
+
disabled={currentLogPage >= totalLogPages}
|
|
684
|
+
className="rounded-lg border px-3 py-1 text-xs disabled:opacity-40"
|
|
685
|
+
style={{ borderColor: "var(--card-border)", color: "var(--muted-text)" }}
|
|
686
|
+
>
|
|
687
|
+
{t("dashboard.next_page")}
|
|
688
|
+
</button>
|
|
689
|
+
</div>
|
|
690
|
+
</div>
|
|
691
|
+
)}
|
|
692
|
+
</>
|
|
539
693
|
)}
|
|
540
694
|
|
|
541
695
|
{tab === "provider" && (
|
|
@@ -543,29 +697,41 @@ export function DashboardPage() {
|
|
|
543
697
|
<thead>
|
|
544
698
|
<tr className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
545
699
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.provider")}</th>
|
|
546
|
-
<
|
|
547
|
-
<
|
|
548
|
-
<
|
|
549
|
-
<
|
|
550
|
-
<
|
|
551
|
-
<
|
|
700
|
+
<SortableTh label={t("dashboard.tokens")} sortKey="totalTokens" sort={providerSort} onSort={toggleSort(setProviderSort)} />
|
|
701
|
+
<SortableTh label={t("dashboard.input")} sortKey="totalInput" sort={providerSort} onSort={toggleSort(setProviderSort)} />
|
|
702
|
+
<SortableTh label={t("dashboard.output")} sortKey="totalOutput" sort={providerSort} onSort={toggleSort(setProviderSort)} />
|
|
703
|
+
<SortableTh label={t("dashboard.cost")} sortKey="totalCost" sort={providerSort} onSort={toggleSort(setProviderSort)} />
|
|
704
|
+
<SortableTh label={t("dashboard.requests")} sortKey="totalRequests" sort={providerSort} onSort={toggleSort(setProviderSort)} />
|
|
705
|
+
<SortableTh label={t("dashboard.models_count")} sortKey="modelCount" sort={providerSort} onSort={toggleSort(setProviderSort)} />
|
|
706
|
+
<th className="px-4 py-3 text-left font-medium w-44" style={{ color: "var(--muted-text)" }}>{t("dashboard.cost_share")}</th>
|
|
552
707
|
</tr>
|
|
553
708
|
</thead>
|
|
554
709
|
<tbody>
|
|
555
|
-
{
|
|
556
|
-
<tr><td colSpan={
|
|
710
|
+
{sortedProviders.length === 0 ? (
|
|
711
|
+
<tr><td colSpan={8} className="px-4 py-8 text-center" style={{ color: "var(--subtle-text)" }}>{t("dashboard.no_data")}</td></tr>
|
|
557
712
|
) : (
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
<
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
713
|
+
sortedProviders.map((p, i) => {
|
|
714
|
+
const pct = providerTotalCost > 0 ? (p.totalCost / providerTotalCost) * 100 : 0;
|
|
715
|
+
return (
|
|
716
|
+
<tr key={p.providerId} className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
717
|
+
<td className="px-4 py-2.5 font-medium" style={{ color: "var(--page-text)" }}>{p.providerId}</td>
|
|
718
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(p.totalTokens, lang)}</td>
|
|
719
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(p.totalInput, lang)}</td>
|
|
720
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(p.totalOutput, lang)}</td>
|
|
721
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{fmtCostCell(p.totalCost)}</td>
|
|
722
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{p.totalRequests}</td>
|
|
723
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{p.modelCount}</td>
|
|
724
|
+
<td className="px-4 py-2.5">
|
|
725
|
+
<div className="flex items-center gap-2">
|
|
726
|
+
<div className="h-1.5 flex-1 rounded-full" style={{ backgroundColor: "var(--card-border)" }}>
|
|
727
|
+
<div className="h-full rounded-full" style={{ width: `${pct}%`, backgroundColor: COLORS[i % COLORS.length] ?? "#3b82f6" }} />
|
|
728
|
+
</div>
|
|
729
|
+
<span className="text-xs w-11 text-right" style={{ color: "var(--subtle-text)" }}>{pct.toFixed(1)}%</span>
|
|
730
|
+
</div>
|
|
731
|
+
</td>
|
|
732
|
+
</tr>
|
|
733
|
+
);
|
|
734
|
+
})
|
|
569
735
|
)}
|
|
570
736
|
</tbody>
|
|
571
737
|
</table>
|
|
@@ -577,25 +743,25 @@ export function DashboardPage() {
|
|
|
577
743
|
<tr className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
578
744
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.model")}</th>
|
|
579
745
|
<th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.provider")}</th>
|
|
580
|
-
<
|
|
581
|
-
<
|
|
582
|
-
<
|
|
583
|
-
<
|
|
584
|
-
<
|
|
746
|
+
<SortableTh label={t("dashboard.tokens")} sortKey="totalTokens" sort={modelSort} onSort={toggleSort(setModelSort)} />
|
|
747
|
+
<SortableTh label={t("dashboard.input")} sortKey="totalInput" sort={modelSort} onSort={toggleSort(setModelSort)} />
|
|
748
|
+
<SortableTh label={t("dashboard.output")} sortKey="totalOutput" sort={modelSort} onSort={toggleSort(setModelSort)} />
|
|
749
|
+
<SortableTh label={t("dashboard.cost")} sortKey="totalCost" sort={modelSort} onSort={toggleSort(setModelSort)} />
|
|
750
|
+
<SortableTh label={t("dashboard.requests")} sortKey="totalRequests" sort={modelSort} onSort={toggleSort(setModelSort)} />
|
|
585
751
|
</tr>
|
|
586
752
|
</thead>
|
|
587
753
|
<tbody>
|
|
588
|
-
{
|
|
754
|
+
{sortedModels.length === 0 ? (
|
|
589
755
|
<tr><td colSpan={7} className="px-4 py-8 text-center" style={{ color: "var(--subtle-text)" }}>{t("dashboard.no_data")}</td></tr>
|
|
590
756
|
) : (
|
|
591
|
-
|
|
592
|
-
<tr key={
|
|
757
|
+
sortedModels.map((m) => (
|
|
758
|
+
<tr key={`${m.providerId}/${m.modelId}`} className="border-b" style={{ borderColor: "var(--card-border)" }}>
|
|
593
759
|
<td className="px-4 py-2.5 font-medium" style={{ color: "var(--page-text)" }}>{m.modelId}</td>
|
|
594
760
|
<td className="px-4 py-2.5" style={{ color: "var(--page-text)" }}>{m.providerId}</td>
|
|
595
761
|
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(m.totalTokens, lang)}</td>
|
|
596
762
|
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(m.totalInput, lang)}</td>
|
|
597
763
|
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(m.totalOutput, lang)}</td>
|
|
598
|
-
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{
|
|
764
|
+
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{fmtCostCell(m.totalCost)}</td>
|
|
599
765
|
<td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{m.totalRequests}</td>
|
|
600
766
|
</tr>
|
|
601
767
|
))
|
|
@@ -605,16 +771,8 @@ export function DashboardPage() {
|
|
|
605
771
|
)}
|
|
606
772
|
</div>
|
|
607
773
|
</div>
|
|
608
|
-
|
|
774
|
+
</div>
|
|
609
775
|
)}
|
|
610
776
|
</div>
|
|
611
777
|
);
|
|
612
778
|
}
|
|
613
|
-
|
|
614
|
-
function TablePlaceholder({ message }: { message: string }) {
|
|
615
|
-
return (
|
|
616
|
-
<div className="flex items-center justify-center py-12">
|
|
617
|
-
<p className="text-sm" style={{ color: "var(--subtle-text)" }}>{message}</p>
|
|
618
|
-
</div>
|
|
619
|
-
);
|
|
620
|
-
}
|
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
History,
|
|
7
7
|
Brain,
|
|
8
8
|
Globe,
|
|
9
|
+
Plug,
|
|
9
10
|
} from "lucide-react";
|
|
10
11
|
import { cn } from "@/lib/utils";
|
|
11
12
|
import { useTranslation, LANGUAGES } from "@/lib/i18n";
|
|
@@ -15,6 +16,7 @@ const navItems = [
|
|
|
15
16
|
{ to: "/", icon: LayoutDashboard, key: "nav.dashboard" },
|
|
16
17
|
{ to: "/sessions", icon: History, key: "nav.sessions" },
|
|
17
18
|
{ to: "/memory", icon: Brain, key: "nav.memory" },
|
|
19
|
+
{ to: "/providers", icon: Plug, key: "nav.providers_models" },
|
|
18
20
|
{ to: "/settings", icon: Settings, key: "nav.settings" },
|
|
19
21
|
];
|
|
20
22
|
|