@linxiraos/pi-stats 1.1.26 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/CHANGELOG.md +12 -5
  2. package/build.ts +4 -0
  3. package/dist/client/favicon.ico +0 -0
  4. package/dist/client/index.css +1 -1
  5. package/dist/client/index.html +1 -0
  6. package/dist/client/index.js +96 -96
  7. package/dist/types/aggregator.d.ts +4 -1
  8. package/dist/types/client/api.d.ts +5 -1
  9. package/dist/types/client/app/nav.d.ts +5 -2
  10. package/dist/types/client/data/formatters.d.ts +7 -2
  11. package/dist/types/client/data/live.d.ts +16 -11
  12. package/dist/types/client/data/query-store.d.ts +39 -0
  13. package/dist/types/client/data/query.d.ts +10 -5
  14. package/dist/types/client/data/range.d.ts +4 -3
  15. package/dist/types/client/i18n/format.d.ts +9 -0
  16. package/dist/types/client/i18n/index.d.ts +18 -0
  17. package/dist/types/client/i18n/messages/charts.d.ts +8 -0
  18. package/dist/types/client/i18n/messages/common.d.ts +153 -0
  19. package/dist/types/client/i18n/messages/costs.d.ts +92 -0
  20. package/dist/types/client/i18n/messages/en.d.ts +1255 -0
  21. package/dist/types/client/i18n/messages/errors.d.ts +78 -0
  22. package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
  23. package/dist/types/client/i18n/messages/gain.d.ts +54 -0
  24. package/dist/types/client/i18n/messages/models.d.ts +94 -0
  25. package/dist/types/client/i18n/messages/nav.d.ts +46 -0
  26. package/dist/types/client/i18n/messages/overview.d.ts +80 -0
  27. package/dist/types/client/i18n/messages/projects.d.ts +84 -0
  28. package/dist/types/client/i18n/messages/providers.d.ts +142 -0
  29. package/dist/types/client/i18n/messages/requests.d.ts +72 -0
  30. package/dist/types/client/i18n/messages/tools.d.ts +119 -0
  31. package/dist/types/client/i18n/messages/traces.d.ts +144 -0
  32. package/dist/types/client/i18n/messages/ui.d.ts +44 -0
  33. package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
  34. package/dist/types/client/i18n/types.d.ts +15 -0
  35. package/dist/types/client/traces/Minimap.d.ts +2 -0
  36. package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
  37. package/dist/types/client/traces/TranscriptList.d.ts +3 -1
  38. package/dist/types/db.d.ts +14 -21
  39. package/dist/types/embedded-client.d.ts +5 -0
  40. package/dist/types/frustration.d.ts +18 -9
  41. package/dist/types/live.d.ts +5 -3
  42. package/dist/types/locale.d.ts +16 -0
  43. package/dist/types/parser.d.ts +0 -17
  44. package/dist/types/rollup.d.ts +9 -1
  45. package/dist/types/server.d.ts +6 -0
  46. package/dist/types/shared-types.d.ts +7 -0
  47. package/dist/types/trace.d.ts +18 -6
  48. package/dist/types/types.d.ts +13 -1
  49. package/dist/types/user-metrics.d.ts +23 -1
  50. package/package.json +5 -5
  51. package/src/aggregator.ts +31 -11
  52. package/src/client/api.ts +40 -5
  53. package/src/client/app/LiveChip.tsx +14 -13
  54. package/src/client/app/Shell.tsx +19 -13
  55. package/src/client/app/ThemeToggle.tsx +4 -3
  56. package/src/client/app/nav.ts +19 -16
  57. package/src/client/data/formatters.ts +80 -13
  58. package/src/client/data/live.tsx +85 -45
  59. package/src/client/data/query-store.ts +108 -0
  60. package/src/client/data/query.ts +47 -56
  61. package/src/client/data/range.ts +34 -14
  62. package/src/client/data/series.ts +7 -1
  63. package/src/client/favicon.ico +0 -0
  64. package/src/client/i18n/format.ts +28 -0
  65. package/src/client/i18n/index.ts +53 -0
  66. package/src/client/i18n/messages/charts.ts +8 -0
  67. package/src/client/i18n/messages/common.ts +157 -0
  68. package/src/client/i18n/messages/costs.ts +104 -0
  69. package/src/client/i18n/messages/en.ts +40 -0
  70. package/src/client/i18n/messages/errors.ts +80 -0
  71. package/src/client/i18n/messages/frustration.ts +198 -0
  72. package/src/client/i18n/messages/gain.ts +54 -0
  73. package/src/client/i18n/messages/models.ts +94 -0
  74. package/src/client/i18n/messages/nav.ts +46 -0
  75. package/src/client/i18n/messages/overview.ts +90 -0
  76. package/src/client/i18n/messages/projects.ts +84 -0
  77. package/src/client/i18n/messages/providers.ts +165 -0
  78. package/src/client/i18n/messages/requests.ts +73 -0
  79. package/src/client/i18n/messages/tools.ts +120 -0
  80. package/src/client/i18n/messages/traces.ts +160 -0
  81. package/src/client/i18n/messages/ui.ts +46 -0
  82. package/src/client/i18n/messages/zh-CN.ts +43 -0
  83. package/src/client/i18n/types.ts +19 -0
  84. package/src/client/index.tsx +4 -0
  85. package/src/client/routes/CostsRoute.tsx +61 -48
  86. package/src/client/routes/ErrorsRoute.tsx +71 -47
  87. package/src/client/routes/FrustrationRoute.tsx +131 -116
  88. package/src/client/routes/GainRoute.tsx +42 -30
  89. package/src/client/routes/ModelsRoute.tsx +90 -73
  90. package/src/client/routes/OverviewRoute.tsx +109 -63
  91. package/src/client/routes/ProjectsRoute.tsx +73 -46
  92. package/src/client/routes/ProvidersRoute.tsx +151 -114
  93. package/src/client/routes/RequestsRoute.tsx +69 -46
  94. package/src/client/routes/ToolsRoute.tsx +108 -94
  95. package/src/client/routes/TracesRoute.tsx +25 -23
  96. package/src/client/styles.css +5 -2
  97. package/src/client/traces/AggregatesPanel.tsx +19 -8
  98. package/src/client/traces/Minimap.tsx +65 -27
  99. package/src/client/traces/SpanDrawer.tsx +46 -21
  100. package/src/client/traces/SummaryStrip.tsx +27 -11
  101. package/src/client/traces/TimelineCanvas.tsx +245 -116
  102. package/src/client/traces/TraceView.tsx +51 -36
  103. package/src/client/traces/TranscriptList.tsx +20 -11
  104. package/src/client/traces/traces.css +26 -0
  105. package/src/client/ui/JsonBlock.tsx +3 -2
  106. package/src/client/ui/Modal.tsx +3 -2
  107. package/src/client/ui/RequestDrawer.tsx +33 -30
  108. package/src/client/ui/SearchInput.tsx +2 -1
  109. package/src/client/ui/States.tsx +3 -2
  110. package/src/client/ui/Table.tsx +7 -3
  111. package/src/db.ts +150 -135
  112. package/src/embedded-client.ts +9 -0
  113. package/src/frustration.ts +9 -30
  114. package/src/index.ts +2 -1
  115. package/src/live.ts +36 -11
  116. package/src/locale.ts +29 -0
  117. package/src/parser.ts +37 -72
  118. package/src/port-conflict.ts +1 -1
  119. package/src/prompts/judge/frustration.json +19 -0
  120. package/src/rollup.ts +41 -20
  121. package/src/server.ts +67 -27
  122. package/src/shared-types.ts +7 -0
  123. package/src/trace.ts +54 -40
  124. package/src/types.ts +13 -1
  125. package/src/user-metrics.ts +220 -6
