@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
|
@@ -13,11 +13,14 @@ export declare function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T
|
|
|
13
13
|
* `current` is the number of files completed (skipped + parsed),
|
|
14
14
|
* `total` is the size of the work set. `processed` is the running total
|
|
15
15
|
* of inserted rows. Parsed files are reported only after their batch commits.
|
|
16
|
+
* `changes` is the running total of stored rows the sync inserted, updated or
|
|
17
|
+
* deleted (0 while every transcript is unchanged).
|
|
16
18
|
*/
|
|
17
19
|
export interface SyncProgress {
|
|
18
20
|
current: number;
|
|
19
21
|
total: number;
|
|
20
22
|
processed: number;
|
|
23
|
+
changes: number;
|
|
21
24
|
sessionFile: string;
|
|
22
25
|
}
|
|
23
26
|
export interface SyncOptions {
|
|
@@ -25,7 +25,11 @@ export declare function getProviderDashboardStats(range?: TimeRange, signal?: Ab
|
|
|
25
25
|
/** Subscription-window insights (all providers) plus utilization series for `provider` only. */
|
|
26
26
|
export declare function getProviderWindowStats(range: TimeRange, provider: string | null, signal?: AbortSignal): Promise<ProviderWindowStats>;
|
|
27
27
|
export declare function getSessions(limit?: number, q?: string, signal?: AbortSignal): Promise<SessionSummary[]>;
|
|
28
|
-
|
|
28
|
+
/**
|
|
29
|
+
* Pass the last trace for `file` as `previous` to revalidate it: an unchanged
|
|
30
|
+
* trace resolves to that same object without downloading or parsing the body.
|
|
31
|
+
*/
|
|
32
|
+
export declare function getSessionTrace(file: string, signal?: AbortSignal, previous?: SessionTrace): Promise<SessionTrace>;
|
|
29
33
|
/** Fetch one full journal entry for the span drawer. Entries are opaque JSON. */
|
|
30
34
|
export declare function getSessionEntryDetail(file: string, id: string, signal?: AbortSignal): Promise<{
|
|
31
35
|
entry: unknown;
|
|
@@ -3,18 +3,14 @@
|
|
|
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
|
import { type ReactNode } from "react";
|
|
11
11
|
import type { LiveSyncStatus } from "../types.js";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
* Client-side data version; bumps whenever the server's version changes
|
|
15
|
-
* (including after reconnecting to a restarted server).
|
|
16
|
-
*/
|
|
17
|
-
version: number;
|
|
12
|
+
/** Ingest status shown by the chrome. */
|
|
13
|
+
export interface LiveStatusView {
|
|
18
14
|
sync: LiveSyncStatus;
|
|
19
15
|
/** Hours whose rollups are still being built; wide ranges may be incomplete. */
|
|
20
16
|
indexingHours: number;
|
|
@@ -23,7 +19,16 @@ export interface LiveState {
|
|
|
23
19
|
/** Ask the server to ingest new session data now. */
|
|
24
20
|
requestSync: () => void;
|
|
25
21
|
}
|
|
22
|
+
/** Current data version, read outside render (e.g. when a fetch starts). */
|
|
23
|
+
export declare function currentLiveVersion(): number;
|
|
24
|
+
/** Owns the `/api/events` stream; mount once at the app root. */
|
|
26
25
|
export declare function LiveProvider({ children }: {
|
|
27
26
|
children: ReactNode;
|
|
28
|
-
}):
|
|
29
|
-
|
|
27
|
+
}): ReactNode;
|
|
28
|
+
/** Ingest status for the chrome; data-version bumps alone don't re-render. */
|
|
29
|
+
export declare function useLiveStatus(): LiveStatusView;
|
|
30
|
+
/**
|
|
31
|
+
* Data version for queries. Disabled (hidden) consumers read a constant -1 so
|
|
32
|
+
* live traffic never re-renders them.
|
|
33
|
+
*/
|
|
34
|
+
export declare function useLiveVersion(enabled?: boolean): number;
|
|
@@ -0,0 +1,39 @@
|
|
|
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
|
+
export interface CacheEntry {
|
|
7
|
+
data: unknown;
|
|
8
|
+
updatedAt: number;
|
|
9
|
+
version: number;
|
|
10
|
+
}
|
|
11
|
+
export interface Inflight {
|
|
12
|
+
promise: Promise<unknown>;
|
|
13
|
+
controller: AbortController;
|
|
14
|
+
/** Queries (and prefetches) waiting on this request. */
|
|
15
|
+
waiters: number;
|
|
16
|
+
}
|
|
17
|
+
/** Handed to every fetcher. */
|
|
18
|
+
export interface QueryFetchContext {
|
|
19
|
+
/** Aborted once no query waits for this request any more. */
|
|
20
|
+
signal: AbortSignal;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Loads one key's data. `previous` is the cached data for this key, for
|
|
24
|
+
* conditional revalidation; `undefined` on first load. It is a separate
|
|
25
|
+
* parameter so fetchers that ignore it still let `T` be inferred from their
|
|
26
|
+
* return type.
|
|
27
|
+
*/
|
|
28
|
+
export type QueryFetcher<T> = (context: QueryFetchContext, previous: T | undefined) => Promise<T>;
|
|
29
|
+
export declare function cachedEntry(key: string): CacheEntry | undefined;
|
|
30
|
+
/** The shared request currently running for `key`, if any. */
|
|
31
|
+
export declare function inflightRequest(key: string): Inflight | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* Fetch into the cache (tagged with live data `version`), sharing one request
|
|
34
|
+
* among concurrent callers of the same key. The caller holds one waiter on
|
|
35
|
+
* the returned request; pair with {@link releaseQuery}.
|
|
36
|
+
*/
|
|
37
|
+
export declare function loadQuery<T>(key: string, version: number, fetcher: QueryFetcher<T>): Inflight;
|
|
38
|
+
/** Drop one waiter; the last one out aborts the request. */
|
|
39
|
+
export declare function releaseQuery(key: string, request: Inflight): void;
|
|
@@ -2,14 +2,19 @@
|
|
|
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
|
*/
|
|
17
|
+
import { type QueryFetcher } from "./query-store.js";
|
|
13
18
|
export interface QueryOptions {
|
|
14
19
|
/** Only enabled queries fetch; hidden (kept-alive) pages pass `false`. Default true. */
|
|
15
20
|
enabled?: boolean;
|
|
@@ -32,4 +37,4 @@ export interface QueryResult<T> {
|
|
|
32
37
|
}
|
|
33
38
|
/** Warm the cache for a key the user is likely to open next. No-op if cached for this version. */
|
|
34
39
|
export declare function prefetchQuery<T>(key: readonly unknown[], fetcher: () => Promise<T>, version: number): void;
|
|
35
|
-
export declare function useQuery<T>(key: readonly unknown[], fetcher:
|
|
40
|
+
export declare function useQuery<T>(key: readonly unknown[], fetcher: QueryFetcher<T>, options?: QueryOptions): QueryResult<T>;
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Timeline overview strip: per-category density over the full virtual domain
|
|
3
3
|
* plus a draggable/resizable viewport brush, mirroring DevTools' overview.
|
|
4
|
+
* The density layer is painted once per trace/scale/size/theme into an
|
|
5
|
+
* offscreen canvas; pan and zoom only blit it and redraw the brush.
|
|
4
6
|
*/
|
|
5
7
|
import type { TraceTrack } from "../types.js";
|
|
6
8
|
import type { TimelineViewport } from "./TimelineCanvas.js";
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The trace flamegraph:
|
|
3
|
-
* labels, collapse chevrons) and a
|
|
4
|
-
*
|
|
5
|
-
* fit/focus keys, hover tooltips,
|
|
2
|
+
* The trace flamegraph: a DPR-scaled scene canvas with DOM hover/selection
|
|
3
|
+
* boxes on top, a DOM gutter (track/lane labels, collapse chevrons) and a
|
|
4
|
+
* DOM tooltip. Implements the Chrome DevTools interaction contract:
|
|
5
|
+
* cursor-anchored wheel zoom, drag pan, WASD, fit/focus keys, hover tooltips,
|
|
6
|
+
* click-to-select, double-click-to-zoom.
|
|
6
7
|
*/
|
|
7
|
-
import type { TraceTrack } from "../types.js";
|
|
8
|
+
import type { TraceSpan, TraceTrack } from "../types.js";
|
|
8
9
|
import { type TraceScale } from "./time-scale.js";
|
|
9
10
|
export interface TimelineViewport {
|
|
10
11
|
u0: number;
|
|
@@ -15,11 +16,31 @@ export interface TimelineCanvasProps {
|
|
|
15
16
|
scale: TraceScale;
|
|
16
17
|
viewport: TimelineViewport;
|
|
17
18
|
onViewportChange: (viewport: TimelineViewport) => void;
|
|
18
|
-
|
|
19
|
+
/** The selected span with its track (resolved by the trace view). */
|
|
20
|
+
selected: {
|
|
21
|
+
span: TraceSpan;
|
|
22
|
+
track: TraceTrack;
|
|
23
|
+
} | null;
|
|
19
24
|
onSelect: (spanId: string | null) => void;
|
|
20
|
-
search
|
|
25
|
+
/** Spans matching the search; `null` when not searching (everything at full opacity). */
|
|
26
|
+
matchIds: ReadonlySet<string> | null;
|
|
21
27
|
collapsed: ReadonlySet<string>;
|
|
22
28
|
onToggleCollapse: (trackId: string) => void;
|
|
23
29
|
traceStart: number;
|
|
24
30
|
}
|
|
25
|
-
|
|
31
|
+
/** Per-lane span positions on the virtual axis, parallel to `LaneRow.spans`. */
|
|
32
|
+
interface LaneAxis {
|
|
33
|
+
startU: Float64Array;
|
|
34
|
+
endU: Float64Array;
|
|
35
|
+
/** Running max of `endU`; monotonic, so the first visible span is a binary search away. */
|
|
36
|
+
maxEndU: Float64Array;
|
|
37
|
+
}
|
|
38
|
+
export declare function buildLaneAxes(layout: {
|
|
39
|
+
lanes: ReadonlyArray<{
|
|
40
|
+
spans: ReadonlyArray<Pick<TraceSpan, "start" | "end">>;
|
|
41
|
+
}>;
|
|
42
|
+
}, scale: Pick<TraceScale, "toU">): LaneAxis[];
|
|
43
|
+
/** First index whose running-max end reaches `u0`: every earlier span ends before the viewport. */
|
|
44
|
+
export declare function firstVisible(maxEndU: Float64Array, u0: number): number;
|
|
45
|
+
export declare function TimelineCanvas({ tracks, scale, viewport, onViewportChange, selected, onSelect, matchIds, collapsed, onToggleCollapse, traceStart, }: TimelineCanvasProps): import("react").JSX.Element;
|
|
46
|
+
export {};
|
|
@@ -17,8 +17,10 @@ export interface TranscriptListProps {
|
|
|
17
17
|
selection: string | null;
|
|
18
18
|
onSelect: (spanId: string) => void;
|
|
19
19
|
search: string;
|
|
20
|
+
/** Spans matching `search` (from the trace view); `null` when not searching. */
|
|
21
|
+
matchIds: ReadonlySet<string> | null;
|
|
20
22
|
traceStart: number;
|
|
21
23
|
}
|
|
22
24
|
/** Flatten all spans and markers across tracks into chronological rows. */
|
|
23
25
|
export declare function buildTranscriptRows(tracks: TraceTrack[]): TranscriptRow[];
|
|
24
|
-
export declare
|
|
26
|
+
export declare const TranscriptList: import("react").MemoExoticComponent<({ tracks, selection, onSelect, search, matchIds, traceStart, }: TranscriptListProps) => import("react").JSX.Element>;
|
package/dist/types/db.d.ts
CHANGED
|
@@ -49,12 +49,23 @@ export interface ParsedSession {
|
|
|
49
49
|
/** Recover missing rows from a full-history scan without rewriting unchanged records. */
|
|
50
50
|
replay: boolean;
|
|
51
51
|
}
|
|
52
|
-
/**
|
|
52
|
+
/**
|
|
53
|
+
* Commit distinct transcripts' rows, reconciliation state, and cursors together; failure rolls back the batch.
|
|
54
|
+
* `changes` counts stored rows inserted, updated or deleted (cursor bookkeeping excluded), so callers can
|
|
55
|
+
* tell a batch that changed nothing.
|
|
56
|
+
*/
|
|
53
57
|
export declare function applySessionParseResults(sessions: ParsedSession[]): {
|
|
54
58
|
processed: number;
|
|
55
59
|
files: number;
|
|
56
60
|
reconcile: boolean;
|
|
61
|
+
changes: number;
|
|
57
62
|
};
|
|
63
|
+
/**
|
|
64
|
+
* SQLite `PRAGMA data_version` of the open connection, or null before
|
|
65
|
+
* {@link initDb}. It moves only when another connection (another omp process)
|
|
66
|
+
* commits, so a caller can detect foreign writes without scanning.
|
|
67
|
+
*/
|
|
68
|
+
export declare function getDataVersion(): number | null;
|
|
58
69
|
export declare function prepareSessionSync(): boolean;
|
|
59
70
|
export declare function completeSessionSync(reconcile: boolean): void;
|
|
60
71
|
/**
|
|
@@ -90,7 +101,8 @@ export declare function getMessageById(id: number): MessageStats | null;
|
|
|
90
101
|
* Self-initializing (opens the stats DB on first use) so the coding-agent TUI
|
|
91
102
|
* can query without the dashboard server's init flow. Days use the machine's
|
|
92
103
|
* timezone — this is a localhost tool, same rationale as
|
|
93
|
-
* {@link getProviderHourlyBurn}.
|
|
104
|
+
* {@link getProviderHourlyBurn}. Reads the hourly rollup, falling back to the
|
|
105
|
+
* raw rows while its rebuild backlog is too large for exact reads.
|
|
94
106
|
*/
|
|
95
107
|
export declare function getDailyActivity(days?: number): Promise<DailyActivityPoint[]>;
|
|
96
108
|
/**
|
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
* prepacked npm bundle, and reset to an empty file afterwards so the dev tree
|
|
8
8
|
* keeps building the dashboard from source.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* Cheap check whether the generated text carries a gzip archive: sniffs the
|
|
12
|
+
* base64 of the gzip magic (`1f 8b 08`) without decoding megabytes.
|
|
13
|
+
*/
|
|
14
|
+
export declare function hasEmbeddedClientArchive(txt: string): boolean;
|
|
10
15
|
/**
|
|
11
16
|
* Decode the generated archive text.
|
|
12
17
|
*
|
package/dist/types/live.d.ts
CHANGED
|
@@ -5,11 +5,13 @@
|
|
|
5
5
|
* The dashboard never waits for ingest. {@link StatsLive.start} kicks a full
|
|
6
6
|
* sync in the background and watches the sessions directory; changed
|
|
7
7
|
* transcripts are re-synced (only those files) shortly after they are written.
|
|
8
|
-
* Every
|
|
9
|
-
* pages refetch and fill in while parsing is still
|
|
8
|
+
* Every batch that changes stored rows bumps {@link LiveStatus.version}
|
|
9
|
+
* (throttled), so open pages refetch and fill in while parsing is still
|
|
10
|
+
* running; syncs that change nothing leave the version alone.
|
|
10
11
|
*/
|
|
11
12
|
import type { LiveStatus } from "./shared-types.js";
|
|
12
|
-
|
|
13
|
+
/** Receives each distinct status and its JSON serialization (shared by every listener). */
|
|
14
|
+
type Listener = (status: LiveStatus, frame: string) => void;
|
|
13
15
|
/** One per process; owned by the dashboard server (see `startServer`). */
|
|
14
16
|
export declare class StatsLive {
|
|
15
17
|
#private;
|
package/dist/types/rollup.d.ts
CHANGED
|
@@ -25,7 +25,7 @@
|
|
|
25
25
|
* (an hour of data is small and indexed by timestamp).
|
|
26
26
|
*/
|
|
27
27
|
import type { Database } from "bun:sqlite";
|
|
28
|
-
import type { AgentTypeStats, AggregatedStats, CostTimeSeriesPoint, FolderStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, ProviderAggregate, ProviderHourlyPoint, ProviderTimeSeriesPoint, TimeSeriesPoint, ToolModelStats, ToolTimeSeriesPoint, ToolUsageStats } from "./types.js";
|
|
28
|
+
import type { AgentTypeStats, AggregatedStats, CostTimeSeriesPoint, DailyActivityPoint, FolderStats, ModelPerformancePoint, ModelStats, ModelTimeSeriesPoint, ProviderAggregate, ProviderHourlyPoint, ProviderTimeSeriesPoint, TimeSeriesPoint, ToolModelStats, ToolTimeSeriesPoint, ToolUsageStats } from "./types.js";
|
|
29
29
|
/**
|
|
30
30
|
* Create (or rebuild after a version bump) the rollup tables and the triggers
|
|
31
31
|
* that mark hours dirty. Called from `initDb` after the raw tables exist.
|
|
@@ -87,6 +87,14 @@ export declare function getStatsByProvider(cutoff?: number | null): ProviderAggr
|
|
|
87
87
|
* up to 30 minutes.
|
|
88
88
|
*/
|
|
89
89
|
export declare function getProviderHourlyBurn(cutoff?: number | null): ProviderHourlyPoint[];
|
|
90
|
+
/**
|
|
91
|
+
* Per-local-day request/token/cost totals since `cutoff`, oldest first, read
|
|
92
|
+
* from the hourly rollup. Returns null when the local UTC offset is not a whole
|
|
93
|
+
* hour (now or at `cutoff`), since hourly buckets then straddle local midnight,
|
|
94
|
+
* or while the dirty backlog is too large for exact reads; callers then
|
|
95
|
+
* aggregate the raw rows instead.
|
|
96
|
+
*/
|
|
97
|
+
export declare function getDailyActivityFromRollup(cutoff: number): DailyActivityPoint[] | null;
|
|
90
98
|
/** Tokens/cost per bucket per provider. */
|
|
91
99
|
export declare function getProviderTimeSeries({ cutoff, bucketMs }: RangeWindow): ProviderTimeSeriesPoint[];
|
|
92
100
|
/** Per-transcript-file rollup for the Traces session list. */
|
package/dist/types/trace.d.ts
CHANGED
|
@@ -18,18 +18,30 @@ export declare class TracePathError extends Error {
|
|
|
18
18
|
* against an unchanged transcript mtime.
|
|
19
19
|
*/
|
|
20
20
|
export declare const TRACE_ETAG_VERSION = 3;
|
|
21
|
+
/** Freshness of a root trace: root mtime plus the child-transcript set fingerprint. */
|
|
22
|
+
export interface TraceFingerprint {
|
|
23
|
+
rootMtimeMs: number;
|
|
24
|
+
childFingerprint: string;
|
|
25
|
+
}
|
|
21
26
|
/**
|
|
22
|
-
* Freshness fingerprint for the trace ETag pre-check
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
27
|
+
* Freshness fingerprint for the trace ETag pre-check; `SessionTrace.etag` is
|
|
28
|
+
* `${rootMtimeMs}:${childFingerprint}` for an unraced build. Returns undefined
|
|
29
|
+
* when the path is rejected or missing (the caller falls through to the full
|
|
30
|
+
* build, which maps those to 400/404).
|
|
26
31
|
*/
|
|
27
|
-
export declare function traceFingerprintForEtag(fileParam: string): Promise<
|
|
32
|
+
export declare function traceFingerprintForEtag(fileParam: string): Promise<TraceFingerprint | undefined>;
|
|
28
33
|
export declare function traceMemoForTests(): {
|
|
29
34
|
file: string;
|
|
30
35
|
mtimeMs: number;
|
|
31
36
|
} | undefined;
|
|
32
|
-
|
|
37
|
+
/**
|
|
38
|
+
* Assemble the full span tree for one root session transcript.
|
|
39
|
+
* Throws {@link TracePathError} for paths outside the sessions root; ENOENT
|
|
40
|
+
* passes through for the caller's 404 mapping. `fingerprint` may carry the
|
|
41
|
+
* {@link traceFingerprintForEtag} value the caller just computed for the same
|
|
42
|
+
* file (its ETag pre-check), sparing a second walk.
|
|
43
|
+
*/
|
|
44
|
+
export declare function buildSessionTrace(fileParam: string, fingerprint?: TraceFingerprint): Promise<SessionTrace>;
|
|
33
45
|
/** Fetch one full journal entry by id from a transcript, for the span drawer. */
|
|
34
46
|
export declare function getTraceEntry(fileParam: string, entryId: string): Promise<SessionEntry | null>;
|
|
35
47
|
/**
|
package/dist/types/types.d.ts
CHANGED
|
@@ -33,6 +33,14 @@ export interface MessageStats {
|
|
|
33
33
|
usage: Usage;
|
|
34
34
|
/** Which agent produced this message (main agent, task subagent, advisor) */
|
|
35
35
|
agentType: AgentType;
|
|
36
|
+
/**
|
|
37
|
+
* Service tier the provider reported serving this turn, or `null` when the
|
|
38
|
+
* provider reported none. Lets consumers separate a fast serving path's
|
|
39
|
+
* throughput and premium traffic from standard-tier traffic. Always written by
|
|
40
|
+
* `rowToMessageStats`; optional only because session fixtures that stand in
|
|
41
|
+
* for ingest input are typed as `MessageStats` too.
|
|
42
|
+
*/
|
|
43
|
+
serviceTier?: ServiceTier | null;
|
|
36
44
|
/**
|
|
37
45
|
* Ingest refused to price this request: a scheduled (time-based) card with no
|
|
38
46
|
* recoverable request timestamp, so `usage.cost.total` of 0 is unknown spend
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/omp-stats",
|
|
4
|
-
"version": "18.
|
|
4
|
+
"version": "18.8.0",
|
|
5
5
|
"description": "Local observability dashboard for omp AI usage statistics",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -42,9 +42,9 @@
|
|
|
42
42
|
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
43
43
|
},
|
|
44
44
|
"dependencies": {
|
|
45
|
-
"@oh-my-pi/pi-ai": "18.
|
|
46
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
47
|
-
"@oh-my-pi/pi-utils": "18.
|
|
45
|
+
"@oh-my-pi/pi-ai": "18.8.0",
|
|
46
|
+
"@oh-my-pi/pi-catalog": "18.8.0",
|
|
47
|
+
"@oh-my-pi/pi-utils": "18.8.0",
|
|
48
48
|
"lucide-react": "^1.24.0",
|
|
49
49
|
"react": "19.2.7",
|
|
50
50
|
"react-dom": "19.2.7"
|
package/src/aggregator.ts
CHANGED
|
@@ -85,11 +85,14 @@ export async function withStatsSyncLock<T>(dbPath: string, fn: () => Promise<T>)
|
|
|
85
85
|
* `current` is the number of files completed (skipped + parsed),
|
|
86
86
|
* `total` is the size of the work set. `processed` is the running total
|
|
87
87
|
* of inserted rows. Parsed files are reported only after their batch commits.
|
|
88
|
+
* `changes` is the running total of stored rows the sync inserted, updated or
|
|
89
|
+
* deleted (0 while every transcript is unchanged).
|
|
88
90
|
*/
|
|
89
91
|
export interface SyncProgress {
|
|
90
92
|
current: number;
|
|
91
93
|
total: number;
|
|
92
94
|
processed: number;
|
|
95
|
+
changes: number;
|
|
93
96
|
sessionFile: string;
|
|
94
97
|
}
|
|
95
98
|
|
|
@@ -297,6 +300,7 @@ async function syncAllSessionsLocked(
|
|
|
297
300
|
|
|
298
301
|
const files = opts?.files ?? (await orderForIngest(await listAllSessionFiles()));
|
|
299
302
|
let totalProcessed = 0;
|
|
303
|
+
let totalChanges = 0;
|
|
300
304
|
let filesProcessed = 0;
|
|
301
305
|
let completed = 0;
|
|
302
306
|
let cursor = 0;
|
|
@@ -314,6 +318,7 @@ async function syncAllSessionsLocked(
|
|
|
314
318
|
current: completed,
|
|
315
319
|
total: files.length,
|
|
316
320
|
processed: totalProcessed,
|
|
321
|
+
changes: totalChanges,
|
|
317
322
|
sessionFile,
|
|
318
323
|
});
|
|
319
324
|
};
|
|
@@ -325,6 +330,7 @@ async function syncAllSessionsLocked(
|
|
|
325
330
|
pending = [];
|
|
326
331
|
pendingRows = 0;
|
|
327
332
|
totalProcessed += applied.processed;
|
|
333
|
+
totalChanges += applied.changes;
|
|
328
334
|
filesProcessed += applied.files;
|
|
329
335
|
reconcile ||= applied.reconcile;
|
|
330
336
|
// Report only durable progress: callbacks may interrupt the sync.
|
|
@@ -358,9 +364,9 @@ async function syncAllSessionsLocked(
|
|
|
358
364
|
metadataEnd = index + batch.length;
|
|
359
365
|
metadata = Promise.all(
|
|
360
366
|
batch.map(async sessionFile => {
|
|
367
|
+
const stored = offsets.get(sessionFile);
|
|
361
368
|
try {
|
|
362
|
-
|
|
363
|
-
return { fileStats, stored: offsets.get(sessionFile) };
|
|
369
|
+
return { fileStats: await fs.promises.stat(sessionFile), stored };
|
|
364
370
|
} catch {
|
|
365
371
|
return {};
|
|
366
372
|
}
|
|
@@ -415,7 +421,13 @@ async function syncAllSessionsLocked(
|
|
|
415
421
|
if (pending.length >= SYNC_BATCH_FILES || pendingRows >= SYNC_BATCH_ROWS) flush();
|
|
416
422
|
};
|
|
417
423
|
|
|
418
|
-
|
|
424
|
+
let requestedWorkers = Math.max(1, Math.floor(opts?.workers ?? defaultWorkerCount()));
|
|
425
|
+
// A few already-tracked transcripts (the watcher's usual work) need only
|
|
426
|
+
// kilobyte tail reads: parsing them inline beats starting a worker.
|
|
427
|
+
if (requestedWorkers > 1 && opts?.workers === undefined && files.length <= SYNC_INLINE_READS) {
|
|
428
|
+
const offsets = getFileOffsets([...files]);
|
|
429
|
+
if (files.every(file => offsets.get(file)?.parserState)) requestedWorkers = 1;
|
|
430
|
+
}
|
|
419
431
|
if (requestedWorkers === 1) {
|
|
420
432
|
for (let start = 0; start < files.length; start += SYNC_INLINE_READS) {
|
|
421
433
|
const results = await Promise.allSettled(
|
|
@@ -437,14 +449,20 @@ async function syncAllSessionsLocked(
|
|
|
437
449
|
|
|
438
450
|
const handles: WorkerHandle[] = [];
|
|
439
451
|
|
|
440
|
-
|
|
452
|
+
// Each lane spawns its worker on its first real parse, so unchanged transcripts cost no worker.
|
|
453
|
+
async function drain(): Promise<void> {
|
|
454
|
+
let handle: WorkerHandle | null = null;
|
|
441
455
|
try {
|
|
442
456
|
while (!failed) {
|
|
443
457
|
const idx = cursor++;
|
|
444
458
|
if (idx >= files.length) return;
|
|
445
|
-
const parsed = await prepareFile(idx, (file, fromOffset, parserState, replay) =>
|
|
446
|
-
|
|
447
|
-
|
|
459
|
+
const parsed = await prepareFile(idx, (file, fromOffset, parserState, replay) => {
|
|
460
|
+
if (!handle) {
|
|
461
|
+
handle = spawnWorker();
|
|
462
|
+
handles.push(handle);
|
|
463
|
+
}
|
|
464
|
+
return dispatch(handle, { sessionFile: file, fromOffset, parserState, replay });
|
|
465
|
+
});
|
|
448
466
|
if (!failed) acceptFile(files[idx], parsed);
|
|
449
467
|
}
|
|
450
468
|
} catch (error) {
|
|
@@ -454,9 +472,8 @@ async function syncAllSessionsLocked(
|
|
|
454
472
|
}
|
|
455
473
|
|
|
456
474
|
try {
|
|
457
|
-
for (let i = 0; i < poolSize; i++) handles.push(spawnWorker());
|
|
458
475
|
// Drain in-flight work before releasing the sync lock, even after a failed batch.
|
|
459
|
-
const results = await Promise.allSettled(
|
|
476
|
+
const results = await Promise.allSettled(Array.from({ length: poolSize }, drain));
|
|
460
477
|
for (const result of results) {
|
|
461
478
|
if (result.status === "rejected") throw result.reason;
|
|
462
479
|
}
|
package/src/client/api.ts
CHANGED
|
@@ -45,12 +45,34 @@ async function readErrorMessage(res: Response, endpoint: string): Promise<string
|
|
|
45
45
|
return `HTTP error ${res.status} on ${endpoint}`;
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
49
|
-
|
|
48
|
+
/**
|
|
49
|
+
* Response validators keyed by the parsed body they describe. Revalidating a
|
|
50
|
+
* body sends its ETag; the entry dies with the body when callers drop it.
|
|
51
|
+
*/
|
|
52
|
+
const validators = new WeakMap<object, string>();
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Fetch `endpoint` as JSON, revalidating `previous` when it came from an
|
|
56
|
+
* ETag-bearing response: a 304 resolves to `previous` itself (same reference,
|
|
57
|
+
* no re-parse).
|
|
58
|
+
*/
|
|
59
|
+
async function fetchJson<T>(endpoint: string, options?: RequestInit, previous?: T): Promise<T> {
|
|
60
|
+
const etag = typeof previous === "object" && previous !== null ? validators.get(previous) : undefined;
|
|
61
|
+
let init = options;
|
|
62
|
+
if (etag) {
|
|
63
|
+
const headers = new Headers(options?.headers);
|
|
64
|
+
headers.set("If-None-Match", etag);
|
|
65
|
+
init = { ...options, headers };
|
|
66
|
+
}
|
|
67
|
+
const res = await fetch(endpoint, init);
|
|
68
|
+
if (res.status === 304 && etag) return previous as T;
|
|
50
69
|
if (!res.ok) {
|
|
51
70
|
throw new ApiError(res.status, endpoint, await readErrorMessage(res, endpoint));
|
|
52
71
|
}
|
|
53
|
-
|
|
72
|
+
const data = (await res.json()) as T;
|
|
73
|
+
const nextEtag = res.headers.get("ETag");
|
|
74
|
+
if (nextEtag && typeof data === "object" && data !== null) validators.set(data, nextEtag);
|
|
75
|
+
return data;
|
|
54
76
|
}
|
|
55
77
|
|
|
56
78
|
/**
|
|
@@ -182,8 +204,21 @@ export async function getSessions(limit = 100, q?: string, signal?: AbortSignal)
|
|
|
182
204
|
return fetchJson<SessionSummary[]>(`${API_BASE}/sessions?${params}`, { signal });
|
|
183
205
|
}
|
|
184
206
|
|
|
185
|
-
|
|
186
|
-
|
|
207
|
+
/**
|
|
208
|
+
* Pass the last trace for `file` as `previous` to revalidate it: an unchanged
|
|
209
|
+
* trace resolves to that same object without downloading or parsing the body.
|
|
210
|
+
*/
|
|
211
|
+
export async function getSessionTrace(
|
|
212
|
+
file: string,
|
|
213
|
+
signal?: AbortSignal,
|
|
214
|
+
previous?: SessionTrace,
|
|
215
|
+
): Promise<SessionTrace> {
|
|
216
|
+
// `no-store`: validation is ours (above); the browser cache would only keep a second multi-MB copy.
|
|
217
|
+
return fetchJson<SessionTrace>(
|
|
218
|
+
`${API_BASE}/session/trace?file=${encodeURIComponent(file)}`,
|
|
219
|
+
{ signal, cache: "no-store" },
|
|
220
|
+
previous,
|
|
221
|
+
);
|
|
187
222
|
}
|
|
188
223
|
|
|
189
224
|
/** Fetch one full journal entry for the span drawer. Entries are opaque JSON. */
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
import { RefreshCw } from "lucide-react";
|
|
2
2
|
import { type ReactNode, useEffect, useState } from "react";
|
|
3
3
|
import { formatInteger } from "../data/formatters";
|
|
4
|
-
import {
|
|
4
|
+
import { useLiveStatus } from "../data/live";
|
|
5
5
|
import { Dot } from "../ui";
|
|
6
6
|
|
|
7
7
|
/** Topbar ingest status; click to sync now. */
|
|
8
8
|
export function LiveChip() {
|
|
9
|
-
const { sync, indexingHours, connected, requestSync } =
|
|
9
|
+
const { sync, indexingHours, connected, requestSync } = useLiveStatus();
|
|
10
10
|
const now = useNow(sync.phase === "idle" ? 15_000 : null);
|
|
11
11
|
|
|
12
12
|
let body: ReactNode;
|
package/src/client/app/Shell.tsx
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Menu } from "lucide-react";
|
|
2
2
|
import { type ReactNode, useEffect, useState } from "react";
|
|
3
3
|
import { rangeMeta, TIME_RANGES } from "../data/range";
|
|
4
|
-
import {
|
|
4
|
+
import { useLiveStatus } from "../data/live";
|
|
5
5
|
import type { TimeRange } from "../types";
|
|
6
6
|
import { Segmented } from "../ui";
|
|
7
7
|
import { LiveChip } from "./LiveChip";
|
|
@@ -30,7 +30,7 @@ const RANGE_OPTIONS = TIME_RANGES.map((range, i) => ({
|
|
|
30
30
|
export function Shell({ section, onSectionChange, range, onRangeChange, children }: ShellProps) {
|
|
31
31
|
const [menuOpen, setMenuOpen] = useState(false);
|
|
32
32
|
const scrolled = useScrolled();
|
|
33
|
-
const { sync } =
|
|
33
|
+
const { sync } = useLiveStatus();
|
|
34
34
|
useShortcuts(onSectionChange, onRangeChange);
|
|
35
35
|
|
|
36
36
|
const go = (next: DashboardSection) => {
|