@oh-my-pi/omp-stats 18.6.3 → 18.8.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 +14 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +96 -96
- package/dist/types/aggregator.d.ts +3 -0
- package/dist/types/client/api.d.ts +5 -1
- 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/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 -2
- package/dist/types/embedded-client.d.ts +5 -0
- package/dist/types/live.d.ts +5 -3
- package/dist/types/rollup.d.ts +9 -1
- package/dist/types/trace.d.ts +18 -6
- package/dist/types/types.d.ts +8 -0
- package/package.json +4 -4
- package/src/aggregator.ts +26 -9
- package/src/client/api.ts +40 -5
- package/src/client/app/LiveChip.tsx +2 -2
- package/src/client/app/Shell.tsx +2 -2
- 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/routes/CostsRoute.tsx +4 -2
- package/src/client/routes/ErrorsRoute.tsx +3 -1
- package/src/client/routes/FrustrationRoute.tsx +1 -1
- package/src/client/routes/GainRoute.tsx +3 -1
- package/src/client/routes/ModelsRoute.tsx +9 -2
- package/src/client/routes/OverviewRoute.tsx +2 -2
- package/src/client/routes/ProjectsRoute.tsx +1 -1
- package/src/client/routes/ProvidersRoute.tsx +5 -3
- package/src/client/routes/RequestsRoute.tsx +1 -1
- package/src/client/routes/ToolsRoute.tsx +1 -1
- package/src/client/routes/TracesRoute.tsx +5 -4
- package/src/client/styles.css +4 -2
- package/src/client/traces/Minimap.tsx +63 -26
- package/src/client/traces/TimelineCanvas.tsx +224 -105
- package/src/client/traces/TraceView.tsx +29 -10
- package/src/client/traces/TranscriptList.tsx +17 -9
- package/src/client/traces/traces.css +26 -0
- package/src/client/ui/RequestDrawer.tsx +3 -1
- package/src/db.ts +123 -69
- package/src/embedded-client.ts +9 -0
- package/src/live.ts +36 -11
- package/src/parser.ts +18 -17
- package/src/rollup.ts +41 -20
- package/src/server.ts +38 -24
- package/src/trace.ts +54 -40
- package/src/types.ts +8 -0
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
|
}
|
|
@@ -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;
|
|
@@ -62,11 +62,13 @@ const UTC_DAY_LONG = new Intl.DateTimeFormat(undefined, {
|
|
|
62
62
|
});
|
|
63
63
|
|
|
64
64
|
export function CostsRoute({ active, range }: CostsRouteProps) {
|
|
65
|
-
const query = useQuery(["costs", range], () => getCostDashboardStats(range), { enabled: active });
|
|
65
|
+
const query = useQuery(["costs", range], ({ signal }) => getCostDashboardStats(range, signal), { enabled: active });
|
|
66
66
|
const meta = rangeMeta(range);
|
|
67
67
|
const [split, setSplit] = useState<SplitMode>("model");
|
|
68
68
|
const [hidden, toggleSeries] = useHiddenSeries();
|
|
69
69
|
const view = useMemo(() => (query.data ? buildCostsView(query.data, range) : null), [query.data, range]);
|
|
70
|
+
// Stable columns: a fresh array would make Table re-sort on every render.
|
|
71
|
+
const columns = useMemo(() => (view ? buildCostColumns(view) : []), [view]);
|
|
70
72
|
const isEmpty = (data: CostDashboardStats) => data.costSeries.length === 0;
|
|
71
73
|
const empty = <EmptyState title="No usage in this range" hint="Pick a wider range in the top bar." />;
|
|
72
74
|
|
|
@@ -167,7 +169,7 @@ export function CostsRoute({ active, range }: CostsRouteProps) {
|
|
|
167
169
|
<Table
|
|
168
170
|
rows={view.summary.models}
|
|
169
171
|
rowKey={row => row.key}
|
|
170
|
-
columns={
|
|
172
|
+
columns={columns}
|
|
171
173
|
initialSort={{ key: "cost", dir: "desc" }}
|
|
172
174
|
limit={20}
|
|
173
175
|
/>
|
|
@@ -44,7 +44,9 @@ const LOAD_STEPS = [50, 200, 1_000] as const;
|
|
|
44
44
|
export function ErrorsRoute({ active, range, onRequestClick }: ErrorsRouteProps) {
|
|
45
45
|
const [step, setStep] = useState(0);
|
|
46
46
|
const limit = LOAD_STEPS[step];
|
|
47
|
-
const errors = useQuery(["errors", range, limit], () => getRecentErrors(range, limit), {
|
|
47
|
+
const errors = useQuery(["errors", range, limit], ({ signal }) => getRecentErrors(range, limit, signal), {
|
|
48
|
+
enabled: active,
|
|
49
|
+
});
|
|
48
50
|
const [selectedSignature, setSelectedSignature] = useState<string | null>(null);
|
|
49
51
|
const [selectedModel, setSelectedModel] = useState<string | null>(null);
|
|
50
52
|
const [search, setSearch] = useState("");
|
|
@@ -53,7 +53,7 @@ const ALL_CLASSES = "*";
|
|
|
53
53
|
|
|
54
54
|
export function FrustrationRoute({ active, range }: FrustrationRouteProps) {
|
|
55
55
|
const [running, setRunning] = useState(false);
|
|
56
|
-
const stats = useQuery(["frustration", range], () => getFrustrationDashboardStats(range), {
|
|
56
|
+
const stats = useQuery(["frustration", range], ({ signal }) => getFrustrationDashboardStats(range, signal), {
|
|
57
57
|
enabled: active,
|
|
58
58
|
pollMs: running ? RUNNING_POLL_MS : undefined,
|
|
59
59
|
});
|
|
@@ -39,7 +39,9 @@ interface SourceRow extends GainSourceTotals {
|
|
|
39
39
|
|
|
40
40
|
export function GainRoute({ active, range }: GainRouteProps) {
|
|
41
41
|
const [project, setProject] = useState<string | null>(null);
|
|
42
|
-
const gain = useQuery(["gain", range, project], () => getGainDashboardStats(range, project), {
|
|
42
|
+
const gain = useQuery(["gain", range, project], ({ signal }) => getGainDashboardStats(range, project, signal), {
|
|
43
|
+
enabled: active,
|
|
44
|
+
});
|
|
43
45
|
const meta = rangeMeta(range);
|
|
44
46
|
const data = gain.data;
|
|
45
47
|
|
|
@@ -60,13 +60,20 @@ const SHARE_OPTIONS = [
|
|
|
60
60
|
const SHARE_LIMIT = 6;
|
|
61
61
|
|
|
62
62
|
export function ModelsRoute({ active, range }: ModelsRouteProps) {
|
|
63
|
-
const query = useQuery(["models", range], () => getModelDashboardStats(range), {
|
|
63
|
+
const query = useQuery(["models", range], ({ signal }) => getModelDashboardStats(range, signal), {
|
|
64
|
+
enabled: active,
|
|
65
|
+
});
|
|
64
66
|
const meta = rangeMeta(range);
|
|
65
67
|
const [mode, setMode] = useState<ShareMode>("share");
|
|
66
68
|
const [hidden, toggleSeries] = useHiddenSeries();
|
|
67
69
|
const [expandedKey, setExpandedKey] = useState<string | null>(null);
|
|
68
70
|
const view = useMemo(() => (query.data ? buildModelsView(query.data, range) : null), [query.data, range]);
|
|
69
71
|
const bucketWord = bucketName(meta.bucketMs);
|
|
72
|
+
// Stable columns: a fresh array would make Table re-sort on every render.
|
|
73
|
+
const columns = useMemo(
|
|
74
|
+
() => (view ? buildModelColumns(view, expandedKey, bucketWord) : []),
|
|
75
|
+
[view, expandedKey, bucketWord],
|
|
76
|
+
);
|
|
70
77
|
const isEmpty = (data: ModelDashboardStats) => data.byModel.length === 0;
|
|
71
78
|
const empty = <EmptyState title="No model usage in this range" hint="Pick a wider range in the top bar." />;
|
|
72
79
|
|
|
@@ -185,7 +192,7 @@ export function ModelsRoute({ active, range }: ModelsRouteProps) {
|
|
|
185
192
|
<Table
|
|
186
193
|
rows={view.models}
|
|
187
194
|
rowKey={row => modelKey(row.model, row.provider)}
|
|
188
|
-
columns={
|
|
195
|
+
columns={columns}
|
|
189
196
|
initialSort={{ key: "requests", dir: "desc" }}
|
|
190
197
|
limit={25}
|
|
191
198
|
onRowClick={row => {
|
|
@@ -62,8 +62,8 @@ const TOKEN_MIX = [
|
|
|
62
62
|
] as const;
|
|
63
63
|
|
|
64
64
|
export function OverviewRoute({ active, range, onRequestClick }: OverviewRouteProps) {
|
|
65
|
-
const overview = useQuery(["overview", range], () => getOverviewStats(range), { enabled: active });
|
|
66
|
-
const recent = useQuery(["recent-requests"], () => getRecentRequests(12), { enabled: active });
|
|
65
|
+
const overview = useQuery(["overview", range], ({ signal }) => getOverviewStats(range, signal), { enabled: active });
|
|
66
|
+
const recent = useQuery(["recent-requests"], ({ signal }) => getRecentRequests(12, signal), { enabled: active });
|
|
67
67
|
const [metric, setMetric] = useState<ActivityMetric>("requests");
|
|
68
68
|
const meta = rangeMeta(range);
|
|
69
69
|
|
|
@@ -43,7 +43,7 @@ const TABLE_LIMIT = 100;
|
|
|
43
43
|
const TOP_LIMIT = 8;
|
|
44
44
|
|
|
45
45
|
export function ProjectsRoute({ active, range }: ProjectsRouteProps) {
|
|
46
|
-
const folders = useQuery(["projects", range], () => getFolderStats(range), { enabled: active });
|
|
46
|
+
const folders = useQuery(["projects", range], ({ signal }) => getFolderStats(range, signal), { enabled: active });
|
|
47
47
|
const [search, setSearch] = useState("");
|
|
48
48
|
// Benchmarks and scratch sessions can leave tens of thousands of one-off temp folders; hide them by default.
|
|
49
49
|
const [hideTemporary, setHideTemporary] = useState(true);
|
|
@@ -75,14 +75,16 @@ interface WindowRef {
|
|
|
75
75
|
}
|
|
76
76
|
|
|
77
77
|
export function ProvidersRoute({ active, range }: ProvidersRouteProps) {
|
|
78
|
-
const stats = useQuery(["providers", range], () => getProviderDashboardStats(range), {
|
|
78
|
+
const stats = useQuery(["providers", range], ({ signal }) => getProviderDashboardStats(range, signal), {
|
|
79
|
+
enabled: active,
|
|
80
|
+
});
|
|
79
81
|
const [metric, setMetric] = useState<BurnMetric>("tokens");
|
|
80
82
|
const [hidden, toggleHidden] = useHiddenSeries();
|
|
81
83
|
const meta = rangeMeta(range);
|
|
82
84
|
|
|
83
85
|
// Subscription windows load independently: insights for every provider
|
|
84
86
|
// first, then utilization series for the one provider being charted.
|
|
85
|
-
const windows = useQuery(["provider-windows", range], () => getProviderWindowStats(range, null), {
|
|
87
|
+
const windows = useQuery(["provider-windows", range], ({ signal }) => getProviderWindowStats(range, null, signal), {
|
|
86
88
|
enabled: active,
|
|
87
89
|
});
|
|
88
90
|
const [picked, setPicked] = useState<WindowRef | null>(null);
|
|
@@ -90,7 +92,7 @@ export function ProvidersRoute({ active, range }: ProvidersRouteProps) {
|
|
|
90
92
|
const selectedProvider = selected?.provider ?? null;
|
|
91
93
|
const accounts = useQuery(
|
|
92
94
|
["provider-windows", range, selectedProvider],
|
|
93
|
-
() => getProviderWindowStats(range, selectedProvider),
|
|
95
|
+
({ signal }) => getProviderWindowStats(range, selectedProvider, signal),
|
|
94
96
|
{ enabled: active && selectedProvider !== null },
|
|
95
97
|
);
|
|
96
98
|
|
|
@@ -47,7 +47,7 @@ type StatusFilter = "all" | RequestStatus;
|
|
|
47
47
|
export function RequestsRoute({ active, range, onRequestClick }: RequestsRouteProps) {
|
|
48
48
|
const [step, setStep] = useState(0);
|
|
49
49
|
const limit = LOAD_STEPS[step];
|
|
50
|
-
const log = useQuery(["requests-log", limit], () => getRecentRequests(limit), { enabled: active });
|
|
50
|
+
const log = useQuery(["requests-log", limit], ({ signal }) => getRecentRequests(limit, signal), { enabled: active });
|
|
51
51
|
const [search, setSearch] = useState("");
|
|
52
52
|
const [status, setStatus] = useState<StatusFilter>("all");
|
|
53
53
|
const meta = rangeMeta(range);
|
|
@@ -57,7 +57,7 @@ const ATTRIBUTION_NOTE =
|
|
|
57
57
|
const NO_CALLS = <EmptyState title="No tool calls in this range" />;
|
|
58
58
|
|
|
59
59
|
export function ToolsRoute({ active, range }: ToolsRouteProps) {
|
|
60
|
-
const tools = useQuery(["tools", range], () => getToolDashboardStats(range), { enabled: active });
|
|
60
|
+
const tools = useQuery(["tools", range], ({ signal }) => getToolDashboardStats(range, signal), { enabled: active });
|
|
61
61
|
const [metric, setMetric] = useState<CallMetric>("calls");
|
|
62
62
|
const [hidden, toggleHidden] = useHiddenSeries();
|
|
63
63
|
const [pickedTool, setToolFilter] = useState<string | null>(null);
|
|
@@ -49,10 +49,11 @@ function ModelList({ models }: { models: string[] }) {
|
|
|
49
49
|
|
|
50
50
|
export function TracesRoute({ active, session, onOpenSession }: TracesRouteProps) {
|
|
51
51
|
const [filter, setFilter] = useState("");
|
|
52
|
-
const sessions = useQuery(
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
const sessions = useQuery(
|
|
53
|
+
["sessions", SESSION_LIMIT],
|
|
54
|
+
({ signal }) => getSessions(SESSION_LIMIT, undefined, signal),
|
|
55
|
+
{ pollMs: 30000, enabled: active && session === null },
|
|
56
|
+
);
|
|
56
57
|
|
|
57
58
|
const filtered = useMemo(() => {
|
|
58
59
|
const rows = sessions.data ?? [];
|
package/src/client/styles.css
CHANGED