@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.
- package/CHANGELOG.md +12 -5
- package/build.ts +4 -0
- package/dist/client/favicon.ico +0 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +1 -0
- package/dist/client/index.js +96 -96
- package/dist/types/aggregator.d.ts +4 -1
- package/dist/types/client/api.d.ts +5 -1
- package/dist/types/client/app/nav.d.ts +5 -2
- package/dist/types/client/data/formatters.d.ts +7 -2
- package/dist/types/client/data/live.d.ts +16 -11
- package/dist/types/client/data/query-store.d.ts +39 -0
- package/dist/types/client/data/query.d.ts +10 -5
- package/dist/types/client/data/range.d.ts +4 -3
- package/dist/types/client/i18n/format.d.ts +9 -0
- package/dist/types/client/i18n/index.d.ts +18 -0
- package/dist/types/client/i18n/messages/charts.d.ts +8 -0
- package/dist/types/client/i18n/messages/common.d.ts +153 -0
- package/dist/types/client/i18n/messages/costs.d.ts +92 -0
- package/dist/types/client/i18n/messages/en.d.ts +1255 -0
- package/dist/types/client/i18n/messages/errors.d.ts +78 -0
- package/dist/types/client/i18n/messages/frustration.d.ts +191 -0
- package/dist/types/client/i18n/messages/gain.d.ts +54 -0
- package/dist/types/client/i18n/messages/models.d.ts +94 -0
- package/dist/types/client/i18n/messages/nav.d.ts +46 -0
- package/dist/types/client/i18n/messages/overview.d.ts +80 -0
- package/dist/types/client/i18n/messages/projects.d.ts +84 -0
- package/dist/types/client/i18n/messages/providers.d.ts +142 -0
- package/dist/types/client/i18n/messages/requests.d.ts +72 -0
- package/dist/types/client/i18n/messages/tools.d.ts +119 -0
- package/dist/types/client/i18n/messages/traces.d.ts +144 -0
- package/dist/types/client/i18n/messages/ui.d.ts +44 -0
- package/dist/types/client/i18n/messages/zh-CN.d.ts +13 -0
- package/dist/types/client/i18n/types.d.ts +15 -0
- package/dist/types/client/traces/Minimap.d.ts +2 -0
- package/dist/types/client/traces/TimelineCanvas.d.ts +29 -8
- package/dist/types/client/traces/TranscriptList.d.ts +3 -1
- package/dist/types/db.d.ts +14 -21
- package/dist/types/embedded-client.d.ts +5 -0
- package/dist/types/frustration.d.ts +18 -9
- package/dist/types/live.d.ts +5 -3
- package/dist/types/locale.d.ts +16 -0
- package/dist/types/parser.d.ts +0 -17
- package/dist/types/rollup.d.ts +9 -1
- package/dist/types/server.d.ts +6 -0
- package/dist/types/shared-types.d.ts +7 -0
- package/dist/types/trace.d.ts +18 -6
- package/dist/types/types.d.ts +13 -1
- package/dist/types/user-metrics.d.ts +23 -1
- package/package.json +5 -5
- package/src/aggregator.ts +31 -11
- package/src/client/api.ts +40 -5
- package/src/client/app/LiveChip.tsx +14 -13
- package/src/client/app/Shell.tsx +19 -13
- package/src/client/app/ThemeToggle.tsx +4 -3
- package/src/client/app/nav.ts +19 -16
- package/src/client/data/formatters.ts +80 -13
- package/src/client/data/live.tsx +85 -45
- package/src/client/data/query-store.ts +108 -0
- package/src/client/data/query.ts +47 -56
- package/src/client/data/range.ts +34 -14
- package/src/client/data/series.ts +7 -1
- package/src/client/favicon.ico +0 -0
- package/src/client/i18n/format.ts +28 -0
- package/src/client/i18n/index.ts +53 -0
- package/src/client/i18n/messages/charts.ts +8 -0
- package/src/client/i18n/messages/common.ts +157 -0
- package/src/client/i18n/messages/costs.ts +104 -0
- package/src/client/i18n/messages/en.ts +40 -0
- package/src/client/i18n/messages/errors.ts +80 -0
- package/src/client/i18n/messages/frustration.ts +198 -0
- package/src/client/i18n/messages/gain.ts +54 -0
- package/src/client/i18n/messages/models.ts +94 -0
- package/src/client/i18n/messages/nav.ts +46 -0
- package/src/client/i18n/messages/overview.ts +90 -0
- package/src/client/i18n/messages/projects.ts +84 -0
- package/src/client/i18n/messages/providers.ts +165 -0
- package/src/client/i18n/messages/requests.ts +73 -0
- package/src/client/i18n/messages/tools.ts +120 -0
- package/src/client/i18n/messages/traces.ts +160 -0
- package/src/client/i18n/messages/ui.ts +46 -0
- package/src/client/i18n/messages/zh-CN.ts +43 -0
- package/src/client/i18n/types.ts +19 -0
- package/src/client/index.tsx +4 -0
- package/src/client/routes/CostsRoute.tsx +61 -48
- package/src/client/routes/ErrorsRoute.tsx +71 -47
- package/src/client/routes/FrustrationRoute.tsx +131 -116
- package/src/client/routes/GainRoute.tsx +42 -30
- package/src/client/routes/ModelsRoute.tsx +90 -73
- package/src/client/routes/OverviewRoute.tsx +109 -63
- package/src/client/routes/ProjectsRoute.tsx +73 -46
- package/src/client/routes/ProvidersRoute.tsx +151 -114
- package/src/client/routes/RequestsRoute.tsx +69 -46
- package/src/client/routes/ToolsRoute.tsx +108 -94
- package/src/client/routes/TracesRoute.tsx +25 -23
- package/src/client/styles.css +5 -2
- package/src/client/traces/AggregatesPanel.tsx +19 -8
- package/src/client/traces/Minimap.tsx +65 -27
- package/src/client/traces/SpanDrawer.tsx +46 -21
- package/src/client/traces/SummaryStrip.tsx +27 -11
- package/src/client/traces/TimelineCanvas.tsx +245 -116
- package/src/client/traces/TraceView.tsx +51 -36
- package/src/client/traces/TranscriptList.tsx +20 -11
- package/src/client/traces/traces.css +26 -0
- package/src/client/ui/JsonBlock.tsx +3 -2
- package/src/client/ui/Modal.tsx +3 -2
- package/src/client/ui/RequestDrawer.tsx +33 -30
- package/src/client/ui/SearchInput.tsx +2 -1
- package/src/client/ui/States.tsx +3 -2
- package/src/client/ui/Table.tsx +7 -3
- package/src/db.ts +150 -135
- package/src/embedded-client.ts +9 -0
- package/src/frustration.ts +9 -30
- package/src/index.ts +2 -1
- package/src/live.ts +36 -11
- package/src/locale.ts +29 -0
- package/src/parser.ts +37 -72
- package/src/port-conflict.ts +1 -1
- package/src/prompts/judge/frustration.json +19 -0
- package/src/rollup.ts +41 -20
- package/src/server.ts +67 -27
- package/src/shared-types.ts +7 -0
- package/src/trace.ts +54 -40
- package/src/types.ts +13 -1
- 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 `
|
|
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 `
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
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 {
|
|
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 } =
|
|
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>
|
|
19
|
+
<span>{t("shell.reconnecting")}</span>
|
|
19
20
|
</>
|
|
20
21
|
);
|
|
21
|
-
title = "
|
|
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>
|
|
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 = "
|
|
35
|
+
title = t("shell.ingesting");
|
|
35
36
|
} else if (sync.phase === "error") {
|
|
36
37
|
body = (
|
|
37
38
|
<>
|
|
38
39
|
<Dot tone="bad" />
|
|
39
|
-
<span>
|
|
40
|
+
<span>{t("shell.sync-failed")}</span>
|
|
40
41
|
</>
|
|
41
42
|
);
|
|
42
|
-
title = `${sync.error ?? "
|
|
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>
|
|
48
|
+
<span>{t("shell.indexing")}</span>
|
|
48
49
|
<span className="live-chip-count">{formatInteger(indexingHours)}h left</span>
|
|
49
50
|
</>
|
|
50
51
|
);
|
|
51
|
-
title = "
|
|
52
|
+
title = t("shell.building-rollups");
|
|
52
53
|
} else {
|
|
53
54
|
body = (
|
|
54
55
|
<>
|
|
55
56
|
<Dot tone="live" pulse />
|
|
56
|
-
<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 = "
|
|
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
|
|
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);
|
package/src/client/app/Shell.tsx
CHANGED
|
@@ -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 {
|
|
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).
|
|
22
|
-
title: `${rangeMeta(range).
|
|
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 } =
|
|
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="
|
|
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
|
|
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="
|
|
109
|
+
<nav className="sidebar" data-open={menuOpen} aria-label={t("nav.pages")}>
|
|
104
110
|
{NAV.map(group => (
|
|
105
|
-
<div key={group.
|
|
106
|
-
<div className="nav-heading">{group.
|
|
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.
|
|
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>
|
|
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: "
|
|
18
|
-
light: "
|
|
19
|
-
dark: "
|
|
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. */
|
package/src/client/app/nav.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
48
|
+
headingKey: "nav.usage",
|
|
46
49
|
items: [
|
|
47
|
-
{ id: "overview",
|
|
48
|
-
{ id: "models",
|
|
49
|
-
{ id: "providers",
|
|
50
|
-
{ id: "costs",
|
|
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
|
-
|
|
57
|
+
headingKey: "nav.activity",
|
|
55
58
|
items: [
|
|
56
|
-
{ id: "requests",
|
|
57
|
-
{ id: "errors",
|
|
58
|
-
{ id: "traces",
|
|
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
|
-
|
|
65
|
+
headingKey: "nav.insights",
|
|
63
66
|
items: [
|
|
64
|
-
{ id: "tools",
|
|
65
|
-
{ id: "frustration",
|
|
66
|
-
{ id: "projects",
|
|
67
|
-
{ id: "gain",
|
|
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 {
|
|
1
|
+
import { getLocale, t } from "../i18n";
|
|
2
2
|
import type { MessageStats } from "../types";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
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
|
-
|
|
11
|
+
function numberLocale(): string {
|
|
12
|
+
return getLocale();
|
|
13
|
+
}
|
|
10
14
|
|
|
11
15
|
export function formatInteger(value: number): string {
|
|
12
|
-
return value.toLocaleString(
|
|
16
|
+
return value.toLocaleString(numberLocale());
|
|
13
17
|
}
|
|
14
18
|
|
|
15
19
|
export function formatCompact(value: number): string {
|
|
16
|
-
return value.toLocaleString(
|
|
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(
|
|
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
|
-
|
|
80
|
-
|
|
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
|
-
|
|
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, "") || "
|
|
157
|
+
return folder.replace(/^\/+|\/+$/g, "") || t("common.root");
|
|
91
158
|
}
|
|
92
159
|
|
|
93
160
|
export function formatBytes(value: number): string {
|