package/src/aggregator.ts CHANGED
@@ -85,11 +85,14 @@ export async function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T>)
85
85
  * `current` is the number of files completed (skipped + parsed),
86
86
  * `total` is the size of the work set. `processed` is the running total
87
87
  * of inserted rows. Parsed files are reported only after their batch commits.
88
+ * `changes` is the running total of stored rows the sync inserted, updated or
89
+ * deleted (0 while every transcript is unchanged).
88
90
  */
89
91
  export interface SyncProgress {
90
92
  current: number;
91
93
  total: number;
92
94
  processed: number;
95
+ changes: number;
93
96
  sessionFile: string;
94
97
  }
95
98
 
@@ -111,7 +114,7 @@ export interface SyncOptions {
111
114
 
112
115
  function defaultWorkerCount(): number {
113
116
  // Bun 1.3.x can abort the macOS process when stats sync workers re-enter
114
- // the compiled `omp` binary. Keep macOS on the documented serial path.
117
+ // the compiled `zetacode` binary. Keep macOS on the documented serial path.
115
118
  if (process.platform === "darwin") return 1;
116
119
  // `navigator.hardwareConcurrency` is the portable answer in Bun; fall
117
120
  // back to a small fixed pool if it's somehow unavailable.
@@ -188,7 +191,7 @@ function dispatch(handle: WorkerHandle, request: SyncWorkerRequest): Promise<Par
188
191
 
189
192
  /**
190
193
  * Smoke test: spawns one sync worker, pings it, asserts the pong response,
191
- * then terminates. Used by `omp --smoke-test` so the install-method CI jobs
194
+ * then terminates. Used by `zetacode --smoke-test` so the install-method CI jobs
192
195
  * catch the silent worker-load failure that hit compiled binaries in #1011
193
196
  * and #1027 — neither `--version` nor `stats --summary` exercises the worker
194
197
  * spawn path on a fresh install (no session files = early return), so a
@@ -297,6 +300,7 @@ async function syncAllSessionsLocked(
297
300
 
298
301
  const files = opts?.files ?? (await orderForIngest(await listAllSessionFiles()));
299
302
  let totalProcessed = 0;
303
+ let totalChanges = 0;
300
304
  let filesProcessed = 0;
301
305
  let completed = 0;
302
306
  let cursor = 0;
@@ -314,6 +318,7 @@ async function syncAllSessionsLocked(
314
318
  current: completed,
315
319
  total: files.length,
316
320
  processed: totalProcessed,
321
+ changes: totalChanges,
317
322
  sessionFile,
318
323
  });
319
324
  };
@@ -325,6 +330,7 @@ async function syncAllSessionsLocked(
325
330
  pending = [];
326
331
  pendingRows = 0;
327
332
  totalProcessed += applied.processed;
333
+ totalChanges += applied.changes;
328
334
  filesProcessed += applied.files;
329
335
  reconcile ||= applied.reconcile;
330
336
  // Report only durable progress: callbacks may interrupt the sync.
@@ -358,9 +364,9 @@ async function syncAllSessionsLocked(
358
364
  metadataEnd = index + batch.length;
359
365
  metadata = Promise.all(
360
366
  batch.map(async sessionFile => {
367
+ const stored = offsets.get(sessionFile);
361
368
  try {
362
- const fileStats = await fs.promises.stat(sessionFile);
363
- return { fileStats, stored: offsets.get(sessionFile) };
369
+ return { fileStats: await fs.promises.stat(sessionFile), stored };
364
370
  } catch {
365
371
  return {};
366
372
  }
@@ -415,7 +421,13 @@ async function syncAllSessionsLocked(
415
421
  if (pending.length >= SYNC_BATCH_FILES || pendingRows >= SYNC_BATCH_ROWS) flush();
416
422
  };
417
423
 
418
- const requestedWorkers = Math.max(1, Math.floor(opts?.workers ?? defaultWorkerCount()));
424
+ let requestedWorkers = Math.max(1, Math.floor(opts?.workers ?? defaultWorkerCount()));
425
+ // A few already-tracked transcripts (the watcher's usual work) need only
426
+ // kilobyte tail reads: parsing them inline beats starting a worker.
427
+ if (requestedWorkers > 1 && opts?.workers === undefined && files.length <= SYNC_INLINE_READS) {
428
+ const offsets = getFileOffsets([...files]);
429
+ if (files.every(file => offsets.get(file)?.parserState)) requestedWorkers = 1;
430
+ }
419
431
  if (requestedWorkers === 1) {
420
432
  for (let start = 0; start < files.length; start += SYNC_INLINE_READS) {
421
433
  const results = await Promise.allSettled(
@@ -437,14 +449,20 @@ async function syncAllSessionsLocked(
437
449
 
438
450
  const handles: WorkerHandle[] = [];
439
451
 
440
- async function drain(handle: WorkerHandle): Promise<void> {
452
+ // Each lane spawns its worker on its first real parse, so unchanged transcripts cost no worker.
453
+ async function drain(): Promise<void> {
454
+ let handle: WorkerHandle | null = null;
441
455
  try {
442
456
  while (!failed) {
443
457
  const idx = cursor++;
444
458
  if (idx >= files.length) return;
445
- const parsed = await prepareFile(idx, (file, fromOffset, parserState, replay) =>
446
- dispatch(handle, { sessionFile: file, fromOffset, parserState, replay }),
447
- );
459
+ const parsed = await prepareFile(idx, (file, fromOffset, parserState, replay) => {
460
+ if (!handle) {
461
+ handle = spawnWorker();
462
+ handles.push(handle);
463
+ }
464
+ return dispatch(handle, { sessionFile: file, fromOffset, parserState, replay });
465
+ });
448
466
  if (!failed) acceptFile(files[idx], parsed);
449
467
  }
450
468
  } catch (error) {
@@ -454,9 +472,8 @@ async function syncAllSessionsLocked(
454
472
  }
455
473
 
456
474
  try {
457
- for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
458
475
  // Drain in-flight work before releasing the sync lock, even after a failed batch.
459
- const results = await Promise.allSettled(handles.map(drain));
476
+ const results = await Promise.allSettled(Array.from({ length: poolSize }, drain));
460
477
  for (const result of results) {
461
478
  if (result.status === "rejected") throw result.reason;
462
479
  }
@@ -565,6 +582,9 @@ export async function getRequestDetails(id: number): Promise<RequestDetails | nu
565
582
  if (!msg) return null;
566
583
 
567
584
  const entry = await getSessionEntry(msg.sessionFile, msg.entryId);
585
+ // Role-model attempts (judge, auto-thinking, …) journal only a `model_usage`
586
+ // record: usage and outcome, no request or response payload.
587
+ if (entry?.type === "model_usage") return { ...msg, messages: [entry], output: null };
568
588
  if (entry?.type !== "message" || !("message" in entry)) return null;
569
589
 
570
590
  return {
package/src/client/api.ts CHANGED
@@ -45,12 +45,34 @@ async function readErrorMessage(res: Response, endpoint: string): Promise<string
45
45
  return `HTTP error ${res.status} on ${endpoint}`;
46
46
  }
47
47
 
48
- async function fetchJson<T>(endpoint: string, options?: RequestInit): Promise<T> {
49
- const res = await fetch(endpoint, options);
48
+ /**
49
+ * Response validators keyed by the parsed body they describe. Revalidating a
50
+ * body sends its ETag; the entry dies with the body when callers drop it.
51
+ */
52
+ const validators = new WeakMap<object, string>();
53
+
54
+ /**
55
+ * Fetch `endpoint` as JSON, revalidating `previous` when it came from an
56
+ * ETag-bearing response: a 304 resolves to `previous` itself (same reference,
57
+ * no re-parse).
58
+ */
59
+ async function fetchJson<T>(endpoint: string, options?: RequestInit, previous?: T): Promise<T> {
60
+ const etag = typeof previous === "object" && previous !== null ? validators.get(previous) : undefined;
61
+ let init = options;
62
+ if (etag) {
63
+ const headers = new Headers(options?.headers);
64
+ headers.set("If-None-Match", etag);
65
+ init = { ...options, headers };
66
+ }
67
+ const res = await fetch(endpoint, init);
68
+ if (res.status === 304 && etag) return previous as T;
50
69
  if (!res.ok) {
51
70
  throw new ApiError(res.status, endpoint, await readErrorMessage(res, endpoint));
52
71
  }
53
- return res.json() as Promise<T>;
72
+ const data = (await res.json()) as T;
73
+ const nextEtag = res.headers.get("ETag");
74
+ if (nextEtag && typeof data === "object" && data !== null) validators.set(data, nextEtag);
75
+ return data;
54
76
  }
55
77
 
56
78
  /**
@@ -182,8 +204,21 @@ export async function getSessions(limit = 100, q?: string, signal?: AbortSignal)
182
204
  return fetchJson<SessionSummary[]>(`${API_BASE}/sessions?${params}`, { signal });
183
205
  }
184
206
 
185
- export async function getSessionTrace(file: string, signal?: AbortSignal): Promise<SessionTrace> {
186
- return fetchJson<SessionTrace>(`${API_BASE}/session/trace?file=${encodeURIComponent(file)}`, { signal });
207
+ /**
208
+ * Pass the last trace for `file` as `previous` to revalidate it: an unchanged
209
+ * trace resolves to that same object without downloading or parsing the body.
210
+ */
211
+ export async function getSessionTrace(
212
+ file: string,
213
+ signal?: AbortSignal,
214
+ previous?: SessionTrace,
215
+ ): Promise<SessionTrace> {
216
+ // `no-store`: validation is ours (above); the browser cache would only keep a second multi-MB copy.
217
+ return fetchJson<SessionTrace>(
218
+ `${API_BASE}/session/trace?file=${encodeURIComponent(file)}`,
219
+ { signal, cache: "no-store" },
220
+ previous,
221
+ );
187
222
  }
188
223
 
189
224
  /** Fetch one full journal entry for the span drawer. Entries are opaque JSON. */
@@ -1,12 +1,13 @@
1
1
  import { RefreshCw } from "lucide-react";
2
2
  import { type ReactNode, useEffect, useState } from "react";
3
3
  import { formatInteger } from "../data/formatters";
4
- import { useLive } from "../data/live";
4
+ import { t } from "../i18n";
5
+ import { useLiveStatus } from "../data/live";
5
6
  import { Dot } from "../ui";
6
7
 
7
8
  /** Topbar ingest status; click to sync now. */
8
9
  export function LiveChip() {
9
- const { sync, indexingHours, connected, requestSync } = useLive();
10
+ const { sync, indexingHours, connected, requestSync } = useLiveStatus();
10
11
  const now = useNow(sync.phase === "idle" ? 15_000 : null);
11
12
 
12
13
  let body: ReactNode;
@@ -15,15 +16,15 @@ export function LiveChip() {
15
16
  body = (
16
17
  <>
17
18
  <Dot tone="warn" />
18
- <span>Reconnecting</span>
19
+ <span>{t("shell.reconnecting")}</span>
19
20
  </>
20
21
  );
21
- title = "Lost the live connection to the stats server; retrying";
22
+ title = t("shell.lost-connection");
22
23
  } else if (sync.phase === "syncing") {
23
24
  body = (
24
25
  <>
25
26
  <RefreshCw size={13} className="spin" />
26
- <span>Syncing</span>
27
+ <span>{t("shell.syncing")}</span>
27
28
  {sync.total > 0 && (
28
29
  <span className="live-chip-count">
29
30
  {formatInteger(sync.current)}/{formatInteger(sync.total)}
@@ -31,33 +32,33 @@ export function LiveChip() {
31
32
  )}
32
33
  </>
33
34
  );
34
- title = "Ingesting session files";
35
+ title = t("shell.ingesting");
35
36
  } else if (sync.phase === "error") {
36
37
  body = (
37
38
  <>
38
39
  <Dot tone="bad" />
39
- <span>Sync failed</span>
40
+ <span>{t("shell.sync-failed")}</span>
40
41
  </>
41
42
  );
42
- title = `${sync.error ?? "Sync failed"} — click to retry`;
43
+ title = `${sync.error ?? t("shell.sync-failed")} — click to retry`;
43
44
  } else if (indexingHours > INDEXING_VISIBLE_HOURS) {
44
45
  body = (
45
46
  <>
46
47
  <RefreshCw size={13} className="spin" />
47
- <span>Indexing</span>
48
+ <span>{t("shell.indexing")}</span>
48
49
  <span className="live-chip-count">{formatInteger(indexingHours)}h left</span>
49
50
  </>
50
51
  );
51
- title = "Building hourly rollups; older ranges fill in as it goes";
52
+ title = t("shell.building-rollups");
52
53
  } else {
53
54
  body = (
54
55
  <>
55
56
  <Dot tone="live" pulse />
56
- <span>Live</span>
57
+ <span>{t("shell.live")}</span>
57
58
  {sync.lastSyncedAt !== null && <span className="live-chip-count">{ago(now - sync.lastSyncedAt)}</span>}
58
59
  </>
59
60
  );
60
- title = "Click to sync session files now";
61
+ title = t("shell.sync-now");
61
62
  }
62
63
 
63
64
  return (
@@ -89,7 +90,7 @@ function useNow(intervalMs: number | null): number {
89
90
 
90
91
  function ago(ms: number): string {
91
92
  const s = Math.max(0, Math.round(ms / 1000));
92
- if (s < 45) return "just now";
93
+ if (s < 45) return t("shell.just-now");
93
94
  const m = Math.round(s / 60);
94
95
  if (m < 60) return `${m}m ago`;
95
96
  const h = Math.round(m / 60);
@@ -1,7 +1,8 @@
1
1
  import { Menu } from "lucide-react";
2
2
  import { type ReactNode, useEffect, useState } from "react";
3
3
  import { rangeMeta, TIME_RANGES } from "../data/range";
4
- import { useLive } from "../data/live";
4
+ import { t } from "../i18n";
5
+ import { useLiveStatus } from "../data/live";
5
6
  import type { TimeRange } from "../types";
6
7
  import { Segmented } from "../ui";
7
8
  import { LiveChip } from "./LiveChip";
@@ -18,8 +19,8 @@ export interface ShellProps {
18
19
 
19
20
  const RANGE_OPTIONS = TIME_RANGES.map((range, i) => ({
20
21
  value: range,
21
- label: rangeMeta(range).label,
22
- title: `${rangeMeta(range).windowLabel} (${i + 1})`,
22
+ label: t(rangeMeta(range).labelKey),
23
+ title: `${t(rangeMeta(range).windowKey)} (${i + 1})`,
23
24
  }));
24
25
 
25
26
  /**
@@ -30,7 +31,7 @@ const RANGE_OPTIONS = TIME_RANGES.map((range, i) => ({
30
31
  export function Shell({ section, onSectionChange, range, onRangeChange, children }: ShellProps) {
31
32
  const [menuOpen, setMenuOpen] = useState(false);
32
33
  const scrolled = useScrolled();
33
- const { sync } = useLive();
34
+ const { sync } = useLiveStatus();
34
35
  useShortcuts(onSectionChange, onRangeChange);
35
36
 
36
37
  const go = (next: DashboardSection) => {
@@ -51,7 +52,7 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
51
52
  data-variant="ghost"
52
53
  data-icon="true"
53
54
  onClick={() => setMenuOpen(true)}
54
- aria-label="Open navigation"
55
+ aria-label={t("nav.open-navigation")}
55
56
  >
56
57
  <Menu size={16} />
57
58
  </button>
@@ -78,14 +79,19 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
78
79
  </svg>
79
80
  <span>zeta</span>
80
81
  <span className="topbar-slash">/</span>
81
- <span className="topbar-title">stats</span>
82
+ <span className="topbar-title">{t("nav.breadcrumb-stats")}</span>
82
83
  </div>
83
84
  <div className="topbar-spacer" />
84
85
  <div className="topbar-actions">
85
86
  <span className="topbar-hide-narrow">
86
87
  <LiveChip />
87
88
  </span>
88
- <Segmented options={RANGE_OPTIONS} value={range} onChange={onRangeChange} aria-label="Time range" />
89
+ <Segmented
90
+ options={RANGE_OPTIONS}
91
+ value={range}
92
+ onChange={onRangeChange}
93
+ aria-label={t("common.time-range")}
94
+ />
89
95
  <ThemeToggle />
90
96
  </div>
91
97
  {progress !== undefined && (
@@ -100,10 +106,10 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
100
106
  </header>
101
107
 
102
108
  {menuOpen && <div className="drawer-scrim" onClick={() => setMenuOpen(false)} role="presentation" />}
103
- <nav className="sidebar" data-open={menuOpen} aria-label="Pages">
109
+ <nav className="sidebar" data-open={menuOpen} aria-label={t("nav.pages")}>
104
110
  {NAV.map(group => (
105
- <div key={group.heading} className="nav-group">
106
- <div className="nav-heading">{group.heading}</div>
111
+ <div key={group.headingKey} className="nav-group">
112
+ <div className="nav-heading">{t(group.headingKey)}</div>
107
113
  {group.items.map(item => (
108
114
  <button
109
115
  key={item.id}
@@ -114,7 +120,7 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
114
120
  onClick={() => go(item.id)}
115
121
  >
116
122
  <item.icon size={15} />
117
- <span className="nav-row-label">{item.label}</span>
123
+ <span className="nav-row-label">{t(item.labelKey)}</span>
118
124
  <kbd>G {item.hotkey.toUpperCase()}</kbd>
119
125
  </button>
120
126
  ))}
@@ -122,10 +128,10 @@ export function Shell({ section, onSectionChange, range, onRangeChange, children
122
128
  ))}
123
129
  <div className="sidebar-foot micro">
124
130
  <span>
125
- <span className="kbd">1</span>–<span className="kbd">6</span> range
131
+ <span className="kbd">1</span>–<span className="kbd">6</span> {t("shell.range-suffix")}
126
132
  </span>
127
133
  <span>
128
- <span className="kbd">G</span> then letter to jump
134
+ <span className="kbd">G</span> {t("shell.jump-hint")}
129
135
  </span>
130
136
  </div>
131
137
  </nav>
@@ -1,5 +1,6 @@
1
1
  import { type LucideIcon, Monitor, Moon, Sun } from "lucide-react";
2
2
  import { type ThemePreference, useThemePreference } from "../useSystemTheme";
3
+ import { t } from "../i18n";
3
4
 
4
5
  const NEXT_PREFERENCE: Record<ThemePreference, ThemePreference> = {
5
6
  system: "light",
@@ -14,9 +15,9 @@ const PREFERENCE_ICON: Record<ThemePreference, LucideIcon> = {
14
15
  };
15
16
 
16
17
  const PREFERENCE_LABEL: Record<ThemePreference, string> = {
17
- system: "System theme",
18
- light: "Light theme",
19
- dark: "Dark theme",
18
+ system: t("shell.system-theme"),
19
+ light: t("shell.light-theme"),
20
+ dark: t("shell.dark-theme"),
20
21
  };
21
22
 
22
23
  /** Cycles system → light → dark. */
@@ -12,6 +12,7 @@ import {
12
12
  SquareChartGantt,
13
13
  Wrench,
14
14
  } from "lucide-react";
15
+ import type { MessageKey } from "../i18n";
15
16
 
16
17
  export type DashboardSection =
17
18
  | "overview"
@@ -28,43 +29,45 @@ export type DashboardSection =
28
29
 
29
30
  export interface NavItem {
30
31
  id: DashboardSection;
31
- label: string;
32
+ /** Message key of the sidebar label; render through `t`. */
33
+ labelKey: MessageKey;
32
34
  icon: LucideIcon;
33
35
  /** Second key of the `g <key>` jump shortcut. */
34
36
  hotkey: string;
35
37
  }
36
38
 
37
39
  export interface NavGroup {
38
- heading: string;
40
+ /** Message key of the group heading; render through `t`. */
41
+ headingKey: MessageKey;
39
42
  items: readonly NavItem[];
40
43
  }
41
44
 
42
45
  /** Sidebar structure; the order here is the order on screen. */
43
46
  export const NAV: readonly NavGroup[] = [
44
47
  {
45
- heading: "Usage",
48
+ headingKey: "nav.usage",
46
49
  items: [
47
- { id: "overview", label: "Overview", icon: LayoutGrid, hotkey: "o" },
48
- { id: "models", label: "Models", icon: Cpu, hotkey: "m" },
49
- { id: "providers", label: "Providers", icon: PlugZap, hotkey: "p" },
50
- { id: "costs", label: "Costs", icon: Coins, hotkey: "c" },
50
+ { id: "overview", labelKey: "nav.overview", icon: LayoutGrid, hotkey: "o" },
51
+ { id: "models", labelKey: "nav.models", icon: Cpu, hotkey: "m" },
52
+ { id: "providers", labelKey: "nav.providers", icon: PlugZap, hotkey: "p" },
53
+ { id: "costs", labelKey: "nav.costs", icon: Coins, hotkey: "c" },
51
54
  ],
52
55
  },
53
56
  {
54
- heading: "Activity",
57
+ headingKey: "nav.activity",
55
58
  items: [
56
- { id: "requests", label: "Requests", icon: Activity, hotkey: "r" },
57
- { id: "errors", label: "Errors", icon: CircleAlert, hotkey: "e" },
58
- { id: "traces", label: "Traces", icon: SquareChartGantt, hotkey: "t" },
59
+ { id: "requests", labelKey: "nav.requests", icon: Activity, hotkey: "r" },
60
+ { id: "errors", labelKey: "nav.errors", icon: CircleAlert, hotkey: "e" },
61
+ { id: "traces", labelKey: "nav.traces", icon: SquareChartGantt, hotkey: "t" },
59
62
  ],
60
63
  },
61
64
  {
62
- heading: "Insights",
65
+ headingKey: "nav.insights",
63
66
  items: [
64
- { id: "tools", label: "Tools", icon: Wrench, hotkey: "l" },
65
- { id: "frustration", label: "Frustration", icon: Frown, hotkey: "f" },
66
- { id: "projects", label: "Projects", icon: FolderGit2, hotkey: "j" },
67
- { id: "gain", label: "Gain", icon: Sparkles, hotkey: "g" },
67
+ { id: "tools", labelKey: "nav.tools", icon: Wrench, hotkey: "l" },
68
+ { id: "frustration", labelKey: "nav.frustration", icon: Frown, hotkey: "f" },
69
+ { id: "projects", labelKey: "nav.projects", icon: FolderGit2, hotkey: "j" },
70
+ { id: "gain", labelKey: "nav.gain", icon: Sparkles, hotkey: "g" },
68
71
  ],
69
72
  },
70
73
  ];
@@ -1,25 +1,29 @@
1
- import { format, formatDistanceToNow } from "@linxiraos/pi-utils/dates";
1
+ import { getLocale, t } from "../i18n";
2
2
  import type { MessageStats } from "../types";
3
3
 
4
4
  /**
5
- * Every number on the dashboard uses one fixed locale. Labels, percents
6
- * (`toFixed`), durations and dates are English, so following the browser
7
- * locale mixed conventions (e.g. `1,4 Mr` beside `97.0%` on a tr-TR browser).
5
+ * One locale drives every number and date, and it is the *product's* — the
6
+ * host CLI's language injected into the page, not the browser's. Mixing
7
+ * conventions (e.g. `1,4 Mr` beside `97.0%` on a tr-TR browser) stays
8
+ * impossible; the difference from the pre-i18n build is only that a `zh-CN`
9
+ * dashboard now formats in `zh-CN` throughout.
8
10
  */
9
- const NUMBER_LOCALE = "en-US";
11
+ function numberLocale(): string {
12
+ return getLocale();
13
+ }
10
14
 
11
15
  export function formatInteger(value: number): string {
12
- return value.toLocaleString(NUMBER_LOCALE);
16
+ return value.toLocaleString(numberLocale());
13
17
  }
14
18
 
15
19
  export function formatCompact(value: number): string {
16
- return value.toLocaleString(NUMBER_LOCALE, { notation: "compact" });
20
+ return value.toLocaleString(numberLocale(), { notation: "compact" });
17
21
  }
18
22
 
19
23
  export function formatCost(value: number, digits?: number): string {
20
24
  if (value === 0) return "$0";
21
25
  const fractionDigits = digits !== undefined ? digits : value > 0 && value < 0.01 ? 4 : 2;
22
- return `$${value.toLocaleString(NUMBER_LOCALE, {
26
+ return `$${value.toLocaleString(numberLocale(), {
23
27
  minimumFractionDigits: fractionDigits,
24
28
  maximumFractionDigits: fractionDigits,
25
29
  })}`;
@@ -56,6 +60,13 @@ export function formatPercent(value: number, digits = 1): string {
56
60
  return `${(value * 100).toFixed(digits)}%`;
57
61
  }
58
62
 
63
+ /** Show small nonzero failure rates without rounding them to zero. */
64
+ export function formatErrorRate(value: number): string {
65
+ const percent = value * 100;
66
+ if (percent > 0 && percent < 0.005) return "<0.01%";
67
+ return formatPercent(value, percent > 0 && percent < 0.1 ? 2 : 1);
68
+ }
69
+
59
70
  export function formatDurationMs(value: number | null, digits?: number): string {
60
71
  if (value === null) return "-";
61
72
  const sec = value / 1000;
@@ -76,18 +87,74 @@ export function formatTokensPerSecond(value: number | null): string {
76
87
  return value.toFixed(1);
77
88
  }
78
89
 
79
- export function formatRelativeTime(timestamp: number): string {
80
- return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
90
+ const RELATIVE_UNITS: readonly [Intl.RelativeTimeFormatUnit, number][] = [
91
+ ["second", 1_000],
92
+ ["minute", 60_000],
93
+ ["hour", 3_600_000],
94
+ ["day", 86_400_000],
95
+ ["month", 2_629_800_000],
96
+ ["year", 31_557_600_000],
97
+ ];
98
+
99
+ const relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();
100
+ const dateFormatters = new Map<string, Intl.DateTimeFormat>();
101
+
102
+ /** "2 minutes ago" / "2 分钟前" — the ladder picks the largest unit the delta reaches. */
103
+ export function formatRelativeTime(timestamp: number, now = Date.now()): string {
104
+ const locale = getLocale();
105
+ let formatter = relativeFormatters.get(locale);
106
+ if (!formatter) {
107
+ formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
108
+ relativeFormatters.set(locale, formatter);
109
+ }
110
+ const delta = timestamp - now;
111
+ let unitIndex = 0;
112
+ for (let i = 1; i < RELATIVE_UNITS.length; i++) {
113
+ if (Math.abs(delta) >= RELATIVE_UNITS[i][1]) unitIndex = i;
114
+ }
115
+ const [unit, ms] = RELATIVE_UNITS[unitIndex];
116
+ return formatter.format(Math.round(delta / ms), unit);
117
+ }
118
+
119
+ /** Local month/day plus minute ("Sep 28 14:03" / "9月28日 14:03") for chart range captions. */
120
+ export function formatDateTimeShort(timestamp: number): string {
121
+ const locale = getLocale();
122
+ const key = `${locale}:mdhm`;
123
+ let formatter = dateFormatters.get(key);
124
+ if (!formatter) {
125
+ formatter = new Intl.DateTimeFormat(locale, {
126
+ month: "short",
127
+ day: "numeric",
128
+ hour: "2-digit",
129
+ minute: "2-digit",
130
+ hourCycle: "h23",
131
+ });
132
+ dateFormatters.set(key, formatter);
133
+ }
134
+ return formatter.format(timestamp);
81
135
  }
82
136
 
83
- /** Absolute local date and time to the second ("Sep 28, 14:03:22"). */
137
+ /** Absolute local date and time to the second ("Sep 28, 14:03:22" / "9月28日 14:03:22"). */
84
138
  export function formatTimestamp(timestamp: number): string {
85
- return format(new Date(timestamp), "MMM d, HH:mm:ss");
139
+ const locale = getLocale();
140
+ let formatter = dateFormatters.get(locale);
141
+ if (!formatter) {
142
+ formatter = new Intl.DateTimeFormat(locale, {
143
+ month: "short",
144
+ day: "numeric",
145
+ hour: "2-digit",
146
+ minute: "2-digit",
147
+ second: "2-digit",
148
+ hourCycle: "h23",
149
+ });
150
+ dateFormatters.set(locale, formatter);
151
+ }
152
+ return formatter.format(timestamp);
86
153
  }
87
154
 
88
155
  /** Project folder as stored by ingest (`/work-pi/`), without the slashes. */
89
156
  export function formatFolder(folder: string): string {
90
- return folder.replace(/^\/+|\/+$/g, "") || "(root)";
157
+ return folder.replace(/^\/+|\/+$/g, "") || t("common.root");
91
158
  }
92
159
 
93
160
  export function formatBytes(value: number): string {