@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.
@@ -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 { formatTokens, formatCost, formatNumber, cn, USD_TO_CNY } from "@/lib/utils";
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 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
- ];
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 CHART_LABELS: Record<string, string> = {
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) => { setData(d); setLoading(false); setLastUpdated(new Date().toLocaleTimeString()); })
241
- .catch(() => setLoading(false));
242
- }, [initialized, range, customFrom, customTo]);
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">· Last updated {lastUpdated}</span>}
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={currency === "USD" ? "Switch to CNY" : "Switch to USD"}
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="Refresh now"
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` : "Off"}
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.range.today")}
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
- {RANGE_OPTIONS.map((opt) => (
467
+ {RANGE_KEYS.map((key) => (
351
468
  <button
352
- key={opt.key}
353
- onClick={() => setRange(opt.key)}
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 === opt.key
473
+ range === key
357
474
  ? "text-white"
358
475
  : "hover:bg-gray-800/30"
359
476
  )}
360
- style={range === opt.key ? { backgroundColor: "#3b82f6", color: "#fff" } : { color: "var(--muted-text)" }}
477
+ style={range === key ? { backgroundColor: "#3b82f6", color: "#fff" } : { color: "var(--muted-text)" }}
361
478
  >
362
- {t("dashboard.range." + opt.key)}
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
- <table className="w-full text-xs">
500
- <thead>
501
- <tr className="border-b" style={{ borderColor: "var(--card-border)" }}>
502
- <th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.time")}</th>
503
- <th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.provider")}</th>
504
- <th className="px-4 py-3 text-left font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.model")}</th>
505
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.input")}</th>
506
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.output")}</th>
507
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.cost")}</th>
508
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.requests")}</th>
509
- <th className="px-4 py-3 text-center font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.status")}</th>
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
- data.requestLog.slice(0, 100).map((r, i) => (
521
- <tr key={i} className="border-b" style={{ borderColor: "var(--card-border)" }}>
522
- <td className="px-4 py-2.5 whitespace-nowrap" style={{ color: "var(--page-text)" }}>{r.timestamp}</td>
523
- <td className="px-4 py-2.5" style={{ color: "var(--page-text)" }}>{r.providerId}</td>
524
- <td className="px-4 py-2.5" style={{ color: "var(--page-text)" }}>{r.modelId}</td>
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
- </tbody>
538
- </table>
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
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>Tokens</th>
547
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.input")}</th>
548
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.output")}</th>
549
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.cost")}</th>
550
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.requests")}</th>
551
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>Models</th>
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
- {data.providerStats.length === 0 ? (
556
- <tr><td colSpan={7} className="px-4 py-8 text-center" style={{ color: "var(--subtle-text)" }}>{t("dashboard.no_data")}</td></tr>
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
- data.providerStats.map((p, i) => (
559
- <tr key={i} className="border-b" style={{ borderColor: "var(--card-border)" }}>
560
- <td className="px-4 py-2.5 font-medium" style={{ color: "var(--page-text)" }}>{p.providerId}</td>
561
- <td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(p.totalTokens, lang)}</td>
562
- <td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(p.totalInput, lang)}</td>
563
- <td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{formatTokensShort(p.totalOutput, lang)}</td>
564
- <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>
565
- <td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{p.totalRequests}</td>
566
- <td className="px-4 py-2.5 text-right font-mono" style={{ color: "var(--page-text)" }}>{p.modelCount}</td>
567
- </tr>
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
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>Tokens</th>
581
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.input")}</th>
582
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.output")}</th>
583
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.cost")}</th>
584
- <th className="px-4 py-3 text-right font-medium" style={{ color: "var(--muted-text)" }}>{t("dashboard.requests")}</th>
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
- {data.modelStats.length === 0 ? (
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
- data.modelStats.map((m, i) => (
592
- <tr key={i} className="border-b" style={{ borderColor: "var(--card-border)" }}>
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)" }}>{currency === "CNY" ? `¥${(m.totalCost * USD_TO_CNY).toFixed(4)}` : formatCostShort(m.totalCost)}</td>
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