@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
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Framework-free query cache behind `useQuery`: per-key cached results in
|
|
3
|
+
* size-bounded buckets, and one shared in-flight request per key that is
|
|
4
|
+
* aborted once its last waiter leaves.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
export interface CacheEntry {
|
|
8
|
+
data: unknown;
|
|
9
|
+
updatedAt: number;
|
|
10
|
+
version: number;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface CacheBucket {
|
|
14
|
+
entries: Map<string, CacheEntry>;
|
|
15
|
+
limit: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface Inflight {
|
|
19
|
+
promise: Promise<unknown>;
|
|
20
|
+
controller: AbortController;
|
|
21
|
+
/** Queries (and prefetches) waiting on this request. */
|
|
22
|
+
waiters: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Handed to every fetcher. */
|
|
26
|
+
export interface QueryFetchContext {
|
|
27
|
+
/** Aborted once no query waits for this request any more. */
|
|
28
|
+
signal: AbortSignal;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Loads one key's data. `previous` is the cached data for this key, for
|
|
33
|
+
* conditional revalidation; `undefined` on first load. It is a separate
|
|
34
|
+
* parameter so fetchers that ignore it still let `T` be inferred from their
|
|
35
|
+
* return type.
|
|
36
|
+
*/
|
|
37
|
+
export type QueryFetcher<T> = (context: QueryFetchContext, previous: T | undefined) => Promise<T>;
|
|
38
|
+
|
|
39
|
+
const defaultBucket: CacheBucket = { entries: new Map(), limit: 128 };
|
|
40
|
+
/** Full session traces: MBs each, at most a few worth keeping. */
|
|
41
|
+
const traceBucket: CacheBucket = { entries: new Map(), limit: 4 };
|
|
42
|
+
/** Request detail payloads (full prompts and responses). */
|
|
43
|
+
const requestBucket: CacheBucket = { entries: new Map(), limit: 16 };
|
|
44
|
+
|
|
45
|
+
const inflight = new Map<string, Inflight>();
|
|
46
|
+
|
|
47
|
+
function bucketFor(key: string): CacheBucket {
|
|
48
|
+
if (key.startsWith('["trace",')) return traceBucket;
|
|
49
|
+
if (key.startsWith('["request",')) return requestBucket;
|
|
50
|
+
return defaultBucket;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function cachedEntry(key: string): CacheEntry | undefined {
|
|
54
|
+
return bucketFor(key).entries.get(key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The shared request currently running for `key`, if any. */
|
|
58
|
+
export function inflightRequest(key: string): Inflight | undefined {
|
|
59
|
+
return inflight.get(key);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function remember(key: string, entry: CacheEntry): void {
|
|
63
|
+
const { entries, limit } = bucketFor(key);
|
|
64
|
+
entries.delete(key);
|
|
65
|
+
entries.set(key, entry);
|
|
66
|
+
if (entries.size > limit) {
|
|
67
|
+
const oldest = entries.keys().next().value;
|
|
68
|
+
if (oldest !== undefined) entries.delete(oldest);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Fetch into the cache (tagged with live data `version`), sharing one request
|
|
74
|
+
* among concurrent callers of the same key. The caller holds one waiter on
|
|
75
|
+
* the returned request; pair with {@link releaseQuery}.
|
|
76
|
+
*/
|
|
77
|
+
export function loadQuery<T>(key: string, version: number, fetcher: QueryFetcher<T>): Inflight {
|
|
78
|
+
const pending = inflight.get(key);
|
|
79
|
+
if (pending) {
|
|
80
|
+
pending.waiters++;
|
|
81
|
+
return pending;
|
|
82
|
+
}
|
|
83
|
+
const controller = new AbortController();
|
|
84
|
+
// Entries for a key are only ever written by fetchers of that key's type.
|
|
85
|
+
const previous = cachedEntry(key)?.data as T | undefined;
|
|
86
|
+
const request: Inflight = {
|
|
87
|
+
promise: fetcher({ signal: controller.signal }, previous)
|
|
88
|
+
.then(data => {
|
|
89
|
+
remember(key, { data, updatedAt: Date.now(), version });
|
|
90
|
+
return data;
|
|
91
|
+
})
|
|
92
|
+
.finally(() => {
|
|
93
|
+
if (inflight.get(key) === request) inflight.delete(key);
|
|
94
|
+
}),
|
|
95
|
+
controller,
|
|
96
|
+
waiters: 1,
|
|
97
|
+
};
|
|
98
|
+
inflight.set(key, request);
|
|
99
|
+
return request;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Drop one waiter; the last one out aborts the request. */
|
|
103
|
+
export function releaseQuery(key: string, request: Inflight): void {
|
|
104
|
+
request.waiters--;
|
|
105
|
+
if (request.waiters > 0) return;
|
|
106
|
+
if (inflight.get(key) === request) inflight.delete(key);
|
|
107
|
+
request.controller.abort();
|
|
108
|
+
}
|
package/src/client/data/query.ts
CHANGED
|
@@ -2,31 +2,26 @@
|
|
|
2
2
|
* `useQuery`: cached, live-revalidating data fetching for dashboard pages.
|
|
3
3
|
*
|
|
4
4
|
* - Results are cached per key for the page session, so revisiting a page or
|
|
5
|
-
* a range renders instantly from memory.
|
|
5
|
+
* a range renders instantly from memory. Multi-MB detail payloads (traces,
|
|
6
|
+
* request details) live in their own small LRUs so they don't pile up.
|
|
6
7
|
* - A key change keeps the previous key's data on screen (`stale: true`) until
|
|
7
8
|
* the new data lands, so charts morph instead of flashing skeletons.
|
|
8
9
|
* - Every cached entry remembers the live data version it was fetched at
|
|
9
|
-
* (see `
|
|
10
|
-
* background, throttled so a burst of ingest batches costs one request.
|
|
11
|
-
* - Identical keys share one in-flight request
|
|
10
|
+
* (see `useLiveVersion`); when the version advances, enabled queries refetch
|
|
11
|
+
* in the background, throttled so a burst of ingest batches costs one request.
|
|
12
|
+
* - Identical keys share one in-flight request; it is aborted once no query
|
|
13
|
+
* waits for it any more.
|
|
14
|
+
* - Fetchers receive the cached data for their key, so conditional requests
|
|
15
|
+
* can resolve to that same object; an unchanged reference does not re-render.
|
|
12
16
|
*/
|
|
13
17
|
|
|
14
18
|
import { startTransition, useCallback, useEffect, useReducer, useRef, useState } from "react";
|
|
15
|
-
import {
|
|
19
|
+
import { currentLiveVersion, useLiveVersion } from "./live";
|
|
20
|
+
import { cachedEntry, type Inflight, inflightRequest, loadQuery, type QueryFetcher, releaseQuery } from "./query-store";
|
|
16
21
|
|
|
17
|
-
interface CacheEntry {
|
|
18
|
-
data: unknown;
|
|
19
|
-
updatedAt: number;
|
|
20
|
-
version: number;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
const CACHE_LIMIT = 128;
|
|
24
22
|
/** Minimum spacing between live-driven refetches of one query. */
|
|
25
23
|
const LIVE_REFETCH_THROTTLE_MS = 1500;
|
|
26
24
|
|
|
27
|
-
const cache = new Map<string, CacheEntry>();
|
|
28
|
-
const inflight = new Map<string, Promise<unknown>>();
|
|
29
|
-
|
|
30
25
|
export interface QueryOptions {
|
|
31
26
|
/** Only enabled queries fetch; hidden (kept-alive) pages pass `false`. Default true. */
|
|
32
27
|
enabled?: boolean;
|
|
@@ -49,48 +44,20 @@ export interface QueryResult<T> {
|
|
|
49
44
|
refetch: () => void;
|
|
50
45
|
}
|
|
51
46
|
|
|
52
|
-
function remember(key: string, entry: CacheEntry): void {
|
|
53
|
-
cache.delete(key);
|
|
54
|
-
cache.set(key, entry);
|
|
55
|
-
if (cache.size > CACHE_LIMIT) {
|
|
56
|
-
const oldest = cache.keys().next().value;
|
|
57
|
-
if (oldest !== undefined) cache.delete(oldest);
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/** Fetch into the cache, sharing one request among concurrent callers of the same key. */
|
|
62
|
-
function load<T>(key: string, fetcher: () => Promise<T>, version: number): Promise<T> {
|
|
63
|
-
const pending = inflight.get(key);
|
|
64
|
-
if (pending) return pending as Promise<T>;
|
|
65
|
-
const promise = fetcher()
|
|
66
|
-
.then(data => {
|
|
67
|
-
remember(key, { data, updatedAt: Date.now(), version });
|
|
68
|
-
return data;
|
|
69
|
-
})
|
|
70
|
-
.finally(() => {
|
|
71
|
-
inflight.delete(key);
|
|
72
|
-
});
|
|
73
|
-
inflight.set(key, promise);
|
|
74
|
-
return promise;
|
|
75
|
-
}
|
|
76
|
-
|
|
77
47
|
/** Warm the cache for a key the user is likely to open next. No-op if cached for this version. */
|
|
78
48
|
export function prefetchQuery<T>(key: readonly unknown[], fetcher: () => Promise<T>, version: number): void {
|
|
79
49
|
const keyString = JSON.stringify(key);
|
|
80
|
-
const entry =
|
|
50
|
+
const entry = cachedEntry(keyString);
|
|
81
51
|
if (entry && entry.version >= version) return;
|
|
82
|
-
|
|
52
|
+
// Never released: a prefetch runs to completion.
|
|
53
|
+
loadQuery(keyString, currentLiveVersion(), fetcher).promise.catch(() => {});
|
|
83
54
|
}
|
|
84
55
|
|
|
85
|
-
export function useQuery<T>(
|
|
86
|
-
key: readonly unknown[],
|
|
87
|
-
fetcher: () => Promise<T>,
|
|
88
|
-
options?: QueryOptions,
|
|
89
|
-
): QueryResult<T> {
|
|
56
|
+
export function useQuery<T>(key: readonly unknown[], fetcher: QueryFetcher<T>, options?: QueryOptions): QueryResult<T> {
|
|
90
57
|
const keyString = JSON.stringify(key);
|
|
91
58
|
const enabled = options?.enabled ?? true;
|
|
92
59
|
const pollMs = options?.pollMs;
|
|
93
|
-
const
|
|
60
|
+
const version = useLiveVersion(enabled);
|
|
94
61
|
|
|
95
62
|
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
|
96
63
|
const [error, setError] = useState<{ key: string; error: Error } | null>(null);
|
|
@@ -100,35 +67,59 @@ export function useQuery<T>(
|
|
|
100
67
|
fetcherRef.current = fetcher;
|
|
101
68
|
const keyRef = useRef(keyString);
|
|
102
69
|
keyRef.current = keyString;
|
|
103
|
-
const versionRef = useRef(version);
|
|
104
|
-
versionRef.current = version;
|
|
105
70
|
const lastShown = useRef<{ key: string; data: unknown; updatedAt: number } | null>(null);
|
|
106
71
|
const lastFetchAt = useRef(0);
|
|
72
|
+
/** The request this query currently waits on. */
|
|
73
|
+
const waiting = useRef<{ key: string; request: Inflight } | null>(null);
|
|
107
74
|
|
|
108
75
|
const run = useCallback((targetKey: string) => {
|
|
109
76
|
lastFetchAt.current = Date.now();
|
|
77
|
+
const current = waiting.current;
|
|
78
|
+
if (current?.key === targetKey && inflightRequest(targetKey) === current.request) return;
|
|
79
|
+
const shownBefore = cachedEntry(targetKey)?.data;
|
|
110
80
|
setRefreshing(true);
|
|
111
|
-
|
|
112
|
-
|
|
81
|
+
const request = loadQuery(targetKey, currentLiveVersion(), fetcherRef.current);
|
|
82
|
+
waiting.current = { key: targetKey, request };
|
|
83
|
+
// Join the new request before leaving the old one, so a shared request isn't aborted in between.
|
|
84
|
+
if (current) releaseQuery(current.key, current.request);
|
|
85
|
+
request.promise
|
|
86
|
+
.then(data => {
|
|
113
87
|
if (keyRef.current !== targetKey) return;
|
|
88
|
+
// Revalidated to the very same object: nothing on screen changes.
|
|
89
|
+
if (data === shownBefore) {
|
|
90
|
+
setError(prev => (prev === null ? prev : null));
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
114
93
|
startTransition(() => {
|
|
115
94
|
setError(null);
|
|
116
95
|
rerender();
|
|
117
96
|
});
|
|
118
97
|
})
|
|
119
98
|
.catch((err: unknown) => {
|
|
120
|
-
if (keyRef.current !== targetKey) return;
|
|
99
|
+
if (keyRef.current !== targetKey || request.controller.signal.aborted) return;
|
|
121
100
|
setError({ key: targetKey, error: err instanceof Error ? err : new Error(String(err)) });
|
|
122
101
|
})
|
|
123
102
|
.finally(() => {
|
|
103
|
+
if (waiting.current?.request === request) waiting.current = null;
|
|
124
104
|
if (keyRef.current === targetKey) setRefreshing(false);
|
|
125
105
|
});
|
|
126
106
|
}, []);
|
|
127
107
|
|
|
108
|
+
/** Stop waiting so an abandoned multi-MB fetch can be aborted. */
|
|
109
|
+
const leave = useCallback(() => {
|
|
110
|
+
const current = waiting.current;
|
|
111
|
+
waiting.current = null;
|
|
112
|
+
if (current) releaseQuery(current.key, current.request);
|
|
113
|
+
}, []);
|
|
114
|
+
|
|
115
|
+
// Unmounting, disabling, or switching keys abandons the current request.
|
|
116
|
+
useEffect(() => leave, [leave]);
|
|
117
|
+
|
|
128
118
|
// Fetch on key change / enable / data-version bump when the cache is behind.
|
|
129
119
|
useEffect(() => {
|
|
120
|
+
if (!enabled || (waiting.current && waiting.current.key !== keyString)) leave();
|
|
130
121
|
if (!enabled) return;
|
|
131
|
-
const entry =
|
|
122
|
+
const entry = cachedEntry(keyString);
|
|
132
123
|
if (entry && entry.version >= version) return;
|
|
133
124
|
const wait = entry ? lastFetchAt.current + LIVE_REFETCH_THROTTLE_MS - Date.now() : 0;
|
|
134
125
|
if (wait <= 0) {
|
|
@@ -137,7 +128,7 @@ export function useQuery<T>(
|
|
|
137
128
|
}
|
|
138
129
|
const timer = setTimeout(() => run(keyString), wait);
|
|
139
130
|
return () => clearTimeout(timer);
|
|
140
|
-
}, [keyString, enabled, version, run]);
|
|
131
|
+
}, [keyString, enabled, version, run, leave]);
|
|
141
132
|
|
|
142
133
|
useEffect(() => {
|
|
143
134
|
if (!enabled || !pollMs) return;
|
|
@@ -149,7 +140,7 @@ export function useQuery<T>(
|
|
|
149
140
|
|
|
150
141
|
const refetch = useCallback(() => run(keyRef.current), [run]);
|
|
151
142
|
|
|
152
|
-
const entry =
|
|
143
|
+
const entry = cachedEntry(keyString);
|
|
153
144
|
if (entry) lastShown.current = { key: keyString, data: entry.data, updatedAt: entry.updatedAt };
|
|
154
145
|
const shown = lastShown.current;
|
|
155
146
|
const currentError = error?.key === keyString ? error.error : null;
|
package/src/client/data/range.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* formatting, and the contiguous bucket axis charts plot against.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
import {
|
|
7
|
+
import { getLocale, type MessageKey } from "../i18n";
|
|
8
8
|
import type { TimeRange } from "../types";
|
|
9
9
|
|
|
10
10
|
const MINUTE_MS = 60 * 1000;
|
|
@@ -12,10 +12,10 @@ const HOUR_MS = 60 * MINUTE_MS;
|
|
|
12
12
|
const DAY_MS = 24 * HOUR_MS;
|
|
13
13
|
|
|
14
14
|
export interface RangeMeta {
|
|
15
|
-
/** Short selector label ("24h"). */
|
|
16
|
-
|
|
15
|
+
/** Short selector label ("24h"); translated so the selector reads in the host language. */
|
|
16
|
+
labelKey: MessageKey;
|
|
17
17
|
/** Prose label for subtitles ("the last 24 hours"). */
|
|
18
|
-
|
|
18
|
+
windowKey: MessageKey;
|
|
19
19
|
/** Span covered by the range; `null` for all time. */
|
|
20
20
|
spanMs: number | null;
|
|
21
21
|
/** Bucket size of the server's primary time series for this range. */
|
|
@@ -23,12 +23,12 @@ export interface RangeMeta {
|
|
|
23
23
|
}
|
|
24
24
|
|
|
25
25
|
const RANGE_META: Record<TimeRange, RangeMeta> = {
|
|
26
|
-
"1h": {
|
|
27
|
-
"24h": {
|
|
28
|
-
"7d": {
|
|
29
|
-
"30d": {
|
|
30
|
-
"90d": {
|
|
31
|
-
all: {
|
|
26
|
+
"1h": { labelKey: "common.range-1h", windowKey: "common.window-1h", spanMs: HOUR_MS, bucketMs: 5 * MINUTE_MS },
|
|
27
|
+
"24h": { labelKey: "common.range-24h", windowKey: "common.window-24h", spanMs: DAY_MS, bucketMs: HOUR_MS },
|
|
28
|
+
"7d": { labelKey: "common.range-7d", windowKey: "common.window-7d", spanMs: 7 * DAY_MS, bucketMs: DAY_MS },
|
|
29
|
+
"30d": { labelKey: "common.range-30d", windowKey: "common.window-30d", spanMs: 30 * DAY_MS, bucketMs: DAY_MS },
|
|
30
|
+
"90d": { labelKey: "common.range-90d", windowKey: "common.window-90d", spanMs: 90 * DAY_MS, bucketMs: DAY_MS },
|
|
31
|
+
all: { labelKey: "common.range-all", windowKey: "common.window-all", spanMs: null, bucketMs: DAY_MS },
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
const MAX_BUCKETS = 1500;
|
|
@@ -40,16 +40,36 @@ export function rangeMeta(range: TimeRange): RangeMeta {
|
|
|
40
40
|
return RANGE_META[range];
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
+
/** Layouts shared by the axis and its tooltips. */
|
|
44
|
+
const HOUR_OPTIONS = { hour: "2-digit", minute: "2-digit", hourCycle: "h23" } as const;
|
|
45
|
+
const DAY_OPTIONS = { month: "short", day: "numeric" } as const;
|
|
46
|
+
|
|
47
|
+
/** One formatter per locale+layout: charts re-render on every tick, so this must not allocate per call. */
|
|
48
|
+
const formatterCache = new Map<string, Intl.DateTimeFormat>();
|
|
49
|
+
|
|
50
|
+
function formatter(layout: string, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {
|
|
51
|
+
const locale = getLocale();
|
|
52
|
+
const key = `${locale}:${layout}`;
|
|
53
|
+
let cached = formatterCache.get(key);
|
|
54
|
+
if (!cached) {
|
|
55
|
+
cached = new Intl.DateTimeFormat(locale, options);
|
|
56
|
+
formatterCache.set(key, cached);
|
|
57
|
+
}
|
|
58
|
+
return cached;
|
|
59
|
+
}
|
|
60
|
+
|
|
43
61
|
/** Tick/tooltip label for a bucket start, precise enough for the bucket size. */
|
|
44
62
|
export function formatBucket(timestamp: number, bucketMs: number): string {
|
|
45
|
-
if (bucketMs <
|
|
46
|
-
|
|
63
|
+
if (bucketMs < HOUR_MS) return formatter("hm", HOUR_OPTIONS).format(timestamp);
|
|
64
|
+
if (bucketMs < DAY_MS) return formatter("mdhm", { ...DAY_OPTIONS, ...HOUR_OPTIONS }).format(timestamp);
|
|
65
|
+
return formatter("md", DAY_OPTIONS).format(timestamp);
|
|
47
66
|
}
|
|
48
67
|
|
|
49
68
|
/** Compact axis tick for a bucket start. */
|
|
50
69
|
export function formatTick(timestamp: number, bucketMs: number): string {
|
|
51
|
-
|
|
52
|
-
|
|
70
|
+
return bucketMs < DAY_MS
|
|
71
|
+
? formatter("hm", HOUR_OPTIONS).format(timestamp)
|
|
72
|
+
: formatter("md", DAY_OPTIONS).format(timestamp);
|
|
53
73
|
}
|
|
54
74
|
|
|
55
75
|
/**
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
import type { ChartSeries } from "../charts/types";
|
|
7
|
+
import { t } from "../i18n";
|
|
7
8
|
import { OTHER_COLOR, SERIES_COLORS } from "./colors";
|
|
8
9
|
|
|
9
10
|
/** Sum `value(point)` into a dense array aligned to `buckets`. Out-of-axis points are dropped. */
|
|
@@ -73,7 +74,12 @@ export function pivotSeries<P extends { timestamp: number }>(
|
|
|
73
74
|
if (tail.length > 0) {
|
|
74
75
|
const other = Array.from({ length: opts.buckets.length }, () => 0);
|
|
75
76
|
for (const entry of tail) for (let i = 0; i < other.length; i++) other[i] += entry.values[i];
|
|
76
|
-
series.push({
|
|
77
|
+
series.push({
|
|
78
|
+
key: "__other__",
|
|
79
|
+
label: t("common.other-n", { count: tail.length }),
|
|
80
|
+
color: OTHER_COLOR,
|
|
81
|
+
values: other,
|
|
82
|
+
});
|
|
77
83
|
}
|
|
78
84
|
return series;
|
|
79
85
|
}
|
|
Binary file
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import type { MessagesByLocale, StatsLocale, TranslationParams } from "./types";
|
|
2
|
+
|
|
3
|
+
/** Substitute `{name}` placeholders; unknown names are left verbatim so a bug is visible, not silent. */
|
|
4
|
+
export function interpolateMessage(message: string, params: TranslationParams = {}): string {
|
|
5
|
+
return message.replace(/\{([\w.-]+)\}/g, (token, name: string) => {
|
|
6
|
+
const value = params[name];
|
|
7
|
+
return value === undefined ? token : String(value);
|
|
8
|
+
});
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolve a message for `locale`, falling back to English and finally to the
|
|
13
|
+
* key itself. The key fallback is deliberate: a renamed key renders its slug
|
|
14
|
+
* rather than blanking a card, so a missed rename is visible but not fatal.
|
|
15
|
+
*/
|
|
16
|
+
export function translateMessage(
|
|
17
|
+
locale: StatsLocale,
|
|
18
|
+
key: string,
|
|
19
|
+
messages: MessagesByLocale,
|
|
20
|
+
params: TranslationParams = {},
|
|
21
|
+
): string {
|
|
22
|
+
const message = messages[locale]?.[key] ?? messages.en?.[key];
|
|
23
|
+
if (message === undefined) {
|
|
24
|
+
if (process.env.NODE_ENV !== "production") console.warn(`[stats.i18n] missing translation: ${key}`);
|
|
25
|
+
return key;
|
|
26
|
+
}
|
|
27
|
+
return interpolateMessage(message, params);
|
|
28
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { resolveBrowserLocale, resolveLocaleTag } from "../../locale";
|
|
2
|
+
import { translateMessage } from "./format";
|
|
3
|
+
import { enLocale, type MessageKey } from "./messages/en";
|
|
4
|
+
import { zhCNLocale } from "./messages/zh-CN";
|
|
5
|
+
import type { MessagesByLocale, StatsLocale, TranslationParams } from "./types";
|
|
6
|
+
|
|
7
|
+
/** The inline global the server stamps into `<head>` (see `server.ts`); read through `globalThis` so this module compiles without a DOM lib. */
|
|
8
|
+
function injectedLocale(): string | undefined {
|
|
9
|
+
return (globalThis as { __ZETA_STATS_LOCALE__?: string }).__ZETA_STATS_LOCALE__;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const messages: MessagesByLocale = { en: enLocale.messages, "zh-CN": zhCNLocale.messages };
|
|
13
|
+
|
|
14
|
+
let activeLocale: StatsLocale = "en";
|
|
15
|
+
|
|
16
|
+
// Resolve once at module load. Every `t()` caller imports this module first, so
|
|
17
|
+
// the host language the server injected into `<head>` (or the browser's own
|
|
18
|
+
// preference) is already in effect by the time a component module is evaluated
|
|
19
|
+
// — including module-level label tables such as the range selector's.
|
|
20
|
+
resolveInitialLocale();
|
|
21
|
+
|
|
22
|
+
/** The locale the dashboard renders in. Fixed at boot; there is no in-page switcher. */
|
|
23
|
+
export function getLocale(): StatsLocale {
|
|
24
|
+
return activeLocale;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** Install a locale by tag (`zh`, `zh-CN`, `zh-Hans`; anything else is English). */
|
|
28
|
+
export function setLocale(tag: string | undefined | null): StatsLocale {
|
|
29
|
+
activeLocale = resolveLocaleTag(tag);
|
|
30
|
+
return activeLocale;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The locale to boot with: the host's language when `zeta-c stats` injected it,
|
|
35
|
+
* then the browser's own preference, then English.
|
|
36
|
+
*/
|
|
37
|
+
export function resolveInitialLocale(): StatsLocale {
|
|
38
|
+
const injected = injectedLocale();
|
|
39
|
+
if (injected) return setLocale(injected);
|
|
40
|
+
if (typeof navigator === "undefined") return "en";
|
|
41
|
+
return setLocale(resolveBrowserLocale(navigator.languages));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Translate `key` in the active locale; `{name}` placeholders substitute from
|
|
46
|
+
* `params`. The key type is the English catalogue, so an unknown key is a
|
|
47
|
+
* compile error rather than a silently blank card.
|
|
48
|
+
*/
|
|
49
|
+
export function t(key: MessageKey, params?: TranslationParams): string {
|
|
50
|
+
return translateMessage(activeLocale, key, messages, params);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export type { MessageKey, StatsLocale, TranslationParams };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-page vocabulary. Every route derives its column headers and card
|
|
3
|
+
* labels from here when the term matches, so one glossary drives the whole
|
|
4
|
+
* dashboard (Providers is 服务商 everywhere, Tokens is Token everywhere).
|
|
5
|
+
*
|
|
6
|
+
* `shell.*` covers the frame: top bar, live chip, theme toggle.
|
|
7
|
+
*/
|
|
8
|
+
export const common = {
|
|
9
|
+
en: {
|
|
10
|
+
// ── shared table / card vocabulary ──────────────────────────────────
|
|
11
|
+
"common.provider": "Provider",
|
|
12
|
+
"common.providers": "Providers",
|
|
13
|
+
"common.model": "Model",
|
|
14
|
+
"common.models": "Models",
|
|
15
|
+
"common.requests": "Requests",
|
|
16
|
+
"common.request": "Request",
|
|
17
|
+
"common.tokens": "Tokens",
|
|
18
|
+
"common.share": "Share",
|
|
19
|
+
"common.cost": "Cost",
|
|
20
|
+
"common.error-rate": "Error rate",
|
|
21
|
+
"common.tokens-per-second": "Tokens/s",
|
|
22
|
+
"common.output-tokens-per-second": "Output tokens/s",
|
|
23
|
+
"common.premium": "Premium",
|
|
24
|
+
"common.premium-requests": "Premium requests",
|
|
25
|
+
"common.duration": "Duration",
|
|
26
|
+
"common.started": "Started",
|
|
27
|
+
"common.request-id": "Request id",
|
|
28
|
+
"common.session-entry": "Session entry",
|
|
29
|
+
"common.entry-id": "Entry id",
|
|
30
|
+
"common.input": "Input",
|
|
31
|
+
"common.output": "Output",
|
|
32
|
+
"common.output-message": "Output message",
|
|
33
|
+
"common.cache-read": "Cache read",
|
|
34
|
+
"common.cache-write": "Cache write",
|
|
35
|
+
"common.project": "Project",
|
|
36
|
+
"common.pages": "Pages",
|
|
37
|
+
"common.filter": "Filter…",
|
|
38
|
+
"common.no-data-in-range": "No data in this range",
|
|
39
|
+
"common.nothing-here-yet": "Nothing here yet",
|
|
40
|
+
"common.show-more": "Show more",
|
|
41
|
+
"common.total": "Total",
|
|
42
|
+
"common.average": "Average",
|
|
43
|
+
"common.unknown": "Unknown",
|
|
44
|
+
"common.root": "(root)",
|
|
45
|
+
"common.other-n": "Other ({count})",
|
|
46
|
+
"common.time-range": "Time range",
|
|
47
|
+
"common.range-1h": "1h",
|
|
48
|
+
"common.range-24h": "24h",
|
|
49
|
+
"common.range-7d": "7d",
|
|
50
|
+
"common.range-30d": "30d",
|
|
51
|
+
"common.range-90d": "90d",
|
|
52
|
+
"common.range-all": "All",
|
|
53
|
+
"common.window-1h": "the last hour",
|
|
54
|
+
"common.window-24h": "the last 24 hours",
|
|
55
|
+
"common.window-7d": "the last 7 days",
|
|
56
|
+
"common.window-30d": "the last 30 days",
|
|
57
|
+
"common.window-90d": "the last 90 days",
|
|
58
|
+
"common.window-all": "all time",
|
|
59
|
+
// ── actions ─────────────────────────────────────────────────────────
|
|
60
|
+
"common.copy": "Copy",
|
|
61
|
+
"common.copied": "Copied",
|
|
62
|
+
"common.close": "Close",
|
|
63
|
+
"common.cancel": "Cancel",
|
|
64
|
+
// ── states ──────────────────────────────────────────────────────────
|
|
65
|
+
"common.aborted": "Aborted",
|
|
66
|
+
"common.failed": "Failed",
|
|
67
|
+
"common.error": "Error",
|
|
68
|
+
// ── frame / live chip ───────────────────────────────────────────────
|
|
69
|
+
"shell.live": "Live",
|
|
70
|
+
"shell.reconnecting": "Reconnecting",
|
|
71
|
+
"shell.syncing": "Syncing",
|
|
72
|
+
"shell.sync-failed": "Sync failed",
|
|
73
|
+
"shell.indexing": "Indexing",
|
|
74
|
+
"shell.lost-connection": "Lost the live connection to the stats server; retrying",
|
|
75
|
+
"shell.ingesting": "Ingesting session files",
|
|
76
|
+
"shell.building-rollups": "Building hourly rollups; older ranges fill in as it goes",
|
|
77
|
+
"shell.sync-now": "Click to sync session files now",
|
|
78
|
+
"shell.just-now": "just now",
|
|
79
|
+
"shell.system-theme": "System theme",
|
|
80
|
+
"shell.dark-theme": "Dark theme",
|
|
81
|
+
"shell.light-theme": "Light theme",
|
|
82
|
+
"shell.range-suffix": "range",
|
|
83
|
+
"shell.jump-hint": "then letter to jump",
|
|
84
|
+
},
|
|
85
|
+
"zh-CN": {
|
|
86
|
+
"common.provider": "服务商",
|
|
87
|
+
"common.providers": "服务商",
|
|
88
|
+
"common.model": "模型",
|
|
89
|
+
"common.models": "模型",
|
|
90
|
+
"common.requests": "请求",
|
|
91
|
+
"common.request": "请求",
|
|
92
|
+
"common.tokens": "Token",
|
|
93
|
+
"common.share": "占比",
|
|
94
|
+
"common.cost": "费用",
|
|
95
|
+
"common.error-rate": "错误率",
|
|
96
|
+
"common.tokens-per-second": "Token/秒",
|
|
97
|
+
"common.output-tokens-per-second": "输出 Token/秒",
|
|
98
|
+
"common.premium": "溢价",
|
|
99
|
+
"common.premium-requests": "溢价请求",
|
|
100
|
+
"common.duration": "耗时",
|
|
101
|
+
"common.started": "开始时间",
|
|
102
|
+
"common.request-id": "请求 ID",
|
|
103
|
+
"common.session-entry": "会话条目",
|
|
104
|
+
"common.entry-id": "条目 ID",
|
|
105
|
+
"common.input": "输入",
|
|
106
|
+
"common.output": "输出",
|
|
107
|
+
"common.output-message": "输出消息",
|
|
108
|
+
"common.cache-read": "缓存读取",
|
|
109
|
+
"common.cache-write": "缓存写入",
|
|
110
|
+
"common.project": "项目",
|
|
111
|
+
"common.pages": "页数",
|
|
112
|
+
"common.filter": "筛选…",
|
|
113
|
+
"common.no-data-in-range": "该时间范围内没有数据",
|
|
114
|
+
"common.nothing-here-yet": "暂无数据",
|
|
115
|
+
"common.show-more": "显示更多",
|
|
116
|
+
"common.total": "合计",
|
|
117
|
+
"common.average": "平均",
|
|
118
|
+
"common.unknown": "未知",
|
|
119
|
+
"common.root": "(根目录)",
|
|
120
|
+
"common.other-n": "其他({count})",
|
|
121
|
+
"common.time-range": "时间范围",
|
|
122
|
+
"common.range-1h": "1 小时",
|
|
123
|
+
"common.range-24h": "24 小时",
|
|
124
|
+
"common.range-7d": "7 天",
|
|
125
|
+
"common.range-30d": "30 天",
|
|
126
|
+
"common.range-90d": "90 天",
|
|
127
|
+
"common.range-all": "全部",
|
|
128
|
+
"common.window-1h": "最近 1 小时",
|
|
129
|
+
"common.window-24h": "最近 24 小时",
|
|
130
|
+
"common.window-7d": "最近 7 天",
|
|
131
|
+
"common.window-30d": "最近 30 天",
|
|
132
|
+
"common.window-90d": "最近 90 天",
|
|
133
|
+
"common.window-all": "全部时间",
|
|
134
|
+
"common.copy": "复制",
|
|
135
|
+
"common.copied": "已复制",
|
|
136
|
+
"common.close": "关闭",
|
|
137
|
+
"common.cancel": "取消",
|
|
138
|
+
"common.aborted": "已中止",
|
|
139
|
+
"common.failed": "失败",
|
|
140
|
+
"common.error": "错误",
|
|
141
|
+
"shell.live": "实时",
|
|
142
|
+
"shell.reconnecting": "重新连接中",
|
|
143
|
+
"shell.syncing": "同步中",
|
|
144
|
+
"shell.sync-failed": "同步失败",
|
|
145
|
+
"shell.indexing": "建立索引",
|
|
146
|
+
"shell.lost-connection": "与统计服务的实时连接已断开,正在重试",
|
|
147
|
+
"shell.ingesting": "正在导入会话文件",
|
|
148
|
+
"shell.building-rollups": "正在构建小时汇总;更早的时间范围会逐步补全",
|
|
149
|
+
"shell.sync-now": "点击立即同步会话文件",
|
|
150
|
+
"shell.just-now": "刚刚",
|
|
151
|
+
"shell.system-theme": "跟随系统",
|
|
152
|
+
"shell.dark-theme": "深色主题",
|
|
153
|
+
"shell.light-theme": "浅色主题",
|
|
154
|
+
"shell.range-suffix": "范围",
|
|
155
|
+
"shell.jump-hint": "再按字母跳转",
|
|
156
|
+
},
|
|
157
|
+
} as const;
|