@oh-my-pi/omp-stats 18.3.4 → 18.4.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 +16 -0
- package/README.md +13 -11
- package/build.ts +5 -49
- package/dist/client/index.css +1 -1
- package/dist/client/index.html +2 -2
- package/dist/client/index.js +105 -126
- package/dist/types/aggregator.d.ts +25 -22
- package/dist/types/client/api.d.ts +11 -7
- package/dist/types/client/app/LiveChip.d.ts +2 -0
- package/dist/types/client/app/Shell.d.ts +16 -0
- package/dist/types/client/app/ThemeToggle.d.ts +1 -0
- package/dist/types/client/app/nav.d.ts +18 -0
- package/dist/types/client/charts/BarList.d.ts +17 -0
- package/dist/types/client/charts/Chart.d.ts +49 -0
- package/dist/types/client/charts/Legend.d.ts +16 -0
- package/dist/types/client/charts/ShareBar.d.ts +11 -0
- package/dist/types/client/charts/Sparkline.d.ts +11 -0
- package/dist/types/client/charts/TimeChart.d.ts +9 -0
- package/dist/types/client/charts/index.d.ts +7 -0
- package/dist/types/client/charts/types.d.ts +25 -0
- package/dist/types/client/charts/useWidth.d.ts +3 -0
- package/dist/types/client/data/colors.d.ts +25 -0
- package/dist/types/client/data/formatters.d.ts +9 -1
- package/dist/types/client/data/live.d.ts +29 -0
- package/dist/types/client/data/query.d.ts +35 -0
- package/dist/types/client/data/range.d.ts +31 -0
- package/dist/types/client/data/series.d.ts +28 -0
- package/dist/types/client/data/useHashRoute.d.ts +18 -4
- package/dist/types/client/data/view-models.d.ts +111 -28
- package/dist/types/client/index.d.ts +0 -1
- package/dist/types/client/routes/CostsRoute.d.ts +2 -2
- package/dist/types/client/routes/ErrorsRoute.d.ts +2 -2
- package/dist/types/client/routes/FrustrationRoute.d.ts +7 -0
- package/dist/types/client/routes/GainRoute.d.ts +1 -2
- package/dist/types/client/routes/ModelsRoute.d.ts +2 -2
- package/dist/types/client/routes/OverviewRoute.d.ts +1 -2
- package/dist/types/client/routes/ProjectsRoute.d.ts +2 -2
- package/dist/types/client/routes/ProvidersRoute.d.ts +2 -2
- package/dist/types/client/routes/RequestsRoute.d.ts +1 -2
- package/dist/types/client/routes/ToolsRoute.d.ts +2 -2
- package/dist/types/client/routes/TracesRoute.d.ts +2 -2
- package/dist/types/client/routes/index.d.ts +1 -1
- package/dist/types/client/traces/AggregatesPanel.d.ts +4 -3
- package/dist/types/client/traces/SummaryStrip.d.ts +2 -2
- package/dist/types/client/traces/trace-colors.d.ts +18 -12
- package/dist/types/client/ui/Badge.d.ts +19 -0
- package/dist/types/client/ui/Card.d.ts +25 -0
- package/dist/types/client/ui/Drawer.d.ts +25 -0
- package/dist/types/client/ui/JsonBlock.d.ts +2 -2
- package/dist/types/client/ui/Modal.d.ts +23 -0
- package/dist/types/client/ui/RequestDrawer.d.ts +10 -1
- package/dist/types/client/ui/SearchInput.d.ts +8 -0
- package/dist/types/client/ui/Segmented.d.ts +15 -0
- package/dist/types/client/ui/Stat.d.ts +36 -0
- package/dist/types/client/ui/States.d.ts +41 -0
- package/dist/types/client/ui/Table.d.ts +48 -0
- package/dist/types/client/ui/index.d.ts +9 -10
- package/dist/types/db.d.ts +90 -106
- package/dist/types/frustration.d.ts +70 -0
- package/dist/types/index.d.ts +3 -1
- package/dist/types/live.d.ts +30 -0
- package/dist/types/rollup.d.ts +115 -0
- package/dist/types/server.d.ts +6 -1
- package/dist/types/shared-types.d.ts +116 -56
- package/dist/types/types.d.ts +4 -0
- package/dist/types/user-metrics.d.ts +8 -0
- package/package.json +7 -13
- package/src/aggregator.ts +276 -211
- package/src/client/App.tsx +38 -80
- package/src/client/api.ts +65 -7
- package/src/client/app/LiveChip.tsx +97 -0
- package/src/client/app/Shell.tsx +181 -0
- package/src/client/app/ThemeToggle.tsx +5 -2
- package/src/client/app/nav.ts +78 -0
- package/src/client/charts/BarList.tsx +48 -0
- package/src/client/charts/Chart.tsx +388 -0
- package/src/client/charts/Legend.tsx +61 -0
- package/src/client/charts/ShareBar.tsx +26 -0
- package/src/client/charts/Sparkline.tsx +38 -0
- package/src/client/charts/TimeChart.tsx +22 -0
- package/src/client/charts/index.ts +7 -0
- package/src/client/charts/types.ts +27 -0
- package/src/client/charts/useWidth.ts +19 -0
- package/src/client/data/colors.ts +45 -0
- package/src/client/data/formatters.ts +30 -9
- package/src/client/data/live.tsx +85 -0
- package/src/client/data/query.ts +166 -0
- package/src/client/data/range.ts +82 -0
- package/src/client/data/series.ts +85 -0
- package/src/client/data/useHashRoute.ts +41 -83
- package/src/client/data/view-models.ts +347 -140
- package/src/client/index.tsx +3 -6
- package/src/client/routes/CostsRoute.tsx +394 -222
- package/src/client/routes/ErrorsRoute.tsx +401 -98
- package/src/client/routes/FrustrationRoute.tsx +924 -0
- package/src/client/routes/GainRoute.tsx +209 -206
- package/src/client/routes/ModelsRoute.tsx +540 -431
- package/src/client/routes/OverviewRoute.tsx +283 -311
- package/src/client/routes/ProjectsRoute.tsx +298 -156
- package/src/client/routes/ProvidersRoute.tsx +948 -416
- package/src/client/routes/RequestsRoute.tsx +295 -107
- package/src/client/routes/ToolsRoute.tsx +495 -398
- package/src/client/routes/TracesRoute.tsx +136 -117
- package/src/client/routes/costs.css +24 -0
- package/src/client/routes/errors.css +60 -0
- package/src/client/routes/frustration.css +123 -0
- package/src/client/routes/index.ts +1 -1
- package/src/client/routes/models.css +43 -0
- package/src/client/routes/projects.css +6 -0
- package/src/client/routes/providers.css +28 -0
- package/src/client/routes/tools.css +14 -0
- package/src/client/styles.css +1396 -1299
- package/src/client/traces/AggregatesPanel.tsx +55 -74
- package/src/client/traces/Minimap.tsx +6 -15
- package/src/client/traces/SpanDrawer.tsx +128 -194
- package/src/client/traces/SummaryStrip.tsx +21 -24
- package/src/client/traces/TimelineCanvas.tsx +27 -44
- package/src/client/traces/TraceView.tsx +175 -130
- package/src/client/traces/TranscriptList.tsx +13 -41
- package/src/client/traces/trace-colors.ts +60 -53
- package/src/client/traces/traces.css +279 -0
- package/src/client/ui/Badge.tsx +29 -0
- package/src/client/ui/Card.tsx +61 -0
- package/src/client/ui/Drawer.tsx +93 -0
- package/src/client/ui/JsonBlock.tsx +26 -44
- package/src/client/ui/Modal.tsx +106 -0
- package/src/client/ui/RequestDrawer.tsx +163 -188
- package/src/client/ui/SearchInput.tsx +25 -0
- package/src/client/ui/Segmented.tsx +61 -0
- package/src/client/ui/Stat.tsx +104 -0
- package/src/client/ui/States.tsx +81 -0
- package/src/client/ui/Table.tsx +229 -0
- package/src/client/ui/index.ts +9 -10
- package/src/client/ui/modal.css +54 -0
- package/src/client/ui/request-drawer.css +40 -0
- package/src/db.ts +379 -983
- package/src/frustration.ts +473 -0
- package/src/index.ts +22 -22
- package/src/live.ts +259 -0
- package/src/parser.ts +55 -66
- package/src/rollup.ts +951 -0
- package/src/server.ts +123 -40
- package/src/shared-types.ts +120 -56
- package/src/trace.ts +14 -7
- package/src/types.ts +4 -0
- package/src/user-metrics.ts +13 -0
- package/dist/client/styles.css +0 -1976
- package/dist/types/client/app/AppLayout.d.ts +0 -16
- package/dist/types/client/app/NavRail.d.ts +0 -7
- package/dist/types/client/app/RangeControl.d.ts +0 -7
- package/dist/types/client/app/SyncButton.d.ts +0 -14
- package/dist/types/client/app/TopBar.d.ts +0 -15
- package/dist/types/client/app/routes.d.ts +0 -12
- package/dist/types/client/components/AgentTokenShare.d.ts +0 -5
- package/dist/types/client/components/chart-shared.d.ts +0 -178
- package/dist/types/client/components/models-table-shared.d.ts +0 -175
- package/dist/types/client/components/range-meta.d.ts +0 -21
- package/dist/types/client/data/charts.d.ts +0 -1
- package/dist/types/client/data/useResource.d.ts +0 -13
- package/dist/types/client/routes/BehaviorRoute.d.ts +0 -7
- package/dist/types/client/ui/AsyncBoundary.d.ts +0 -12
- package/dist/types/client/ui/DataTable.d.ts +0 -17
- package/dist/types/client/ui/EmptyState.d.ts +0 -7
- package/dist/types/client/ui/ErrorState.d.ts +0 -6
- package/dist/types/client/ui/MetricCluster.d.ts +0 -5
- package/dist/types/client/ui/Panel.d.ts +0 -7
- package/dist/types/client/ui/SegmentedControl.d.ts +0 -12
- package/dist/types/client/ui/Skeleton.d.ts +0 -8
- package/dist/types/client/ui/StatusPill.d.ts +0 -7
- package/src/client/app/AppLayout.tsx +0 -93
- package/src/client/app/NavRail.tsx +0 -44
- package/src/client/app/RangeControl.tsx +0 -39
- package/src/client/app/SyncButton.tsx +0 -75
- package/src/client/app/TopBar.tsx +0 -73
- package/src/client/app/routes.ts +0 -93
- package/src/client/components/AgentTokenShare.tsx +0 -68
- package/src/client/components/chart-shared.tsx +0 -273
- package/src/client/components/models-table-shared.tsx +0 -255
- package/src/client/components/range-meta.ts +0 -73
- package/src/client/data/charts.ts +0 -14
- package/src/client/data/useResource.ts +0 -154
- package/src/client/routes/BehaviorRoute.tsx +0 -623
- package/src/client/ui/AsyncBoundary.tsx +0 -54
- package/src/client/ui/DataTable.tsx +0 -122
- package/src/client/ui/EmptyState.tsx +0 -16
- package/src/client/ui/ErrorState.tsx +0 -25
- package/src/client/ui/MetricCluster.tsx +0 -92
- package/src/client/ui/Panel.tsx +0 -24
- package/src/client/ui/SegmentedControl.tsx +0 -36
- package/src/client/ui/Skeleton.tsx +0 -17
- package/src/client/ui/StatusPill.tsx +0 -15
- package/tailwind.config.js +0 -40
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { formatDistanceToNow } from "@oh-my-pi/pi-utils/dates";
|
|
1
|
+
import { format, formatDistanceToNow } from "@oh-my-pi/pi-utils/dates";
|
|
2
2
|
import type { MessageStats } from "../types";
|
|
3
3
|
|
|
4
4
|
export function formatInteger(value: number): string {
|
|
@@ -24,22 +24,25 @@ export function formatEstimatedCost(value: number, unpricedRequests: number, dig
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
27
|
+
* Whether one request's zero cost means unknown spend rather than free usage.
|
|
28
28
|
* Mirrors the server's `unpricedRequestSql`: `xai-oauth` is billed through a
|
|
29
29
|
* subscription, and `costUnpriced` marks a row the ingest path could not price
|
|
30
30
|
* (a scheduled card with no recoverable request timestamp).
|
|
31
31
|
*/
|
|
32
|
+
export function isUnpricedMessage(message: Pick<MessageStats, "provider" | "usage" | "costUnpriced">): boolean {
|
|
33
|
+
return (
|
|
34
|
+
message.usage.totalTokens > 0 &&
|
|
35
|
+
message.usage.cost.total === 0 &&
|
|
36
|
+
(message.provider === "xai-oauth" || message.costUnpriced === true)
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Format one request's cost, distinguishing unpriced usage from free usage. */
|
|
32
41
|
export function formatMessageCost(
|
|
33
42
|
message: Pick<MessageStats, "provider" | "usage" | "costUnpriced">,
|
|
34
43
|
digits?: number,
|
|
35
44
|
): string {
|
|
36
|
-
|
|
37
|
-
message.usage.totalTokens > 0 &&
|
|
38
|
-
message.usage.cost.total === 0 &&
|
|
39
|
-
(message.provider === "xai-oauth" || message.costUnpriced === true)
|
|
40
|
-
? 1
|
|
41
|
-
: 0;
|
|
42
|
-
return formatEstimatedCost(message.usage.cost.total, unpricedRequests, digits);
|
|
45
|
+
return formatEstimatedCost(message.usage.cost.total, isUnpricedMessage(message) ? 1 : 0, digits);
|
|
43
46
|
}
|
|
44
47
|
|
|
45
48
|
export function formatPercent(value: number, digits = 1): string {
|
|
@@ -53,6 +56,14 @@ export function formatDurationMs(value: number | null, digits?: number): string
|
|
|
53
56
|
return `${sec.toFixed(d)}s`;
|
|
54
57
|
}
|
|
55
58
|
|
|
59
|
+
/** Wall-clock length: seconds below a minute, then `17m 41s` / `2h 05m`. */
|
|
60
|
+
export function formatElapsed(ms: number): string {
|
|
61
|
+
if (ms < 60_000) return formatDurationMs(ms);
|
|
62
|
+
const totalMin = Math.floor(ms / 60_000);
|
|
63
|
+
if (totalMin < 60) return `${totalMin}m ${String(Math.floor((ms % 60_000) / 1000)).padStart(2, "0")}s`;
|
|
64
|
+
return `${Math.floor(totalMin / 60)}h ${String(totalMin % 60).padStart(2, "0")}m`;
|
|
65
|
+
}
|
|
66
|
+
|
|
56
67
|
export function formatTokensPerSecond(value: number | null): string {
|
|
57
68
|
if (value === null) return "-";
|
|
58
69
|
return value.toFixed(1);
|
|
@@ -62,6 +73,16 @@ export function formatRelativeTime(timestamp: number): string {
|
|
|
62
73
|
return formatDistanceToNow(new Date(timestamp), { addSuffix: true });
|
|
63
74
|
}
|
|
64
75
|
|
|
76
|
+
/** Absolute local date and time to the second ("Sep 28, 14:03:22"). */
|
|
77
|
+
export function formatTimestamp(timestamp: number): string {
|
|
78
|
+
return format(new Date(timestamp), "MMM d, HH:mm:ss");
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Project folder as stored by ingest (`/work-pi/`), without the slashes. */
|
|
82
|
+
export function formatFolder(folder: string): string {
|
|
83
|
+
return folder.replace(/^\/+|\/+$/g, "") || "(root)";
|
|
84
|
+
}
|
|
85
|
+
|
|
65
86
|
export function formatBytes(value: number): string {
|
|
66
87
|
if (value >= 1e9) return `${(value / 1e9).toFixed(1)} GB`;
|
|
67
88
|
if (value >= 1e6) return `${(value / 1e6).toFixed(1)} MB`;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Live dashboard state streamed from the server (`/api/events`): the data
|
|
3
|
+
* version every query revalidates against, background ingest progress, and
|
|
4
|
+
* the rollup indexing backlog.
|
|
5
|
+
*
|
|
6
|
+
* Any component can `useLive()`; queries (see `useQuery`) refetch whenever
|
|
7
|
+
* `version` advances, so rows ingested in the background repaint the active
|
|
8
|
+
* page without a manual refresh.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { createContext, type ReactNode, useCallback, useContext, useEffect, useMemo, useState } from "react";
|
|
12
|
+
import * as api from "../api";
|
|
13
|
+
import type { LiveStatus, LiveSyncStatus } from "../types";
|
|
14
|
+
|
|
15
|
+
export interface LiveState {
|
|
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;
|
|
21
|
+
sync: LiveSyncStatus;
|
|
22
|
+
/** Hours whose rollups are still being built; wide ranges may be incomplete. */
|
|
23
|
+
indexingHours: number;
|
|
24
|
+
/** The event stream is open. */
|
|
25
|
+
connected: boolean;
|
|
26
|
+
/** Ask the server to ingest new session data now. */
|
|
27
|
+
requestSync: () => void;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const IDLE_SYNC: LiveSyncStatus = {
|
|
31
|
+
phase: "idle",
|
|
32
|
+
current: 0,
|
|
33
|
+
total: 0,
|
|
34
|
+
processed: 0,
|
|
35
|
+
lastSyncedAt: null,
|
|
36
|
+
error: null,
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const LiveContext = createContext<LiveState>({
|
|
40
|
+
version: 0,
|
|
41
|
+
sync: IDLE_SYNC,
|
|
42
|
+
indexingHours: 0,
|
|
43
|
+
connected: false,
|
|
44
|
+
requestSync: () => {},
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export function LiveProvider({ children }: { children: ReactNode }) {
|
|
48
|
+
const [status, setStatus] = useState<{ server: LiveStatus | null; version: number }>({ server: null, version: 0 });
|
|
49
|
+
const [connected, setConnected] = useState(false);
|
|
50
|
+
|
|
51
|
+
useEffect(() => {
|
|
52
|
+
const source = new EventSource("/api/events");
|
|
53
|
+
source.onopen = () => setConnected(true);
|
|
54
|
+
source.onerror = () => setConnected(false);
|
|
55
|
+
source.onmessage = event => {
|
|
56
|
+
const next = JSON.parse(event.data) as LiveStatus;
|
|
57
|
+
setStatus(prev => ({
|
|
58
|
+
server: next,
|
|
59
|
+
version: prev.server && prev.server.version === next.version ? prev.version : prev.version + 1,
|
|
60
|
+
}));
|
|
61
|
+
};
|
|
62
|
+
return () => source.close();
|
|
63
|
+
}, []);
|
|
64
|
+
|
|
65
|
+
const requestSync = useCallback(() => {
|
|
66
|
+
api.requestSync().catch(() => {});
|
|
67
|
+
}, []);
|
|
68
|
+
|
|
69
|
+
const value = useMemo<LiveState>(
|
|
70
|
+
() => ({
|
|
71
|
+
// Start at 0 so the first stream message (version 1) doesn't refetch what just loaded.
|
|
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>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function useLive(): LiveState {
|
|
84
|
+
return useContext(LiveContext);
|
|
85
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `useQuery`: cached, live-revalidating data fetching for dashboard pages.
|
|
3
|
+
*
|
|
4
|
+
* - Results are cached per key for the page session, so revisiting a page or
|
|
5
|
+
* a range renders instantly from memory.
|
|
6
|
+
* - A key change keeps the previous key's data on screen (`stale: true`) until
|
|
7
|
+
* the new data lands, so charts morph instead of flashing skeletons.
|
|
8
|
+
* - Every cached entry remembers the live data version it was fetched at
|
|
9
|
+
* (see `useLive`); when the version advances, enabled queries refetch in the
|
|
10
|
+
* background, throttled so a burst of ingest batches costs one request.
|
|
11
|
+
* - Identical keys share one in-flight request.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { startTransition, useCallback, useEffect, useReducer, useRef, useState } from "react";
|
|
15
|
+
import { useLive } from "./live";
|
|
16
|
+
|
|
17
|
+
interface CacheEntry {
|
|
18
|
+
data: unknown;
|
|
19
|
+
updatedAt: number;
|
|
20
|
+
version: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const CACHE_LIMIT = 128;
|
|
24
|
+
/** Minimum spacing between live-driven refetches of one query. */
|
|
25
|
+
const LIVE_REFETCH_THROTTLE_MS = 1500;
|
|
26
|
+
|
|
27
|
+
const cache = new Map<string, CacheEntry>();
|
|
28
|
+
const inflight = new Map<string, Promise<unknown>>();
|
|
29
|
+
|
|
30
|
+
export interface QueryOptions {
|
|
31
|
+
/** Only enabled queries fetch; hidden (kept-alive) pages pass `false`. Default true. */
|
|
32
|
+
enabled?: boolean;
|
|
33
|
+
/** Additionally refetch on this interval while enabled and the tab is visible. */
|
|
34
|
+
pollMs?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface QueryResult<T> {
|
|
38
|
+
/** Data for the current key, or the previous key's data while it loads. */
|
|
39
|
+
data: T | null;
|
|
40
|
+
error: Error | null;
|
|
41
|
+
/** Nothing to show yet (first load of this query). */
|
|
42
|
+
loading: boolean;
|
|
43
|
+
/** `data` belongs to a previous key; the current key is still loading. */
|
|
44
|
+
stale: boolean;
|
|
45
|
+
/** A background request is in flight. */
|
|
46
|
+
refreshing: boolean;
|
|
47
|
+
/** When the shown data was fetched. */
|
|
48
|
+
updatedAt: number | null;
|
|
49
|
+
refetch: () => void;
|
|
50
|
+
}
|
|
51
|
+
|
|
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
|
+
/** Warm the cache for a key the user is likely to open next. No-op if cached for this version. */
|
|
78
|
+
export function prefetchQuery<T>(key: readonly unknown[], fetcher: () => Promise<T>, version: number): void {
|
|
79
|
+
const keyString = JSON.stringify(key);
|
|
80
|
+
const entry = cache.get(keyString);
|
|
81
|
+
if (entry && entry.version >= version) return;
|
|
82
|
+
load(keyString, fetcher, version).catch(() => {});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function useQuery<T>(
|
|
86
|
+
key: readonly unknown[],
|
|
87
|
+
fetcher: () => Promise<T>,
|
|
88
|
+
options?: QueryOptions,
|
|
89
|
+
): QueryResult<T> {
|
|
90
|
+
const keyString = JSON.stringify(key);
|
|
91
|
+
const enabled = options?.enabled ?? true;
|
|
92
|
+
const pollMs = options?.pollMs;
|
|
93
|
+
const { version } = useLive();
|
|
94
|
+
|
|
95
|
+
const [, rerender] = useReducer((n: number) => n + 1, 0);
|
|
96
|
+
const [error, setError] = useState<{ key: string; error: Error } | null>(null);
|
|
97
|
+
const [refreshing, setRefreshing] = useState(false);
|
|
98
|
+
|
|
99
|
+
const fetcherRef = useRef(fetcher);
|
|
100
|
+
fetcherRef.current = fetcher;
|
|
101
|
+
const keyRef = useRef(keyString);
|
|
102
|
+
keyRef.current = keyString;
|
|
103
|
+
const versionRef = useRef(version);
|
|
104
|
+
versionRef.current = version;
|
|
105
|
+
const lastShown = useRef<{ key: string; data: unknown; updatedAt: number } | null>(null);
|
|
106
|
+
const lastFetchAt = useRef(0);
|
|
107
|
+
|
|
108
|
+
const run = useCallback((targetKey: string) => {
|
|
109
|
+
lastFetchAt.current = Date.now();
|
|
110
|
+
setRefreshing(true);
|
|
111
|
+
load(targetKey, () => fetcherRef.current(), versionRef.current)
|
|
112
|
+
.then(() => {
|
|
113
|
+
if (keyRef.current !== targetKey) return;
|
|
114
|
+
startTransition(() => {
|
|
115
|
+
setError(null);
|
|
116
|
+
rerender();
|
|
117
|
+
});
|
|
118
|
+
})
|
|
119
|
+
.catch((err: unknown) => {
|
|
120
|
+
if (keyRef.current !== targetKey) return;
|
|
121
|
+
setError({ key: targetKey, error: err instanceof Error ? err : new Error(String(err)) });
|
|
122
|
+
})
|
|
123
|
+
.finally(() => {
|
|
124
|
+
if (keyRef.current === targetKey) setRefreshing(false);
|
|
125
|
+
});
|
|
126
|
+
}, []);
|
|
127
|
+
|
|
128
|
+
// Fetch on key change / enable / data-version bump when the cache is behind.
|
|
129
|
+
useEffect(() => {
|
|
130
|
+
if (!enabled) return;
|
|
131
|
+
const entry = cache.get(keyString);
|
|
132
|
+
if (entry && entry.version >= version) return;
|
|
133
|
+
const wait = entry ? lastFetchAt.current + LIVE_REFETCH_THROTTLE_MS - Date.now() : 0;
|
|
134
|
+
if (wait <= 0) {
|
|
135
|
+
run(keyString);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
const timer = setTimeout(() => run(keyString), wait);
|
|
139
|
+
return () => clearTimeout(timer);
|
|
140
|
+
}, [keyString, enabled, version, run]);
|
|
141
|
+
|
|
142
|
+
useEffect(() => {
|
|
143
|
+
if (!enabled || !pollMs) return;
|
|
144
|
+
const interval = setInterval(() => {
|
|
145
|
+
if (!document.hidden) run(keyRef.current);
|
|
146
|
+
}, pollMs);
|
|
147
|
+
return () => clearInterval(interval);
|
|
148
|
+
}, [enabled, pollMs, run]);
|
|
149
|
+
|
|
150
|
+
const refetch = useCallback(() => run(keyRef.current), [run]);
|
|
151
|
+
|
|
152
|
+
const entry = cache.get(keyString);
|
|
153
|
+
if (entry) lastShown.current = { key: keyString, data: entry.data, updatedAt: entry.updatedAt };
|
|
154
|
+
const shown = lastShown.current;
|
|
155
|
+
const currentError = error?.key === keyString ? error.error : null;
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
data: (shown?.data as T | undefined) ?? null,
|
|
159
|
+
error: currentError,
|
|
160
|
+
loading: !shown && !currentError,
|
|
161
|
+
stale: !!shown && shown.key !== keyString,
|
|
162
|
+
refreshing,
|
|
163
|
+
updatedAt: shown?.updatedAt ?? null,
|
|
164
|
+
refetch,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time-range metadata shared by every page: labels, the server's bucket size
|
|
3
|
+
* for the range (mirrors `getTimeRangeConfig` in `aggregator.ts`), tick
|
|
4
|
+
* formatting, and the contiguous bucket axis charts plot against.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { format } from "@oh-my-pi/pi-utils/dates";
|
|
8
|
+
import type { TimeRange } from "../types";
|
|
9
|
+
|
|
10
|
+
const MINUTE_MS = 60 * 1000;
|
|
11
|
+
const HOUR_MS = 60 * MINUTE_MS;
|
|
12
|
+
const DAY_MS = 24 * HOUR_MS;
|
|
13
|
+
|
|
14
|
+
export interface RangeMeta {
|
|
15
|
+
/** Short selector label ("24h"). */
|
|
16
|
+
label: string;
|
|
17
|
+
/** Prose label for subtitles ("the last 24 hours"). */
|
|
18
|
+
windowLabel: string;
|
|
19
|
+
/** Span covered by the range; `null` for all time. */
|
|
20
|
+
spanMs: number | null;
|
|
21
|
+
/** Bucket size of the server's primary time series for this range. */
|
|
22
|
+
bucketMs: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const RANGE_META: Record<TimeRange, RangeMeta> = {
|
|
26
|
+
"1h": { label: "1h", windowLabel: "the last hour", spanMs: HOUR_MS, bucketMs: 5 * MINUTE_MS },
|
|
27
|
+
"24h": { label: "24h", windowLabel: "the last 24 hours", spanMs: DAY_MS, bucketMs: HOUR_MS },
|
|
28
|
+
"7d": { label: "7d", windowLabel: "the last 7 days", spanMs: 7 * DAY_MS, bucketMs: DAY_MS },
|
|
29
|
+
"30d": { label: "30d", windowLabel: "the last 30 days", spanMs: 30 * DAY_MS, bucketMs: DAY_MS },
|
|
30
|
+
"90d": { label: "90d", windowLabel: "the last 90 days", spanMs: 90 * DAY_MS, bucketMs: DAY_MS },
|
|
31
|
+
all: { label: "All", windowLabel: "all time", spanMs: null, bucketMs: DAY_MS },
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const MAX_BUCKETS = 1500;
|
|
35
|
+
|
|
36
|
+
/** Ranges in selector order. */
|
|
37
|
+
export const TIME_RANGES: readonly TimeRange[] = ["1h", "24h", "7d", "30d", "90d", "all"];
|
|
38
|
+
|
|
39
|
+
export function rangeMeta(range: TimeRange): RangeMeta {
|
|
40
|
+
return RANGE_META[range];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Tick/tooltip label for a bucket start, precise enough for the bucket size. */
|
|
44
|
+
export function formatBucket(timestamp: number, bucketMs: number): string {
|
|
45
|
+
if (bucketMs < DAY_MS) return format(new Date(timestamp), bucketMs < HOUR_MS ? "HH:mm" : "MMM d HH:mm");
|
|
46
|
+
return format(new Date(timestamp), "MMM d");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Compact axis tick for a bucket start. */
|
|
50
|
+
export function formatTick(timestamp: number, bucketMs: number): string {
|
|
51
|
+
if (bucketMs < DAY_MS) return format(new Date(timestamp), "HH:mm");
|
|
52
|
+
return format(new Date(timestamp), "MMM d");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Contiguous, ascending bucket starts covering `range`, aligned like the
|
|
57
|
+
* server's `(timestamp / bucketMs) * bucketMs`. For all time the axis starts at
|
|
58
|
+
* the earliest data point, so gaps between sessions still render as zeros;
|
|
59
|
+
* it is capped at {@link MAX_BUCKETS} so a stray epoch-zero timestamp cannot
|
|
60
|
+
* explode the axis into decades of empty days.
|
|
61
|
+
*/
|
|
62
|
+
export function bucketAxis(
|
|
63
|
+
range: TimeRange,
|
|
64
|
+
dataTimestamps: Iterable<number>,
|
|
65
|
+
bucketMs = RANGE_META[range].bucketMs,
|
|
66
|
+
now = Date.now(),
|
|
67
|
+
): number[] {
|
|
68
|
+
const last = Math.floor(now / bucketMs) * bucketMs;
|
|
69
|
+
const span = RANGE_META[range].spanMs;
|
|
70
|
+
let first: number;
|
|
71
|
+
if (span !== null) {
|
|
72
|
+
first = Math.floor((now - span) / bucketMs) * bucketMs;
|
|
73
|
+
} else {
|
|
74
|
+
first = last;
|
|
75
|
+
for (const ts of dataTimestamps) if (ts < first) first = ts;
|
|
76
|
+
first = Math.floor(first / bucketMs) * bucketMs;
|
|
77
|
+
}
|
|
78
|
+
first = Math.max(first, last - (MAX_BUCKETS - 1) * bucketMs);
|
|
79
|
+
const buckets: number[] = [];
|
|
80
|
+
for (let t = first; t <= last; t += bucketMs) buckets.push(t);
|
|
81
|
+
return buckets;
|
|
82
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turn the server's long-format bucket rows (`{ timestamp, key…, value }`)
|
|
3
|
+
* into dense chart series aligned to a bucket axis (see `bucketAxis`).
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { ChartSeries } from "../charts/types";
|
|
7
|
+
import { OTHER_COLOR, SERIES_COLORS } from "./colors";
|
|
8
|
+
|
|
9
|
+
/** Sum `value(point)` into a dense array aligned to `buckets`. Out-of-axis points are dropped. */
|
|
10
|
+
export function densify<P extends { timestamp: number }>(
|
|
11
|
+
points: readonly P[],
|
|
12
|
+
buckets: readonly number[],
|
|
13
|
+
value: (point: P) => number,
|
|
14
|
+
): number[] {
|
|
15
|
+
const index = bucketIndex(buckets);
|
|
16
|
+
const out = Array.from({ length: buckets.length }, () => 0);
|
|
17
|
+
for (const point of points) {
|
|
18
|
+
const i = index.get(point.timestamp);
|
|
19
|
+
if (i !== undefined) out[i] += value(point);
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PivotOptions<P> {
|
|
25
|
+
buckets: readonly number[];
|
|
26
|
+
/** Series identity for a row. */
|
|
27
|
+
key: (point: P) => string;
|
|
28
|
+
/** Display label for a series key; defaults to the key. */
|
|
29
|
+
label?: (key: string) => string;
|
|
30
|
+
value: (point: P) => number;
|
|
31
|
+
/** Keep the `limit` largest series (by total); fold the rest into "Other". */
|
|
32
|
+
limit?: number;
|
|
33
|
+
/** Fixed colors per key (e.g. from `buildModelColorLookup`); falls back to rank order. */
|
|
34
|
+
colors?: ReadonlyMap<string, string>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Pivot long rows into one dense series per key, ranked by total (largest
|
|
39
|
+
* first). Series beyond `limit` merge into a trailing "Other" series.
|
|
40
|
+
*/
|
|
41
|
+
export function pivotSeries<P extends { timestamp: number }>(
|
|
42
|
+
points: readonly P[],
|
|
43
|
+
opts: PivotOptions<P>,
|
|
44
|
+
): ChartSeries[] {
|
|
45
|
+
const index = bucketIndex(opts.buckets);
|
|
46
|
+
const byKey = new Map<string, number[]>();
|
|
47
|
+
for (const point of points) {
|
|
48
|
+
const i = index.get(point.timestamp);
|
|
49
|
+
if (i === undefined) continue;
|
|
50
|
+
const key = opts.key(point);
|
|
51
|
+
let values = byKey.get(key);
|
|
52
|
+
if (!values) {
|
|
53
|
+
values = Array.from({ length: opts.buckets.length }, () => 0);
|
|
54
|
+
byKey.set(key, values);
|
|
55
|
+
}
|
|
56
|
+
values[i] += opts.value(point);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const ranked = [...byKey.entries()]
|
|
60
|
+
.map(([key, values]) => ({ key, values, total: values.reduce((sum, v) => sum + v, 0) }))
|
|
61
|
+
.filter(entry => entry.total !== 0)
|
|
62
|
+
.sort((a, b) => b.total - a.total || a.key.localeCompare(b.key));
|
|
63
|
+
|
|
64
|
+
const limit = opts.limit ?? Number.POSITIVE_INFINITY;
|
|
65
|
+
const head = ranked.slice(0, limit);
|
|
66
|
+
const tail = ranked.slice(limit);
|
|
67
|
+
const series: ChartSeries[] = head.map((entry, rank) => ({
|
|
68
|
+
key: entry.key,
|
|
69
|
+
label: opts.label ? opts.label(entry.key) : entry.key,
|
|
70
|
+
color: opts.colors?.get(entry.key) ?? SERIES_COLORS[rank % SERIES_COLORS.length],
|
|
71
|
+
values: entry.values,
|
|
72
|
+
}));
|
|
73
|
+
if (tail.length > 0) {
|
|
74
|
+
const other = Array.from({ length: opts.buckets.length }, () => 0);
|
|
75
|
+
for (const entry of tail) for (let i = 0; i < other.length; i++) other[i] += entry.values[i];
|
|
76
|
+
series.push({ key: "__other__", label: `Other (${tail.length})`, color: OTHER_COLOR, values: other });
|
|
77
|
+
}
|
|
78
|
+
return series;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function bucketIndex(buckets: readonly number[]): Map<number, number> {
|
|
82
|
+
const index = new Map<number, number>();
|
|
83
|
+
for (let i = 0; i < buckets.length; i++) index.set(buckets[i], i);
|
|
84
|
+
return index;
|
|
85
|
+
}
|
|
@@ -1,106 +1,64 @@
|
|
|
1
1
|
import { useCallback, useEffect, useState } from "react";
|
|
2
|
-
import type
|
|
2
|
+
import { type DashboardSection, SECTIONS } from "../app/nav";
|
|
3
3
|
import type { TimeRange } from "../types";
|
|
4
|
+
import { TIME_RANGES } from "./range";
|
|
4
5
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
"providers",
|
|
12
|
-
"tools",
|
|
13
|
-
"costs",
|
|
14
|
-
"behavior",
|
|
15
|
-
"projects",
|
|
16
|
-
"gain",
|
|
17
|
-
];
|
|
18
|
-
|
|
19
|
-
const VALID_RANGES: TimeRange[] = ["1h", "24h", "7d", "30d", "90d", "all"];
|
|
20
|
-
|
|
21
|
-
function parseHash(hash: string): { section: DashboardSection; range: TimeRange; session: string | null } {
|
|
22
|
-
const cleanHash = hash.replace(/^#\/?/, "");
|
|
23
|
-
const [pathPart, queryPart] = cleanHash.split("?");
|
|
24
|
-
|
|
25
|
-
const section: DashboardSection = (VALID_SECTIONS as string[]).includes(pathPart)
|
|
26
|
-
? (pathPart as DashboardSection)
|
|
27
|
-
: "overview";
|
|
28
|
-
|
|
29
|
-
let range: TimeRange = "24h";
|
|
30
|
-
let session: string | null = null;
|
|
31
|
-
if (queryPart) {
|
|
32
|
-
const params = new URLSearchParams(queryPart);
|
|
33
|
-
const rangeParam = params.get("range") as TimeRange;
|
|
34
|
-
if (VALID_RANGES.includes(rangeParam)) {
|
|
35
|
-
range = rangeParam;
|
|
36
|
-
}
|
|
37
|
-
session = params.get("s");
|
|
38
|
-
}
|
|
6
|
+
export interface HashRoute {
|
|
7
|
+
section: DashboardSection;
|
|
8
|
+
range: TimeRange;
|
|
9
|
+
/** Deep-linked trace session file (traces only). */
|
|
10
|
+
session: string | null;
|
|
11
|
+
}
|
|
39
12
|
|
|
40
|
-
|
|
13
|
+
export function parseHash(hash: string): HashRoute {
|
|
14
|
+
const [pathPart, queryPart] = hash.replace(/^#\/?/, "").split("?");
|
|
15
|
+
const section = (SECTIONS as readonly string[]).includes(pathPart) ? (pathPart as DashboardSection) : "overview";
|
|
16
|
+
const params = new URLSearchParams(queryPart ?? "");
|
|
17
|
+
const rangeParam = params.get("range");
|
|
18
|
+
const range = (TIME_RANGES as readonly string[]).includes(rangeParam ?? "") ? (rangeParam as TimeRange) : "24h";
|
|
19
|
+
return { section, range, session: params.get("s") };
|
|
41
20
|
}
|
|
42
21
|
|
|
43
|
-
function buildHash(section
|
|
22
|
+
export function buildHash({ section, range, session }: HashRoute): string {
|
|
44
23
|
const sessionPart = session ? `&s=${encodeURIComponent(session)}` : "";
|
|
45
|
-
return
|
|
24
|
+
return `#/${section}?range=${range}${sessionPart}`;
|
|
46
25
|
}
|
|
47
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Dashboard location in the URL hash (`#/models?range=7d`). Setters update
|
|
29
|
+
* state synchronously and push a history entry, so the UI never waits on the
|
|
30
|
+
* `hashchange` round trip.
|
|
31
|
+
*/
|
|
48
32
|
export function useHashRoute() {
|
|
49
|
-
const [route,
|
|
33
|
+
const [route, setRoute] = useState(() => parseHash(window.location.hash));
|
|
50
34
|
|
|
51
35
|
useEffect(() => {
|
|
52
|
-
const
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
window.
|
|
36
|
+
const onHashChange = () => setRoute(parseHash(window.location.hash));
|
|
37
|
+
window.addEventListener("hashchange", onHashChange);
|
|
38
|
+
window.addEventListener("popstate", onHashChange);
|
|
39
|
+
// Canonicalize a missing/partial hash without adding a history entry.
|
|
40
|
+
const canonical = buildHash(parseHash(window.location.hash));
|
|
41
|
+
if (window.location.hash !== canonical) history.replaceState(null, "", canonical);
|
|
57
42
|
return () => {
|
|
58
|
-
window.removeEventListener("hashchange",
|
|
43
|
+
window.removeEventListener("hashchange", onHashChange);
|
|
44
|
+
window.removeEventListener("popstate", onHashChange);
|
|
59
45
|
};
|
|
60
46
|
}, []);
|
|
61
47
|
|
|
62
|
-
const
|
|
63
|
-
|
|
48
|
+
const navigate = useCallback((next: HashRoute) => {
|
|
49
|
+
setRoute(next);
|
|
50
|
+
const hash = buildHash(next);
|
|
51
|
+
if (window.location.hash !== hash) history.pushState(null, "", hash);
|
|
64
52
|
}, []);
|
|
65
53
|
|
|
66
54
|
const setSection = useCallback(
|
|
67
|
-
(
|
|
55
|
+
(section: DashboardSection) =>
|
|
68
56
|
// The deep-linked session only applies to the traces view.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
[route.range, route.session, updateHash],
|
|
57
|
+
navigate({ ...route, section, session: section === "traces" ? route.session : null }),
|
|
58
|
+
[route, navigate],
|
|
72
59
|
);
|
|
60
|
+
const setRange = useCallback((range: TimeRange) => navigate({ ...route, range }), [route, navigate]);
|
|
61
|
+
const setSession = useCallback((session: string | null) => navigate({ ...route, session }), [route, navigate]);
|
|
73
62
|
|
|
74
|
-
|
|
75
|
-
(newRange: string) => {
|
|
76
|
-
const nextRange = VALID_RANGES.includes(newRange as TimeRange) ? (newRange as TimeRange) : "24h";
|
|
77
|
-
updateHash(route.section, nextRange, route.session);
|
|
78
|
-
},
|
|
79
|
-
[route.section, route.session, updateHash],
|
|
80
|
-
);
|
|
81
|
-
|
|
82
|
-
const setSession = useCallback(
|
|
83
|
-
(file: string | null) => {
|
|
84
|
-
updateHash(route.section, route.range, file);
|
|
85
|
-
},
|
|
86
|
-
[route.section, route.range, updateHash],
|
|
87
|
-
);
|
|
88
|
-
|
|
89
|
-
useEffect(() => {
|
|
90
|
-
const currentHash = window.location.hash;
|
|
91
|
-
const parsed = parseHash(currentHash);
|
|
92
|
-
const expectedHash = `#${buildHash(parsed.section, parsed.range, parsed.session)}`;
|
|
93
|
-
if (currentHash !== expectedHash) {
|
|
94
|
-
window.location.hash = buildHash(parsed.section, parsed.range, parsed.session);
|
|
95
|
-
}
|
|
96
|
-
}, []);
|
|
97
|
-
|
|
98
|
-
return {
|
|
99
|
-
section: route.section,
|
|
100
|
-
setSection,
|
|
101
|
-
range: route.range,
|
|
102
|
-
setRange,
|
|
103
|
-
session: route.session,
|
|
104
|
-
setSession,
|
|
105
|
-
};
|
|
63
|
+
return { ...route, setSection, setRange, setSession };
|
|
106
64
|
}
|