@linxiraos/pi-stats 1.1.27 → 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 +10 -7
- 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 +5 -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 +12 -0
- package/dist/types/user-metrics.d.ts +23 -1
- package/package.json +4 -4
- package/src/aggregator.ts +28 -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 +73 -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 +87 -71
- package/src/client/routes/OverviewRoute.tsx +107 -62
- package/src/client/routes/ProjectsRoute.tsx +71 -45
- package/src/client/routes/ProvidersRoute.tsx +149 -113
- package/src/client/routes/RequestsRoute.tsx +68 -45
- package/src/client/routes/ToolsRoute.tsx +105 -92
- package/src/client/routes/TracesRoute.tsx +25 -23
- package/src/client/styles.css +4 -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/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 +12 -0
- package/src/user-metrics.ts +220 -6
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
|
})}`;
|
|
@@ -83,18 +87,74 @@ export function formatTokensPerSecond(value: number | null): string {
|
|
|
83
87
|
return value.toFixed(1);
|
|
84
88
|
}
|
|
85
89
|
|
|
86
|
-
|
|
87
|
-
|
|
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);
|
|
88
135
|
}
|
|
89
136
|
|
|
90
|
-
/** 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"). */
|
|
91
138
|
export function formatTimestamp(timestamp: number): string {
|
|
92
|
-
|
|
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);
|
|
93
153
|
}
|
|
94
154
|
|
|
95
155
|
/** Project folder as stored by ingest (`/work-pi/`), without the slashes. */
|
|
96
156
|
export function formatFolder(folder: string): string {
|
|
97
|
-
return folder.replace(/^\/+|\/+$/g, "") || "
|
|
157
|
+
return folder.replace(/^\/+|\/+$/g, "") || t("common.root");
|
|
98
158
|
}
|
|
99
159
|
|
|
100
160
|
export function formatBytes(value: number): string {
|
package/src/client/data/live.tsx
CHANGED
|
@@ -3,21 +3,17 @@
|
|
|
3
3
|
* version every query revalidates against, background ingest progress, and
|
|
4
4
|
* the rollup indexing backlog.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* The state lives in a module store read through narrow selectors, so a
|
|
7
|
+
* progress tick only re-renders what shows progress: `useLiveVersion()` for
|
|
8
|
+
* queries (see `useQuery`), `useLiveStatus()` for the chrome.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import {
|
|
11
|
+
import { type ReactNode, useEffect, useSyncExternalStore } from "react";
|
|
12
12
|
import * as api from "../api";
|
|
13
13
|
import type { LiveStatus, LiveSyncStatus } from "../types";
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
* Client-side data version; bumps whenever the server's version changes
|
|
18
|
-
* (including after reconnecting to a restarted server).
|
|
19
|
-
*/
|
|
20
|
-
version: number;
|
|
15
|
+
/** Ingest status shown by the chrome. */
|
|
16
|
+
export interface LiveStatusView {
|
|
21
17
|
sync: LiveSyncStatus;
|
|
22
18
|
/** Hours whose rollups are still being built; wide ranges may be incomplete. */
|
|
23
19
|
indexingHours: number;
|
|
@@ -36,50 +32,94 @@ const IDLE_SYNC: LiveSyncStatus = {
|
|
|
36
32
|
error: null,
|
|
37
33
|
};
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
indexingHours: 0,
|
|
43
|
-
connected: false,
|
|
44
|
-
requestSync: () => {},
|
|
45
|
-
});
|
|
35
|
+
function requestSync(): void {
|
|
36
|
+
api.requestSync().catch(() => {});
|
|
37
|
+
}
|
|
46
38
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
39
|
+
let lastServerVersion: number | null = null;
|
|
40
|
+
/** Distinct server versions seen (including across server restarts). */
|
|
41
|
+
let seenVersions = 0;
|
|
42
|
+
/**
|
|
43
|
+
* Client-side data version; bumps whenever the server's version changes.
|
|
44
|
+
* Lags `seenVersions` by one so the first stream message doesn't refetch what
|
|
45
|
+
* just loaded.
|
|
46
|
+
*/
|
|
47
|
+
let version = 0;
|
|
48
|
+
let status: LiveStatusView = { sync: IDLE_SYNC, indexingHours: 0, connected: false, requestSync };
|
|
49
|
+
const listeners = new Set<() => void>();
|
|
50
|
+
|
|
51
|
+
function subscribe(listener: () => void): () => void {
|
|
52
|
+
listeners.add(listener);
|
|
53
|
+
return () => listeners.delete(listener);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function emit(): void {
|
|
57
|
+
for (const listener of listeners) listener();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function sameSync(a: LiveSyncStatus, b: LiveSyncStatus): boolean {
|
|
61
|
+
return (
|
|
62
|
+
a.phase === b.phase &&
|
|
63
|
+
a.current === b.current &&
|
|
64
|
+
a.total === b.total &&
|
|
65
|
+
a.processed === b.processed &&
|
|
66
|
+
a.lastSyncedAt === b.lastSyncedAt &&
|
|
67
|
+
a.error === b.error
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function receive(next: LiveStatus): void {
|
|
72
|
+
if (lastServerVersion !== next.version) {
|
|
73
|
+
lastServerVersion = next.version;
|
|
74
|
+
seenVersions++;
|
|
75
|
+
}
|
|
76
|
+
const nextVersion = Math.max(0, seenVersions - 1);
|
|
77
|
+
const statusChanged = next.indexingHours !== status.indexingHours || !sameSync(next.sync, status.sync);
|
|
78
|
+
if (!statusChanged && nextVersion === version) return;
|
|
79
|
+
version = nextVersion;
|
|
80
|
+
if (statusChanged) status = { ...status, sync: next.sync, indexingHours: next.indexingHours };
|
|
81
|
+
emit();
|
|
82
|
+
}
|
|
50
83
|
|
|
84
|
+
function setConnected(connected: boolean): void {
|
|
85
|
+
if (connected === status.connected) return;
|
|
86
|
+
status = { ...status, connected };
|
|
87
|
+
emit();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const getStatus = (): LiveStatusView => status;
|
|
91
|
+
const getVersion = (): number => version;
|
|
92
|
+
const getDisabledVersion = (): number => -1;
|
|
93
|
+
|
|
94
|
+
/** Current data version, read outside render (e.g. when a fetch starts). */
|
|
95
|
+
export function currentLiveVersion(): number {
|
|
96
|
+
return version;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Owns the `/api/events` stream; mount once at the app root. */
|
|
100
|
+
export function LiveProvider({ children }: { children: ReactNode }) {
|
|
51
101
|
useEffect(() => {
|
|
52
102
|
const source = new EventSource("/api/events");
|
|
53
103
|
source.onopen = () => setConnected(true);
|
|
54
104
|
source.onerror = () => setConnected(false);
|
|
55
|
-
source.onmessage = event =>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
version: prev.server && prev.server.version === next.version ? prev.version : prev.version + 1,
|
|
60
|
-
}));
|
|
105
|
+
source.onmessage = event => receive(JSON.parse(event.data) as LiveStatus);
|
|
106
|
+
return () => {
|
|
107
|
+
source.close();
|
|
108
|
+
setConnected(false);
|
|
61
109
|
};
|
|
62
|
-
return () => source.close();
|
|
63
|
-
}, []);
|
|
64
|
-
|
|
65
|
-
const requestSync = useCallback(() => {
|
|
66
|
-
api.requestSync().catch(() => {});
|
|
67
110
|
}, []);
|
|
111
|
+
return children;
|
|
112
|
+
}
|
|
68
113
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
version: Math.max(0, status.version - 1),
|
|
73
|
-
sync: status.server?.sync ?? IDLE_SYNC,
|
|
74
|
-
indexingHours: status.server?.indexingHours ?? 0,
|
|
75
|
-
connected,
|
|
76
|
-
requestSync,
|
|
77
|
-
}),
|
|
78
|
-
[status, connected, requestSync],
|
|
79
|
-
);
|
|
80
|
-
return <LiveContext.Provider value={value}>{children}</LiveContext.Provider>;
|
|
114
|
+
/** Ingest status for the chrome; data-version bumps alone don't re-render. */
|
|
115
|
+
export function useLiveStatus(): LiveStatusView {
|
|
116
|
+
return useSyncExternalStore(subscribe, getStatus);
|
|
81
117
|
}
|
|
82
118
|
|
|
83
|
-
|
|
84
|
-
|
|
119
|
+
/**
|
|
120
|
+
* Data version for queries. Disabled (hidden) consumers read a constant -1 so
|
|
121
|
+
* live traffic never re-renders them.
|
|
122
|
+
*/
|
|
123
|
+
export function useLiveVersion(enabled = true): number {
|
|
124
|
+
return useSyncExternalStore(subscribe, enabled ? getVersion : getDisabledVersion);
|
|
85
125
|
}
|