@oh-my-pi/omp-stats 18.0.10 → 18.1.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 +11 -0
- package/dist/client/index.css +1 -1
- package/dist/client/index.js +101 -101
- package/dist/client/styles.css +265 -0
- package/dist/types/client/api.d.ts +7 -1
- package/dist/types/client/app/routes.d.ts +1 -1
- package/dist/types/client/data/useHashRoute.d.ts +2 -0
- package/dist/types/client/routes/TracesRoute.d.ts +11 -0
- package/dist/types/client/routes/index.d.ts +1 -0
- package/dist/types/client/traces/AggregatesPanel.d.ts +9 -0
- package/dist/types/client/traces/Minimap.d.ts +14 -0
- package/dist/types/client/traces/SpanDrawer.d.ts +14 -0
- package/dist/types/client/traces/SummaryStrip.d.ts +9 -0
- package/dist/types/client/traces/TimelineCanvas.d.ts +25 -0
- package/dist/types/client/traces/TraceView.d.ts +11 -0
- package/dist/types/client/traces/TranscriptList.d.ts +24 -0
- package/dist/types/client/traces/time-scale.d.ts +40 -0
- package/dist/types/client/traces/trace-colors.d.ts +31 -0
- package/dist/types/db.d.ts +24 -1
- package/dist/types/index.d.ts +1 -1
- package/dist/types/parser.d.ts +8 -0
- package/dist/types/server.d.ts +3 -2
- package/dist/types/shared-types.d.ts +130 -0
- package/dist/types/trace.d.ts +33 -0
- package/dist/types/types.d.ts +35 -1
- package/package.json +8 -8
- package/src/client/App.tsx +11 -1
- package/src/client/api.ts +23 -0
- package/src/client/app/routes.ts +7 -0
- package/src/client/data/useHashRoute.ts +28 -10
- package/src/client/routes/ProvidersRoute.tsx +1 -0
- package/src/client/routes/TracesRoute.tsx +182 -0
- package/src/client/routes/index.ts +1 -0
- package/src/client/styles.css +316 -0
- package/src/client/traces/AggregatesPanel.tsx +88 -0
- package/src/client/traces/Minimap.tsx +177 -0
- package/src/client/traces/SpanDrawer.tsx +309 -0
- package/src/client/traces/SummaryStrip.tsx +37 -0
- package/src/client/traces/TimelineCanvas.tsx +768 -0
- package/src/client/traces/TraceView.tsx +344 -0
- package/src/client/traces/TranscriptList.tsx +166 -0
- package/src/client/traces/time-scale.ts +250 -0
- package/src/client/traces/trace-colors.ts +77 -0
- package/src/db.ts +77 -0
- package/src/index.ts +1 -6
- package/src/parser.ts +55 -2
- package/src/server.ts +69 -17
- package/src/shared-types.ts +138 -0
- package/src/trace.ts +1002 -0
- package/src/types.ts +45 -1
|
@@ -127,6 +127,20 @@ export interface CostTimeSeriesPoint {
|
|
|
127
127
|
/** Request count */
|
|
128
128
|
requests: number;
|
|
129
129
|
}
|
|
130
|
+
/**
|
|
131
|
+
* One local calendar day of aggregate request activity, for the `/usage`
|
|
132
|
+
* activity heatmap in the coding-agent TUI.
|
|
133
|
+
*/
|
|
134
|
+
export interface DailyActivityPoint {
|
|
135
|
+
/** Local calendar date, `YYYY-MM-DD`. */
|
|
136
|
+
day: string;
|
|
137
|
+
/** Summed API-equivalent cost for the day. */
|
|
138
|
+
cost: number;
|
|
139
|
+
/** Request count for the day. */
|
|
140
|
+
requests: number;
|
|
141
|
+
/** Total tokens (input + output + cache) for the day. */
|
|
142
|
+
totalTokens: number;
|
|
143
|
+
}
|
|
130
144
|
/**
|
|
131
145
|
* Overall dashboard stats.
|
|
132
146
|
*/
|
|
@@ -419,3 +433,119 @@ export interface ProviderDashboardStats {
|
|
|
419
433
|
usageSeries: UsageWindowSeries[];
|
|
420
434
|
windowInsights: ProviderWindowInsight[];
|
|
421
435
|
}
|
|
436
|
+
/**
|
|
437
|
+
* One row of the Traces session list: a root session with every child
|
|
438
|
+
* transcript (task subagents, advisors) folded in.
|
|
439
|
+
*/
|
|
440
|
+
export interface SessionSummary {
|
|
441
|
+
/** Absolute root session file path (trace key). */
|
|
442
|
+
file: string;
|
|
443
|
+
/** Decoded project path (e.g. `/work/pi`). */
|
|
444
|
+
folder: string;
|
|
445
|
+
title: string | null;
|
|
446
|
+
/** ms epoch of first activity. */
|
|
447
|
+
startedAt: number;
|
|
448
|
+
/** ms epoch of last activity, children included. */
|
|
449
|
+
endedAt: number;
|
|
450
|
+
/** Assistant messages, children folded in. */
|
|
451
|
+
requests: number;
|
|
452
|
+
toolCalls: number;
|
|
453
|
+
/** Child transcript count. */
|
|
454
|
+
subagents: number;
|
|
455
|
+
totalTokens: number;
|
|
456
|
+
costTotal: number;
|
|
457
|
+
models: string[];
|
|
458
|
+
}
|
|
459
|
+
export type TraceSpanKind = "turn" | "model" | "tool" | "subagent" | "background";
|
|
460
|
+
/** One rendered block on a trace track lane. */
|
|
461
|
+
export interface TraceSpan {
|
|
462
|
+
/** `${track.id}:${entryId}` (+`:${toolCallId}` for tool spans); stable across refetch. */
|
|
463
|
+
id: string;
|
|
464
|
+
kind: TraceSpanKind;
|
|
465
|
+
/** ms epoch. */
|
|
466
|
+
start: number;
|
|
467
|
+
/** ms epoch, >= start. */
|
|
468
|
+
end: number;
|
|
469
|
+
/** ≤80 chars: tool name / model id / user-text head / agent name. */
|
|
470
|
+
label: string;
|
|
471
|
+
/** ≤160 chars: args projection / result head / task text. */
|
|
472
|
+
detail?: string;
|
|
473
|
+
/** Journal entry id for /api/session/entry. */
|
|
474
|
+
entryId?: string;
|
|
475
|
+
toolCallId?: string;
|
|
476
|
+
model?: string;
|
|
477
|
+
/** usage.totalTokens (model spans). */
|
|
478
|
+
tokens?: number;
|
|
479
|
+
/** usage.cost.total (model spans). */
|
|
480
|
+
cost?: number;
|
|
481
|
+
/** Time to first token, ms offset from start. */
|
|
482
|
+
ttft?: number;
|
|
483
|
+
isError?: boolean;
|
|
484
|
+
/** End synthesized (no result / pending at session exit). */
|
|
485
|
+
unterminated?: boolean;
|
|
486
|
+
/** Set on `subagent` spans whose child transcript became a track. */
|
|
487
|
+
childTrackId?: string;
|
|
488
|
+
}
|
|
489
|
+
/** Point event drawn on a track header row. */
|
|
490
|
+
export interface TraceMarker {
|
|
491
|
+
/** ms epoch. */
|
|
492
|
+
time: number;
|
|
493
|
+
kind: "compaction" | "model_change" | "mode_change" | "reset" | "session_exit";
|
|
494
|
+
/** e.g. "compaction 142k→38k", model id, mode name, exit kind. */
|
|
495
|
+
label: string;
|
|
496
|
+
}
|
|
497
|
+
/** One transcript (main session, subagent, advisor) in a trace. */
|
|
498
|
+
export interface TraceTrack {
|
|
499
|
+
/** "main" or slash-joined child key: "Scout1", "Scout1/Nested2", "__advisor". */
|
|
500
|
+
id: string;
|
|
501
|
+
parentId: string | null;
|
|
502
|
+
label: string;
|
|
503
|
+
/** session_init.agent when recorded. */
|
|
504
|
+
agent: string | null;
|
|
505
|
+
/** session_init.resolvedModel ?? first assistant model. */
|
|
506
|
+
model: string | null;
|
|
507
|
+
/** Absolute transcript path (for entry fetch). */
|
|
508
|
+
file: string;
|
|
509
|
+
/** Sorted by start. */
|
|
510
|
+
spans: TraceSpan[];
|
|
511
|
+
markers: TraceMarker[];
|
|
512
|
+
}
|
|
513
|
+
/** Per-tool duration aggregate across all tracks of one trace. */
|
|
514
|
+
export interface TraceToolStat {
|
|
515
|
+
tool: string;
|
|
516
|
+
calls: number;
|
|
517
|
+
errors: number;
|
|
518
|
+
totalMs: number;
|
|
519
|
+
maxMs: number;
|
|
520
|
+
}
|
|
521
|
+
/** Headline aggregates for one trace. */
|
|
522
|
+
export interface TraceSummary {
|
|
523
|
+
wallMs: number;
|
|
524
|
+
/** Summed model-span duration on the main track. */
|
|
525
|
+
modelMs: number;
|
|
526
|
+
/** Summed tool-span duration on the main track. */
|
|
527
|
+
toolMs: number;
|
|
528
|
+
/** Wall time not covered by any span on any track. */
|
|
529
|
+
idleMs: number;
|
|
530
|
+
turns: number;
|
|
531
|
+
requests: number;
|
|
532
|
+
toolCalls: number;
|
|
533
|
+
subagents: number;
|
|
534
|
+
totalTokens: number;
|
|
535
|
+
costTotal: number;
|
|
536
|
+
/** Sorted totalMs desc. */
|
|
537
|
+
toolStats: TraceToolStat[];
|
|
538
|
+
}
|
|
539
|
+
/** Complete span tree for one session, `/api/session/trace` payload. */
|
|
540
|
+
export interface SessionTrace {
|
|
541
|
+
file: string;
|
|
542
|
+
title: string | null;
|
|
543
|
+
cwd: string | null;
|
|
544
|
+
startedAt: number;
|
|
545
|
+
endedAt: number;
|
|
546
|
+
/** Root transcript mtime; doubles as the ETag. */
|
|
547
|
+
mtimeMs: number;
|
|
548
|
+
/** DFS order, main first. */
|
|
549
|
+
tracks: TraceTrack[];
|
|
550
|
+
summary: TraceSummary;
|
|
551
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session trace assembly for the Traces dashboard section.
|
|
3
|
+
*
|
|
4
|
+
* Builds a span tree (turns, model calls, tool calls, subagents, background
|
|
5
|
+
* jobs) directly from raw session JSONL — the only source with tool start/end
|
|
6
|
+
* times — while stats.db supplies the session-list rollups. Entry shapes are
|
|
7
|
+
* mirrored structurally from the coding-agent journal (`tool_execution_start`,
|
|
8
|
+
* `session_exit`, task results, async-result batches); stats never imports
|
|
9
|
+
* coding-agent.
|
|
10
|
+
*/
|
|
11
|
+
import type { SessionEntry, SessionSummary, SessionTrace } from "./types.js";
|
|
12
|
+
/** Client-supplied path escaped the sessions root or is not a transcript. Maps to HTTP 400. */
|
|
13
|
+
export declare class TracePathError extends Error {
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Trace payload schema/assembly revision, folded into the HTTP ETag. Bump when
|
|
17
|
+
* span assembly changes so browsers don't revalidate stale cached bodies
|
|
18
|
+
* against an unchanged transcript mtime.
|
|
19
|
+
*/
|
|
20
|
+
export declare const TRACE_ETAG_VERSION = 2;
|
|
21
|
+
/**
|
|
22
|
+
* Assemble the full span tree for one root session transcript.
|
|
23
|
+
* Throws {@link TracePathError} for paths outside the sessions root; ENOENT
|
|
24
|
+
* passes through for the caller's 404 mapping.
|
|
25
|
+
*/
|
|
26
|
+
export declare function buildSessionTrace(fileParam: string): Promise<SessionTrace>;
|
|
27
|
+
/** Fetch one full journal entry by id from a transcript, for the span drawer. */
|
|
28
|
+
export declare function getTraceEntry(fileParam: string, entryId: string): Promise<SessionEntry | null>;
|
|
29
|
+
/**
|
|
30
|
+
* List root sessions for the Traces section, folding every synced child
|
|
31
|
+
* transcript (subagents, advisors) into its root row.
|
|
32
|
+
*/
|
|
33
|
+
export declare function listSessionSummaries(limit?: number, q?: string): Promise<SessionSummary[]>;
|
package/dist/types/types.d.ts
CHANGED
|
@@ -70,7 +70,41 @@ export interface SessionServiceTierChangeEntry {
|
|
|
70
70
|
timestamp: string;
|
|
71
71
|
serviceTier: ServiceTierByFamily | ServiceTier | null;
|
|
72
72
|
}
|
|
73
|
-
export
|
|
73
|
+
export interface SessionModelUsageEntry {
|
|
74
|
+
type: "model_usage";
|
|
75
|
+
id: string;
|
|
76
|
+
parentId: string | null;
|
|
77
|
+
timestamp: string;
|
|
78
|
+
purpose: string;
|
|
79
|
+
role?: string;
|
|
80
|
+
api: string;
|
|
81
|
+
provider: string;
|
|
82
|
+
model: string;
|
|
83
|
+
usage: Usage;
|
|
84
|
+
stopReason?: StopReason;
|
|
85
|
+
errorMessage?: string;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Custom journal entry (`tool_execution_start`, `session_exit`, …). Mirrors
|
|
89
|
+
* the coding-agent shape structurally — stats never imports coding-agent.
|
|
90
|
+
*/
|
|
91
|
+
export interface SessionCustomEntry {
|
|
92
|
+
type: "custom";
|
|
93
|
+
id?: string;
|
|
94
|
+
parentId?: string | null;
|
|
95
|
+
timestamp?: string;
|
|
96
|
+
customType: string;
|
|
97
|
+
data?: Record<string, unknown>;
|
|
98
|
+
}
|
|
99
|
+
/** Structural variants the trace builder matches on beyond messages. */
|
|
100
|
+
export interface SessionTypedEntry {
|
|
101
|
+
type: "session_init" | "compaction" | "model_change" | "mode_change" | "reset_boundary";
|
|
102
|
+
id?: string;
|
|
103
|
+
parentId?: string | null;
|
|
104
|
+
timestamp?: string;
|
|
105
|
+
[key: string]: unknown;
|
|
106
|
+
}
|
|
107
|
+
export type SessionEntry = SessionHeader | SessionMessageEntry | SessionServiceTierChangeEntry | SessionModelUsageEntry | SessionCustomEntry | SessionTypedEntry | {
|
|
74
108
|
type: string;
|
|
75
109
|
};
|
|
76
110
|
/**
|
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.0
|
|
4
|
+
"version": "18.1.0",
|
|
5
5
|
"description": "Local observability dashboard for pi AI usage statistics",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": "Stencil Labs, Inc.",
|
|
@@ -32,16 +32,16 @@
|
|
|
32
32
|
"gen:stats:reset": "bun scripts/generate-client-bundle.ts --reset",
|
|
33
33
|
"build": "bun run build.ts",
|
|
34
34
|
"dev": "bun run src/index.ts",
|
|
35
|
-
"check": "
|
|
35
|
+
"check": "oxlint . && oxfmt --check --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts' && bun run check:types",
|
|
36
36
|
"check:types": "tsgo -p tsconfig.json --noEmit && tsgo -p tsconfig.client.json --noEmit",
|
|
37
|
-
"lint": "
|
|
38
|
-
"fix": "
|
|
39
|
-
"fmt": "
|
|
37
|
+
"lint": "oxlint .",
|
|
38
|
+
"fix": "oxlint --fix --fix-suggestions . && bun run fmt",
|
|
39
|
+
"fmt": "oxfmt --no-error-on-unmatched-pattern 'src/**/*.{ts,tsx}' '{test,bench,examples,scripts}/**/*.ts' '*.ts'"
|
|
40
40
|
},
|
|
41
41
|
"dependencies": {
|
|
42
|
-
"@oh-my-pi/pi-ai": "18.0
|
|
43
|
-
"@oh-my-pi/pi-catalog": "18.0
|
|
44
|
-
"@oh-my-pi/pi-utils": "18.0
|
|
42
|
+
"@oh-my-pi/pi-ai": "18.1.0",
|
|
43
|
+
"@oh-my-pi/pi-catalog": "18.1.0",
|
|
44
|
+
"@oh-my-pi/pi-utils": "18.1.0",
|
|
45
45
|
"@tailwindcss/node": "^4.3.2",
|
|
46
46
|
"chart.js": "^4.5.1",
|
|
47
47
|
"lucide-react": "^1.24.0",
|
package/src/client/App.tsx
CHANGED
|
@@ -13,11 +13,12 @@ import {
|
|
|
13
13
|
ProvidersRoute,
|
|
14
14
|
RequestsRoute,
|
|
15
15
|
ToolsRoute,
|
|
16
|
+
TracesRoute,
|
|
16
17
|
} from "./routes";
|
|
17
18
|
import { RequestDrawer } from "./ui/RequestDrawer";
|
|
18
19
|
|
|
19
20
|
export default function App() {
|
|
20
|
-
const { section, setSection, range, setRange } = useHashRoute();
|
|
21
|
+
const { section, setSection, range, setRange, session, setSession } = useHashRoute();
|
|
21
22
|
const [refreshTrigger, setRefreshTrigger] = useState(0);
|
|
22
23
|
const [selectedRequestId, setSelectedRequestId] = useState<number | null>(null);
|
|
23
24
|
const [updatedAt, setUpdatedAt] = useState<number | null>(() => Date.now());
|
|
@@ -63,6 +64,15 @@ export default function App() {
|
|
|
63
64
|
onRequestClick={setSelectedRequestId}
|
|
64
65
|
/>
|
|
65
66
|
);
|
|
67
|
+
case "traces":
|
|
68
|
+
return (
|
|
69
|
+
<TracesRoute
|
|
70
|
+
active={isActive}
|
|
71
|
+
session={session}
|
|
72
|
+
onOpenSession={setSession}
|
|
73
|
+
refreshTrigger={refreshTrigger}
|
|
74
|
+
/>
|
|
75
|
+
);
|
|
66
76
|
case "errors":
|
|
67
77
|
return (
|
|
68
78
|
<ErrorsRoute
|
package/src/client/api.ts
CHANGED
|
@@ -8,6 +8,8 @@ import type {
|
|
|
8
8
|
OverviewStats,
|
|
9
9
|
ProviderDashboardStats,
|
|
10
10
|
RequestDetails,
|
|
11
|
+
SessionSummary,
|
|
12
|
+
SessionTrace,
|
|
11
13
|
TimeRange,
|
|
12
14
|
ToolDashboardStats,
|
|
13
15
|
} from "./types";
|
|
@@ -116,3 +118,24 @@ export async function getProviderDashboardStats(
|
|
|
116
118
|
signal,
|
|
117
119
|
});
|
|
118
120
|
}
|
|
121
|
+
export async function getSessions(limit = 100, q?: string, signal?: AbortSignal): Promise<SessionSummary[]> {
|
|
122
|
+
const params = new URLSearchParams({ limit: String(limit) });
|
|
123
|
+
if (q) params.set("q", q);
|
|
124
|
+
return fetchJson<SessionSummary[]>(`${API_BASE}/sessions?${params}`, { signal });
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export async function getSessionTrace(file: string, signal?: AbortSignal): Promise<SessionTrace> {
|
|
128
|
+
return fetchJson<SessionTrace>(`${API_BASE}/session/trace?file=${encodeURIComponent(file)}`, { signal });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Fetch one full journal entry for the span drawer. Entries are opaque JSON. */
|
|
132
|
+
export async function getSessionEntryDetail(
|
|
133
|
+
file: string,
|
|
134
|
+
id: string,
|
|
135
|
+
signal?: AbortSignal,
|
|
136
|
+
): Promise<{ entry: unknown }> {
|
|
137
|
+
return fetchJson<{ entry: unknown }>(
|
|
138
|
+
`${API_BASE}/session/entry?file=${encodeURIComponent(file)}&id=${encodeURIComponent(id)}`,
|
|
139
|
+
{ signal },
|
|
140
|
+
);
|
|
141
|
+
}
|
package/src/client/app/routes.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
Cpu,
|
|
6
6
|
Folder,
|
|
7
7
|
LayoutDashboard,
|
|
8
|
+
ListTree,
|
|
8
9
|
Plug,
|
|
9
10
|
Smile,
|
|
10
11
|
TrendingUp,
|
|
@@ -15,6 +16,7 @@ import type React from "react";
|
|
|
15
16
|
export type DashboardSection =
|
|
16
17
|
| "overview"
|
|
17
18
|
| "requests"
|
|
19
|
+
| "traces"
|
|
18
20
|
| "errors"
|
|
19
21
|
| "models"
|
|
20
22
|
| "providers"
|
|
@@ -42,6 +44,11 @@ export const routes: DashboardRoute[] = [
|
|
|
42
44
|
label: "Requests",
|
|
43
45
|
icon: Activity,
|
|
44
46
|
},
|
|
47
|
+
{
|
|
48
|
+
id: "traces",
|
|
49
|
+
label: "Traces",
|
|
50
|
+
icon: ListTree,
|
|
51
|
+
},
|
|
45
52
|
{
|
|
46
53
|
id: "errors",
|
|
47
54
|
label: "Errors",
|
|
@@ -5,6 +5,7 @@ import type { TimeRange } from "../types";
|
|
|
5
5
|
const VALID_SECTIONS: DashboardSection[] = [
|
|
6
6
|
"overview",
|
|
7
7
|
"requests",
|
|
8
|
+
"traces",
|
|
8
9
|
"errors",
|
|
9
10
|
"models",
|
|
10
11
|
"providers",
|
|
@@ -17,7 +18,7 @@ const VALID_SECTIONS: DashboardSection[] = [
|
|
|
17
18
|
|
|
18
19
|
const VALID_RANGES: TimeRange[] = ["1h", "24h", "7d", "30d", "90d", "all"];
|
|
19
20
|
|
|
20
|
-
function parseHash(hash: string): { section: DashboardSection; range: TimeRange } {
|
|
21
|
+
function parseHash(hash: string): { section: DashboardSection; range: TimeRange; session: string | null } {
|
|
21
22
|
const cleanHash = hash.replace(/^#\/?/, "");
|
|
22
23
|
const [pathPart, queryPart] = cleanHash.split("?");
|
|
23
24
|
|
|
@@ -26,15 +27,22 @@ function parseHash(hash: string): { section: DashboardSection; range: TimeRange
|
|
|
26
27
|
: "overview";
|
|
27
28
|
|
|
28
29
|
let range: TimeRange = "24h";
|
|
30
|
+
let session: string | null = null;
|
|
29
31
|
if (queryPart) {
|
|
30
32
|
const params = new URLSearchParams(queryPart);
|
|
31
33
|
const rangeParam = params.get("range") as TimeRange;
|
|
32
34
|
if (VALID_RANGES.includes(rangeParam)) {
|
|
33
35
|
range = rangeParam;
|
|
34
36
|
}
|
|
37
|
+
session = params.get("s");
|
|
35
38
|
}
|
|
36
39
|
|
|
37
|
-
return { section, range };
|
|
40
|
+
return { section, range, session };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function buildHash(section: string, range: TimeRange, session?: string | null): string {
|
|
44
|
+
const sessionPart = session ? `&s=${encodeURIComponent(session)}` : "";
|
|
45
|
+
return `/${section}?range=${range}${sessionPart}`;
|
|
38
46
|
}
|
|
39
47
|
|
|
40
48
|
export function useHashRoute() {
|
|
@@ -51,31 +59,39 @@ export function useHashRoute() {
|
|
|
51
59
|
};
|
|
52
60
|
}, []);
|
|
53
61
|
|
|
54
|
-
const updateHash = useCallback((section: string, range: TimeRange) => {
|
|
55
|
-
window.location.hash =
|
|
62
|
+
const updateHash = useCallback((section: string, range: TimeRange, session?: string | null) => {
|
|
63
|
+
window.location.hash = buildHash(section, range, session);
|
|
56
64
|
}, []);
|
|
57
65
|
|
|
58
66
|
const setSection = useCallback(
|
|
59
67
|
(newSection: DashboardSection) => {
|
|
60
|
-
|
|
68
|
+
// The deep-linked session only applies to the traces view.
|
|
69
|
+
updateHash(newSection, route.range, newSection === "traces" ? route.session : null);
|
|
61
70
|
},
|
|
62
|
-
[route.range, updateHash],
|
|
71
|
+
[route.range, route.session, updateHash],
|
|
63
72
|
);
|
|
64
73
|
|
|
65
74
|
const setRange = useCallback(
|
|
66
75
|
(newRange: string) => {
|
|
67
76
|
const nextRange = VALID_RANGES.includes(newRange as TimeRange) ? (newRange as TimeRange) : "24h";
|
|
68
|
-
updateHash(route.section, nextRange);
|
|
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);
|
|
69
85
|
},
|
|
70
|
-
[route.section, updateHash],
|
|
86
|
+
[route.section, route.range, updateHash],
|
|
71
87
|
);
|
|
72
88
|
|
|
73
89
|
useEffect(() => {
|
|
74
90
|
const currentHash = window.location.hash;
|
|
75
91
|
const parsed = parseHash(currentHash);
|
|
76
|
-
const expectedHash =
|
|
92
|
+
const expectedHash = `#${buildHash(parsed.section, parsed.range, parsed.session)}`;
|
|
77
93
|
if (currentHash !== expectedHash) {
|
|
78
|
-
window.location.hash =
|
|
94
|
+
window.location.hash = buildHash(parsed.section, parsed.range, parsed.session);
|
|
79
95
|
}
|
|
80
96
|
}, []);
|
|
81
97
|
|
|
@@ -84,5 +100,7 @@ export function useHashRoute() {
|
|
|
84
100
|
setSection,
|
|
85
101
|
range: route.range,
|
|
86
102
|
setRange,
|
|
103
|
+
session: route.session,
|
|
104
|
+
setSession,
|
|
87
105
|
};
|
|
88
106
|
}
|
|
@@ -234,6 +234,7 @@ function PeakHoursPanel({ hourly, providers }: { hourly: ProviderHourlyPoint[];
|
|
|
234
234
|
const chartTheme = CHART_THEMES[theme];
|
|
235
235
|
|
|
236
236
|
const { tokensByHour, peakHour } = useMemo(() => {
|
|
237
|
+
// oxlint-disable-next-line unicorn/no-new-array -- length preallocation
|
|
237
238
|
const tokens = new Array<number>(24).fill(0);
|
|
238
239
|
for (const point of hourly) {
|
|
239
240
|
if (provider !== ALL_PROVIDERS && point.provider !== provider) continue;
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Traces section: root-session list (subagents folded in) that opens the
|
|
3
|
+
* flamegraph trace viewer for a selected session.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { useMemo, useState } from "react";
|
|
7
|
+
import { getSessions } from "../api";
|
|
8
|
+
import { formatCompact, formatCost, formatDurationMs, formatRelativeTime } from "../data/formatters";
|
|
9
|
+
import { useResource } from "../data/useResource";
|
|
10
|
+
import { TraceView } from "../traces/TraceView";
|
|
11
|
+
import type { SessionSummary } from "../types";
|
|
12
|
+
import { AsyncBoundary, DataTable, Panel } from "../ui";
|
|
13
|
+
|
|
14
|
+
export interface TracesRouteProps {
|
|
15
|
+
active: boolean;
|
|
16
|
+
session: string | null;
|
|
17
|
+
onOpenSession: (file: string | null) => void;
|
|
18
|
+
refreshTrigger: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function ModelChips({ models }: { models: string[] }) {
|
|
22
|
+
const shown = models.slice(0, 3);
|
|
23
|
+
return (
|
|
24
|
+
<div style={{ display: "flex", gap: 4, flexWrap: "wrap" }}>
|
|
25
|
+
{shown.map(model => (
|
|
26
|
+
<span
|
|
27
|
+
key={model}
|
|
28
|
+
className="stats-text-muted truncate"
|
|
29
|
+
style={{
|
|
30
|
+
fontSize: 10,
|
|
31
|
+
border: "1px solid var(--border)",
|
|
32
|
+
borderRadius: 999,
|
|
33
|
+
padding: "1px 6px",
|
|
34
|
+
maxWidth: 140,
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
{model}
|
|
38
|
+
</span>
|
|
39
|
+
))}
|
|
40
|
+
{models.length > 3 && (
|
|
41
|
+
<span className="stats-text-muted" style={{ fontSize: 10 }}>
|
|
42
|
+
+{models.length - 3}
|
|
43
|
+
</span>
|
|
44
|
+
)}
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function TracesRoute({ active, session, onOpenSession, refreshTrigger }: TracesRouteProps) {
|
|
50
|
+
const [filter, setFilter] = useState("");
|
|
51
|
+
|
|
52
|
+
const {
|
|
53
|
+
data: sessions,
|
|
54
|
+
error,
|
|
55
|
+
loading,
|
|
56
|
+
} = useResource(["sessions", refreshTrigger], signal => getSessions(200, undefined, signal), {
|
|
57
|
+
pollMs: 30000,
|
|
58
|
+
enabled: active && session === null,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const filtered = useMemo(() => {
|
|
62
|
+
if (!sessions) return [];
|
|
63
|
+
const needle = filter.trim().toLowerCase();
|
|
64
|
+
if (!needle) return sessions;
|
|
65
|
+
return sessions.filter(
|
|
66
|
+
row =>
|
|
67
|
+
(row.title ?? "").toLowerCase().includes(needle) ||
|
|
68
|
+
row.folder.toLowerCase().includes(needle) ||
|
|
69
|
+
row.models.some(model => model.toLowerCase().includes(needle)),
|
|
70
|
+
);
|
|
71
|
+
}, [sessions, filter]);
|
|
72
|
+
|
|
73
|
+
const columns = useMemo(
|
|
74
|
+
() => [
|
|
75
|
+
{
|
|
76
|
+
key: "title",
|
|
77
|
+
header: "Title",
|
|
78
|
+
render: (item: SessionSummary) => (
|
|
79
|
+
<div className="stats-font-medium stats-text-primary truncate" style={{ maxWidth: 280 }}>
|
|
80
|
+
{item.title ?? item.file.split("/").pop()}
|
|
81
|
+
</div>
|
|
82
|
+
),
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
key: "folder",
|
|
86
|
+
header: "Project",
|
|
87
|
+
render: (item: SessionSummary) => (
|
|
88
|
+
<span className="stats-text-muted truncate" style={{ maxWidth: 160, display: "inline-block" }}>
|
|
89
|
+
{item.folder.split("/").slice(-2).join("/")}
|
|
90
|
+
</span>
|
|
91
|
+
),
|
|
92
|
+
},
|
|
93
|
+
{
|
|
94
|
+
key: "started",
|
|
95
|
+
header: "Started",
|
|
96
|
+
render: (item: SessionSummary) => formatRelativeTime(item.startedAt),
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
key: "duration",
|
|
100
|
+
header: "Duration",
|
|
101
|
+
numeric: true,
|
|
102
|
+
render: (item: SessionSummary) => formatDurationMs(item.endedAt - item.startedAt),
|
|
103
|
+
},
|
|
104
|
+
{ key: "requests", header: "Requests", numeric: true, render: (item: SessionSummary) => item.requests },
|
|
105
|
+
{ key: "toolCalls", header: "Tools", numeric: true, render: (item: SessionSummary) => item.toolCalls },
|
|
106
|
+
{ key: "subagents", header: "Agents", numeric: true, render: (item: SessionSummary) => item.subagents },
|
|
107
|
+
{
|
|
108
|
+
key: "tokens",
|
|
109
|
+
header: "Tokens",
|
|
110
|
+
numeric: true,
|
|
111
|
+
render: (item: SessionSummary) => formatCompact(item.totalTokens),
|
|
112
|
+
},
|
|
113
|
+
{ key: "cost", header: "Cost", numeric: true, render: (item: SessionSummary) => formatCost(item.costTotal) },
|
|
114
|
+
{ key: "models", header: "Models", render: (item: SessionSummary) => <ModelChips models={item.models} /> },
|
|
115
|
+
],
|
|
116
|
+
[],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const renderMobileCard = (item: SessionSummary, onClick?: () => void) => (
|
|
120
|
+
<div className="stats-mobile-card" onClick={onClick}>
|
|
121
|
+
<div className="stats-mobile-card-header">
|
|
122
|
+
<div className="stats-font-semibold stats-text-primary truncate">
|
|
123
|
+
{item.title ?? item.file.split("/").pop()}
|
|
124
|
+
</div>
|
|
125
|
+
</div>
|
|
126
|
+
<div className="stats-mobile-card-grid">
|
|
127
|
+
<div>
|
|
128
|
+
<div className="stats-mobile-card-label">Started</div>
|
|
129
|
+
<div className="stats-mobile-card-value">{formatRelativeTime(item.startedAt)}</div>
|
|
130
|
+
</div>
|
|
131
|
+
<div>
|
|
132
|
+
<div className="stats-mobile-card-label">Duration</div>
|
|
133
|
+
<div className="stats-mobile-card-value">{formatDurationMs(item.endedAt - item.startedAt)}</div>
|
|
134
|
+
</div>
|
|
135
|
+
<div>
|
|
136
|
+
<div className="stats-mobile-card-label">Requests</div>
|
|
137
|
+
<div className="stats-mobile-card-value">{item.requests}</div>
|
|
138
|
+
</div>
|
|
139
|
+
<div>
|
|
140
|
+
<div className="stats-mobile-card-label">Cost</div>
|
|
141
|
+
<div className="stats-mobile-card-value">{formatCost(item.costTotal)}</div>
|
|
142
|
+
</div>
|
|
143
|
+
</div>
|
|
144
|
+
</div>
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
if (session !== null) {
|
|
148
|
+
return <TraceView file={session} active={active} onBack={() => onOpenSession(null)} />;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return (
|
|
152
|
+
<div className="stats-route-container">
|
|
153
|
+
<Panel
|
|
154
|
+
title="Sessions"
|
|
155
|
+
subtitle="Recent sessions with subagent activity folded in — click one to open its trace"
|
|
156
|
+
actions={
|
|
157
|
+
<input
|
|
158
|
+
type="search"
|
|
159
|
+
value={filter}
|
|
160
|
+
onChange={event => setFilter(event.target.value)}
|
|
161
|
+
placeholder="Filter by title, project, model…"
|
|
162
|
+
aria-label="Filter sessions"
|
|
163
|
+
spellCheck={false}
|
|
164
|
+
className="stats-trace-input"
|
|
165
|
+
style={{ width: 220 }}
|
|
166
|
+
/>
|
|
167
|
+
}
|
|
168
|
+
>
|
|
169
|
+
<AsyncBoundary loading={loading} error={error} data={sessions}>
|
|
170
|
+
<DataTable
|
|
171
|
+
columns={columns}
|
|
172
|
+
data={filtered}
|
|
173
|
+
keyExtractor={item => item.file}
|
|
174
|
+
onRowClick={item => onOpenSession(item.file)}
|
|
175
|
+
renderMobileCard={renderMobileCard}
|
|
176
|
+
emptyText="No sessions found — run a Sync to index recent activity"
|
|
177
|
+
/>
|
|
178
|
+
</AsyncBoundary>
|
|
179
|
+
</Panel>
|
|
180
|
+
</div>
|
|
181
|
+
);
|
|
182
|
+
}
|