@galdor/dashboard 0.3.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/dist/index.d.ts +135 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +1683 -0
- package/dist/index.js.map +1 -0
- package/package.json +36 -0
- package/src/dashboard.test.ts +396 -0
- package/src/index.ts +1856 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,1856 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedded observability dashboard for galdor.
|
|
3
|
+
*
|
|
4
|
+
* A self-contained web UI served over either {@link Bun.serve} (under Bun) or
|
|
5
|
+
* `node:http` (under Node), chosen at runtime by {@link startDashboard}. It reads
|
|
6
|
+
* the SQLite span store that the CLI and exporter write to and renders a full
|
|
7
|
+
* trace explorer:
|
|
8
|
+
*
|
|
9
|
+
* - `GET /` run list with a live (SSE) tail
|
|
10
|
+
* - `GET /runs/:id` run detail: timeline, span tree, graph topology
|
|
11
|
+
* - `GET /runs/:id/steps` step-by-step walkthrough of the run
|
|
12
|
+
* - `GET /runs/:id/spans/:spanId` one span: metadata, attributes, captured messages
|
|
13
|
+
* - `GET /api/runs` JSON run summaries
|
|
14
|
+
* - `GET /api/runs/:id/spans` JSON spans for a run
|
|
15
|
+
* - `GET /api/runs/:id/spans/:spanId`JSON for a single span
|
|
16
|
+
* - `GET /api/runs/:id/graph` JSON graph topology recorded for a run
|
|
17
|
+
* - `GET /api/orphans` orphan-span count
|
|
18
|
+
* - `GET /events` Server-Sent Events live-tail of the run list
|
|
19
|
+
*
|
|
20
|
+
* All HTML, CSS, and the one small client script are inlined in this module —
|
|
21
|
+
* the interactive timeline and graph are rendered server-side as SVG, so the UI
|
|
22
|
+
* works without any client-side framework and ships as a single importable unit.
|
|
23
|
+
*
|
|
24
|
+
* @see {@link startDashboard} to launch the server.
|
|
25
|
+
* @see {@link createHandler} to obtain the request handler on its own.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
29
|
+
import { isIP } from "node:net";
|
|
30
|
+
import { END, type GraphSpec, START } from "@galdor/core/graph";
|
|
31
|
+
import type { Message } from "@galdor/core/schema";
|
|
32
|
+
import { runDuration, type RunSummary, runStatus, type Span, spanDuration, Store } from "@galdor/core/store";
|
|
33
|
+
|
|
34
|
+
/** `true` when running on the Bun runtime; `false` under Node (or any other host). */
|
|
35
|
+
const isBun = typeof (globalThis as { Bun?: unknown }).Bun !== "undefined";
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Attribute keys and span names recorded by the tracing layer. These mirror the
|
|
39
|
+
* constants exported from `@galdor/core/observability`; they are kept local so
|
|
40
|
+
* the viewer depends only on the span store, not on the tracing SDK.
|
|
41
|
+
*/
|
|
42
|
+
const A = {
|
|
43
|
+
node: "galdor.node.name",
|
|
44
|
+
label: "galdor.span.label",
|
|
45
|
+
provider: "galdor.provider.name",
|
|
46
|
+
streaming: "galdor.provider.streaming",
|
|
47
|
+
step: "galdor.step",
|
|
48
|
+
prompt: "gen_ai.prompt",
|
|
49
|
+
completion: "gen_ai.completion",
|
|
50
|
+
reasoning: "gen_ai.reasoning",
|
|
51
|
+
reqModel: "gen_ai.request.model",
|
|
52
|
+
respModel: "gen_ai.response.model",
|
|
53
|
+
finish: "gen_ai.response.finish_reasons",
|
|
54
|
+
inTokens: "gen_ai.usage.input_tokens",
|
|
55
|
+
outTokens: "gen_ai.usage.output_tokens",
|
|
56
|
+
toolName: "gen_ai.tool.name",
|
|
57
|
+
toolIn: "gen_ai.tool.input_size_bytes",
|
|
58
|
+
toolOut: "gen_ai.tool.output_size_bytes",
|
|
59
|
+
reqTools: "gen_ai.request.tools",
|
|
60
|
+
system: "gen_ai.system",
|
|
61
|
+
} as const;
|
|
62
|
+
|
|
63
|
+
const SPAN = {
|
|
64
|
+
run: "galdor.graph.run",
|
|
65
|
+
node: "galdor.graph.node",
|
|
66
|
+
generate: "galdor.provider.generate",
|
|
67
|
+
stream: "galdor.provider.stream",
|
|
68
|
+
tool: "galdor.tool.execute",
|
|
69
|
+
} as const;
|
|
70
|
+
|
|
71
|
+
/** The four kinds of span the UI colour-codes, plus a catch-all. */
|
|
72
|
+
type Kind = "run" | "node" | "model" | "tool" | "other";
|
|
73
|
+
|
|
74
|
+
/** Map a span name to its display kind. */
|
|
75
|
+
function kindOf(name: string): Kind {
|
|
76
|
+
if (name === SPAN.run) return "run";
|
|
77
|
+
if (name === SPAN.node) return "node";
|
|
78
|
+
if (name === SPAN.generate || name === SPAN.stream) return "model";
|
|
79
|
+
if (name === SPAN.tool) return "tool";
|
|
80
|
+
return "other";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** CSS custom-property colour for each span kind. */
|
|
84
|
+
const KIND_FILL: Record<Kind, string> = {
|
|
85
|
+
run: "var(--accent)",
|
|
86
|
+
node: "var(--slate)",
|
|
87
|
+
model: "var(--blue)",
|
|
88
|
+
tool: "var(--violet)",
|
|
89
|
+
other: "var(--subtle)",
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Configuration for {@link startDashboard}.
|
|
94
|
+
*
|
|
95
|
+
* Provide either a {@link DashboardOptions.store | store} to serve from an
|
|
96
|
+
* already-open {@link Store}, or a {@link DashboardOptions.dbPath | dbPath} to
|
|
97
|
+
* open one on demand.
|
|
98
|
+
*/
|
|
99
|
+
export interface DashboardOptions {
|
|
100
|
+
/** Path to the span database; `":memory:"` for tests, or a file path for the real store. */
|
|
101
|
+
dbPath?: string;
|
|
102
|
+
/** An already-open {@link Store} to serve from. Takes precedence over {@link DashboardOptions.dbPath | dbPath}. */
|
|
103
|
+
store?: Store;
|
|
104
|
+
/** Listen host. Defaults to `127.0.0.1` (loopback only). */
|
|
105
|
+
hostname?: string;
|
|
106
|
+
/** Listen port. Defaults to `7777`; pass `0` to bind an ephemeral port. */
|
|
107
|
+
port?: number;
|
|
108
|
+
/**
|
|
109
|
+
* Extra `Host`-header hostnames to accept beyond localhost and IP literals
|
|
110
|
+
* (DNS-rebinding guard). Set only when serving behind a trusted proxy under a
|
|
111
|
+
* custom DNS name. See {@link HandlerOptions.allowedHosts}.
|
|
112
|
+
*/
|
|
113
|
+
allowedHosts?: string[];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Convert a nanosecond count to whole milliseconds. */
|
|
117
|
+
const nanosToMs = (n: bigint): number => Number(n / 1_000_000n);
|
|
118
|
+
|
|
119
|
+
/** Like {@link JSON.stringify} but renders `bigint` values as decimal numbers (safe at millisecond scale). */
|
|
120
|
+
function toJSON(value: unknown): string {
|
|
121
|
+
return JSON.stringify(value, (_k, v) => (typeof v === "bigint" ? Number(v) : v));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Wrap a value as a JSON {@link Response} with the given status. */
|
|
125
|
+
function json(value: unknown, status = 200): Response {
|
|
126
|
+
return new Response(toJSON(value), { status, headers: { "content-type": "application/json" } });
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** Wrap an HTML body as a UTF-8 {@link Response} with the given status. */
|
|
130
|
+
function html(body: string, status = 200): Response {
|
|
131
|
+
return new Response(body, { status, headers: { "content-type": "text/html; charset=utf-8" } });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Wrap an SVG document as an `image/svg+xml` {@link Response}. */
|
|
135
|
+
function svg(body: string): Response {
|
|
136
|
+
return new Response(body, { headers: { "content-type": "image/svg+xml; charset=utf-8", "cache-control": "no-cache" } });
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ── request guards (caps, limits, host allowlist) ────────────────────────────────
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Body-size cap for `POST /api/graph/svg`. A spec larger than this is almost
|
|
143
|
+
* certainly an attempt to amplify a small POST into a multi-megabyte SVG
|
|
144
|
+
* buffered in memory, so it is rejected before decoding.
|
|
145
|
+
*/
|
|
146
|
+
const MAX_GRAPH_SVG_BODY = 1 << 20; // 1 MiB
|
|
147
|
+
/**
|
|
148
|
+
* Node-count cap for a rendered graph. The SVG grows with the node count, so
|
|
149
|
+
* this bounds peak render memory even for a body that fits under
|
|
150
|
+
* {@link MAX_GRAPH_SVG_BODY}.
|
|
151
|
+
*/
|
|
152
|
+
const MAX_GRAPH_NODES = 2000;
|
|
153
|
+
/** Floor applied to the SSE poll cadence so a caller can't drive a hot loop against SQLite. */
|
|
154
|
+
const MIN_STREAM_INTERVAL_MS = 100;
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* DNS-rebinding protection. A rebinding attack relies on a domain name (which
|
|
158
|
+
* the attacker can re-point at `127.0.0.1`) in the `Host` header, so only
|
|
159
|
+
* `localhost`, IP literals (which can't be rebound) and explicitly allowed
|
|
160
|
+
* hostnames are accepted. A bare/absent host is treated as loopback.
|
|
161
|
+
*
|
|
162
|
+
* @param hostname - The request URL hostname (IPv6 arrives bracketed).
|
|
163
|
+
* @param allowed - Extra hostnames to accept beyond localhost/IP literals.
|
|
164
|
+
*/
|
|
165
|
+
function hostAllowed(hostname: string, allowed: Set<string>): boolean {
|
|
166
|
+
let host = hostname;
|
|
167
|
+
if (host.startsWith("[") && host.endsWith("]")) host = host.slice(1, -1); // unwrap IPv6 literal
|
|
168
|
+
if (host === "" || host === "localhost") return true;
|
|
169
|
+
if (isIP(host) !== 0) return true;
|
|
170
|
+
return allowed.has(host);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Parse a `?limit`, clamping to `1..500`; blank/invalid falls back to `fallback`. */
|
|
174
|
+
function parseLimit(raw: string | null, fallback: number): number {
|
|
175
|
+
if (!raw || !/^-?\d+$/.test(raw.trim())) return fallback;
|
|
176
|
+
const n = Number.parseInt(raw, 10);
|
|
177
|
+
if (n <= 0) return fallback;
|
|
178
|
+
return n > 500 ? 500 : n;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Parse a Go-style duration (`500ms`, `2s`, `1us`) to milliseconds, or `undefined`. */
|
|
182
|
+
function parseDurationMs(raw: string): number | undefined {
|
|
183
|
+
const m = raw.trim().match(/^(\d+(?:\.\d+)?)(ns|us|µs|ms|s|m|h)$/);
|
|
184
|
+
if (!m) return undefined;
|
|
185
|
+
const factor: Record<string, number> = { ns: 1e-6, us: 1e-3, "µs": 1e-3, ms: 1, s: 1000, m: 60000, h: 3600000 };
|
|
186
|
+
return Number(m[1]) * factor[m[2]!]!;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Parse the SSE `?interval`: a Go-style duration or a plain millisecond count,
|
|
191
|
+
* defaulting to `fallbackMs` and floored at {@link MIN_STREAM_INTERVAL_MS}.
|
|
192
|
+
*/
|
|
193
|
+
function parseInterval(raw: string | null, fallbackMs: number): number {
|
|
194
|
+
let ms = fallbackMs;
|
|
195
|
+
if (raw) {
|
|
196
|
+
const dur = parseDurationMs(raw);
|
|
197
|
+
if (dur !== undefined && dur > 0) {
|
|
198
|
+
ms = dur;
|
|
199
|
+
} else {
|
|
200
|
+
const n = Number.parseInt(raw, 10);
|
|
201
|
+
if (Number.isFinite(n) && n > 0) ms = n;
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
return ms < MIN_STREAM_INTERVAL_MS ? MIN_STREAM_INTERVAL_MS : ms;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Parse the test-only `_max-iterations` bound; `0` (the default) means run until the client leaves. */
|
|
208
|
+
function parseMaxIters(raw: string | null): number {
|
|
209
|
+
if (!raw || !/^-?\d+$/.test(raw.trim())) return 0;
|
|
210
|
+
return Number.parseInt(raw, 10);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Options for {@link createHandler}. */
|
|
214
|
+
export interface HandlerOptions {
|
|
215
|
+
/**
|
|
216
|
+
* Extra `Host`-header hostnames to accept beyond localhost and IP literals.
|
|
217
|
+
* Set this only when fronting the dashboard with a custom DNS name behind a
|
|
218
|
+
* trusted proxy; by default a domain-name Host is rejected as a
|
|
219
|
+
* DNS-rebinding attempt.
|
|
220
|
+
*/
|
|
221
|
+
allowedHosts?: string[];
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Build the dashboard request handler bound to a span store.
|
|
226
|
+
*
|
|
227
|
+
* The returned function routes requests to the HTML pages, JSON endpoints, and
|
|
228
|
+
* the SSE live-tail described in the module overview, and answers `404` for any
|
|
229
|
+
* unmatched path. Use it directly when embedding the dashboard in an existing
|
|
230
|
+
* server, or let {@link startDashboard} wire it into {@link Bun.serve} for you.
|
|
231
|
+
*
|
|
232
|
+
* Requests whose `Host` header is a domain name (rather than `localhost` or an
|
|
233
|
+
* IP literal) are rejected with `403` as a DNS-rebinding guard; extend the
|
|
234
|
+
* allowlist via {@link HandlerOptions.allowedHosts}.
|
|
235
|
+
*
|
|
236
|
+
* @param store - The {@link Store} whose runs and spans are served.
|
|
237
|
+
* @param opts - Optional {@link HandlerOptions}.
|
|
238
|
+
* @returns A fetch handler that maps a {@link Request} to a {@link Response}.
|
|
239
|
+
* @example
|
|
240
|
+
* ```ts
|
|
241
|
+
* const handler = createHandler(Store.openExisting("spans.db"));
|
|
242
|
+
* const res = handler(new Request("http://127.0.0.1/api/runs"));
|
|
243
|
+
* ```
|
|
244
|
+
*/
|
|
245
|
+
export function createHandler(store: Store, opts: HandlerOptions = {}): (req: Request) => Response | Promise<Response> {
|
|
246
|
+
const allowedHosts = new Set(opts.allowedHosts ?? []);
|
|
247
|
+
return (req: Request): Response | Promise<Response> => {
|
|
248
|
+
const url = new URL(req.url);
|
|
249
|
+
const path = url.pathname;
|
|
250
|
+
|
|
251
|
+
// DNS-rebinding guard: reject any Host that isn't loopback / an IP literal.
|
|
252
|
+
if (!hostAllowed(url.hostname, allowedHosts)) return new Response("forbidden host", { status: 403 });
|
|
253
|
+
|
|
254
|
+
// Health check for liveness probes.
|
|
255
|
+
if (path === "/healthz") return new Response("ok", { headers: { "content-type": "text/plain" } });
|
|
256
|
+
|
|
257
|
+
// Render an arbitrary client-supplied graph spec to SVG (not tied to a run).
|
|
258
|
+
if (path === "/api/graph/svg" && req.method === "POST") return renderPostedGraphSVG(req, url);
|
|
259
|
+
|
|
260
|
+
if (path === "/") return html(runListPage(store, parseLimit(url.searchParams.get("limit"), 50)));
|
|
261
|
+
if (path === "/graph") return html(graphExplorerPage(store, url));
|
|
262
|
+
if (path === "/api/runs") return json(runListJSON(store, parseLimit(url.searchParams.get("limit"), 50)));
|
|
263
|
+
if (path === "/api/orphans") return json({ orphans: store.orphanSpanCount() });
|
|
264
|
+
if (path === "/events") return sseLiveTail(store, url);
|
|
265
|
+
|
|
266
|
+
let m = path.match(/^\/api\/runs\/([^/]+)\/spans\/([^/]+)$/);
|
|
267
|
+
if (m) {
|
|
268
|
+
const s = findSpan(store, decodeURIComponent(m[1]!), decodeURIComponent(m[2]!));
|
|
269
|
+
return s ? json(spanJSON(s)) : json({ error: "span not found" }, 404);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
m = path.match(/^\/api\/runs\/([^/]+)\/spans$/);
|
|
273
|
+
if (m) {
|
|
274
|
+
const runId = decodeURIComponent(m[1]!);
|
|
275
|
+
const spans = store.spansForRun(runId);
|
|
276
|
+
if (spans.length === 0) return json({ error: `run ${runId} not found` }, 404);
|
|
277
|
+
return json(spans.map(spanJSON));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
m = path.match(/^\/api\/runs\/([^/]+)\/graph\/model$/);
|
|
281
|
+
if (m) {
|
|
282
|
+
const id = decodeURIComponent(m[1]!);
|
|
283
|
+
const spec = graphSpec(store, id);
|
|
284
|
+
return spec
|
|
285
|
+
? json(graphModel(spec, store.spansForRun(id)))
|
|
286
|
+
: json({ error: "no graph topology recorded for this run" }, 404);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
m = path.match(/^\/api\/runs\/([^/]+)\/graph\/svg$/);
|
|
290
|
+
if (m) {
|
|
291
|
+
const id = decodeURIComponent(m[1]!);
|
|
292
|
+
const spec = graphSpec(store, id);
|
|
293
|
+
if (!spec) return json({ error: "no graph topology recorded for this run" }, 404);
|
|
294
|
+
const theme = url.searchParams.get("theme") === "dark" ? "dark" : "light";
|
|
295
|
+
return svg(graphSVG(id, spec, store.spansForRun(id), { standalone: true, theme }));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
m = path.match(/^\/api\/runs\/([^/]+)\/graph$/);
|
|
299
|
+
if (m) {
|
|
300
|
+
const spec = graphSpec(store, decodeURIComponent(m[1]!));
|
|
301
|
+
return spec ? json(spec) : json({ error: "no graph topology recorded for this run" }, 404);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
m = path.match(/^\/runs\/([^/]+)\/spans\/([^/]+)$/);
|
|
305
|
+
if (m) {
|
|
306
|
+
const runId = decodeURIComponent(m[1]!), spanId = decodeURIComponent(m[2]!);
|
|
307
|
+
const found = findSpan(store, runId, spanId);
|
|
308
|
+
return html(spanDetailPage(store, runId, spanId), found ? 200 : 404);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
m = path.match(/^\/runs\/([^/]+)\/steps$/);
|
|
312
|
+
if (m) {
|
|
313
|
+
const runId = decodeURIComponent(m[1]!);
|
|
314
|
+
const exists = store.spansForRun(runId).length > 0;
|
|
315
|
+
return html(stepsPage(store, runId), exists ? 200 : 404);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
m = path.match(/^\/runs\/([^/]+)$/);
|
|
319
|
+
if (m) {
|
|
320
|
+
const runId = decodeURIComponent(m[1]!);
|
|
321
|
+
const exists = store.spansForRun(runId).length > 0;
|
|
322
|
+
return html(runDetailPage(store, runId), exists ? 200 : 404);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
return new Response("not found", { status: 404 });
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* A running dashboard server, exposed uniformly across runtimes.
|
|
331
|
+
*
|
|
332
|
+
* Both the Bun and Node backends return a value matching this shape so callers
|
|
333
|
+
* and tests can stay runtime-agnostic.
|
|
334
|
+
*/
|
|
335
|
+
export interface DashboardServer {
|
|
336
|
+
/**
|
|
337
|
+
* The bound listen port. When an ephemeral port (`0`) was requested, the
|
|
338
|
+
* OS-assigned port is only known once the server is listening — await
|
|
339
|
+
* {@link DashboardServer.ready} before reading `.port` in that case. For a
|
|
340
|
+
* fixed port it is valid immediately.
|
|
341
|
+
*/
|
|
342
|
+
port: number;
|
|
343
|
+
/** The bound listen host. */
|
|
344
|
+
hostname: string;
|
|
345
|
+
/**
|
|
346
|
+
* Resolves once the server is accepting connections. Await it before reading
|
|
347
|
+
* {@link DashboardServer.port} when an ephemeral port (`0`) was requested.
|
|
348
|
+
*/
|
|
349
|
+
ready: Promise<void>;
|
|
350
|
+
/**
|
|
351
|
+
* Shut the server down.
|
|
352
|
+
*
|
|
353
|
+
* @param force - When `true`, also tear down active connections (e.g. open
|
|
354
|
+
* Server-Sent Events streams) so the listener can close promptly.
|
|
355
|
+
*/
|
|
356
|
+
stop(force?: boolean): void;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/**
|
|
360
|
+
* Start the dashboard HTTP server.
|
|
361
|
+
*
|
|
362
|
+
* Serves from {@link DashboardOptions.store} when provided, otherwise opens the
|
|
363
|
+
* store at {@link DashboardOptions.dbPath} (defaulting to an in-memory store).
|
|
364
|
+
*
|
|
365
|
+
* The transport is chosen by runtime: on Bun the server is backed by
|
|
366
|
+
* {@link Bun.serve}; on Node it is backed by `node:http`, with an adapter that
|
|
367
|
+
* converts each incoming request into a Web {@link Request}, runs the same
|
|
368
|
+
* {@link createHandler} handler, and streams the Web {@link Response} back —
|
|
369
|
+
* including unbounded Server-Sent Events bodies. Either way the return value
|
|
370
|
+
* conforms to {@link DashboardServer}.
|
|
371
|
+
*
|
|
372
|
+
* @param opts - Server and store {@link DashboardOptions | options}.
|
|
373
|
+
* @returns The running {@link DashboardServer}; call `.stop()` to shut it down.
|
|
374
|
+
* @example
|
|
375
|
+
* ```ts
|
|
376
|
+
* const server = startDashboard({ dbPath: "spans.db", port: 7777 });
|
|
377
|
+
* // ...later
|
|
378
|
+
* server.stop();
|
|
379
|
+
* ```
|
|
380
|
+
*/
|
|
381
|
+
export function startDashboard(opts: DashboardOptions = {}): DashboardServer {
|
|
382
|
+
const store = opts.store ?? Store.openExisting(opts.dbPath ?? ":memory:");
|
|
383
|
+
const handler = createHandler(store);
|
|
384
|
+
const hostname = opts.hostname ?? "127.0.0.1";
|
|
385
|
+
const port = opts.port ?? 7777;
|
|
386
|
+
|
|
387
|
+
if (isBun) {
|
|
388
|
+
const server = Bun.serve({ hostname, port, fetch: handler });
|
|
389
|
+
return {
|
|
390
|
+
get port() {
|
|
391
|
+
return server.port ?? port;
|
|
392
|
+
},
|
|
393
|
+
get hostname() {
|
|
394
|
+
return server.hostname ?? hostname;
|
|
395
|
+
},
|
|
396
|
+
// Bun resolves the ephemeral port synchronously, so the server is ready at once.
|
|
397
|
+
ready: Promise.resolve(),
|
|
398
|
+
stop(force?: boolean) {
|
|
399
|
+
void server.stop(force);
|
|
400
|
+
},
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
return startNodeDashboard(handler, hostname, port);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// ── Node (node:http) transport ───────────────────────────────────────────────────
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Back the dashboard with `node:http` when not running under Bun.
|
|
410
|
+
*
|
|
411
|
+
* For every request the Node {@link IncomingMessage} is adapted to a Web
|
|
412
|
+
* {@link Request} (see {@link toWebRequest}), passed through the shared handler,
|
|
413
|
+
* and the resulting Web {@link Response} is written back to the
|
|
414
|
+
* {@link ServerResponse} by streaming its body (see {@link writeWebResponse}).
|
|
415
|
+
* Open responses are tracked so {@link DashboardServer.stop | stop} can tear
|
|
416
|
+
* down live SSE streams and let the listener close.
|
|
417
|
+
*/
|
|
418
|
+
function startNodeDashboard(
|
|
419
|
+
handler: (req: Request) => Response | Promise<Response>,
|
|
420
|
+
hostname: string,
|
|
421
|
+
port: number,
|
|
422
|
+
): DashboardServer {
|
|
423
|
+
const active = new Set<ServerResponse>();
|
|
424
|
+
|
|
425
|
+
const server = createServer((req, res) => {
|
|
426
|
+
active.add(res);
|
|
427
|
+
res.on("close", () => active.delete(res));
|
|
428
|
+
void (async () => {
|
|
429
|
+
let webRes: Response;
|
|
430
|
+
try {
|
|
431
|
+
webRes = await handler(toWebRequest(req, hostname, boundPort()));
|
|
432
|
+
} catch (e) {
|
|
433
|
+
res.writeHead(500, { "content-type": "text/plain" });
|
|
434
|
+
res.end((e as Error).message);
|
|
435
|
+
return;
|
|
436
|
+
}
|
|
437
|
+
await writeWebResponse(res, webRes);
|
|
438
|
+
})();
|
|
439
|
+
});
|
|
440
|
+
// node:http binds asynchronously: server.address() (and thus an ephemeral
|
|
441
|
+
// port) is null until the "listening" event fires. Expose that as `ready`.
|
|
442
|
+
const ready = new Promise<void>((resolve) => server.once("listening", () => resolve()));
|
|
443
|
+
server.listen(port, hostname);
|
|
444
|
+
|
|
445
|
+
/** The OS-assigned port once listening, falling back to the requested port. */
|
|
446
|
+
const boundPort = (): number => {
|
|
447
|
+
const addr = server.address();
|
|
448
|
+
return addr && typeof addr === "object" ? addr.port : port;
|
|
449
|
+
};
|
|
450
|
+
|
|
451
|
+
return {
|
|
452
|
+
get port() {
|
|
453
|
+
return boundPort();
|
|
454
|
+
},
|
|
455
|
+
hostname,
|
|
456
|
+
ready,
|
|
457
|
+
stop() {
|
|
458
|
+
// Destroy live connections (notably open SSE streams) so close() can settle.
|
|
459
|
+
for (const res of active) res.destroy();
|
|
460
|
+
active.clear();
|
|
461
|
+
server.close();
|
|
462
|
+
},
|
|
463
|
+
};
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Adapt a Node {@link IncomingMessage} to a Web {@link Request}.
|
|
468
|
+
*
|
|
469
|
+
* Reconstructs the absolute URL from the `Host` header (or the bound address),
|
|
470
|
+
* copies method and headers, and — for methods that carry a body — wraps the
|
|
471
|
+
* request stream in a {@link ReadableStream}. The dashboard only serves `GET`,
|
|
472
|
+
* but body forwarding keeps the adapter general.
|
|
473
|
+
*/
|
|
474
|
+
function toWebRequest(req: IncomingMessage, hostname: string, port: number): Request {
|
|
475
|
+
const host = req.headers.host ?? `${hostname}:${port}`;
|
|
476
|
+
const url = `http://${host}${req.url ?? "/"}`;
|
|
477
|
+
const method = req.method ?? "GET";
|
|
478
|
+
|
|
479
|
+
const headers = new Headers();
|
|
480
|
+
for (const [key, value] of Object.entries(req.headers)) {
|
|
481
|
+
if (value === undefined) continue;
|
|
482
|
+
if (Array.isArray(value)) for (const v of value) headers.append(key, v);
|
|
483
|
+
else headers.set(key, value);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const hasBody = method !== "GET" && method !== "HEAD";
|
|
487
|
+
if (!hasBody) return new Request(url, { method, headers });
|
|
488
|
+
|
|
489
|
+
const body = new ReadableStream<Uint8Array>({
|
|
490
|
+
start(controller) {
|
|
491
|
+
req.on("data", (chunk: Buffer) => controller.enqueue(new Uint8Array(chunk)));
|
|
492
|
+
req.on("end", () => controller.close());
|
|
493
|
+
req.on("error", (err) => controller.error(err));
|
|
494
|
+
},
|
|
495
|
+
});
|
|
496
|
+
// `duplex` is required when streaming a request body but is missing from the lib DOM types.
|
|
497
|
+
return new Request(url, { method, headers, body, duplex: "half" } as RequestInit & { duplex: "half" });
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
/**
|
|
501
|
+
* Write a Web {@link Response} back to a Node {@link ServerResponse}.
|
|
502
|
+
*
|
|
503
|
+
* The status and headers are flushed first, then the response body is streamed
|
|
504
|
+
* chunk-by-chunk. This handles both finite bodies (HTML/JSON, whose stream ends)
|
|
505
|
+
* and the unbounded SSE live-tail (whose stream only ends when the client
|
|
506
|
+
* disconnects): when the socket closes, the reader is cancelled, which runs the
|
|
507
|
+
* body's `cancel()` and clears the SSE interval. String chunks (the SSE handler
|
|
508
|
+
* enqueues strings) and byte chunks are both encoded to a {@link Buffer}.
|
|
509
|
+
*/
|
|
510
|
+
async function writeWebResponse(res: ServerResponse, webRes: Response): Promise<void> {
|
|
511
|
+
const headers: Record<string, string | string[]> = {};
|
|
512
|
+
webRes.headers.forEach((value, key) => {
|
|
513
|
+
headers[key] = value;
|
|
514
|
+
});
|
|
515
|
+
res.writeHead(webRes.status, headers);
|
|
516
|
+
|
|
517
|
+
const body = webRes.body;
|
|
518
|
+
if (!body) {
|
|
519
|
+
res.end();
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
const reader = body.getReader();
|
|
524
|
+
let cancelled = false;
|
|
525
|
+
const cancel = (): void => {
|
|
526
|
+
if (cancelled) return;
|
|
527
|
+
cancelled = true;
|
|
528
|
+
void reader.cancel().catch(() => {});
|
|
529
|
+
};
|
|
530
|
+
// If the client hangs up (e.g. closes the SSE connection), stop pulling.
|
|
531
|
+
res.on("close", cancel);
|
|
532
|
+
|
|
533
|
+
try {
|
|
534
|
+
for (;;) {
|
|
535
|
+
const { done, value } = await reader.read();
|
|
536
|
+
if (done) break;
|
|
537
|
+
const chunk = typeof value === "string" ? Buffer.from(value) : Buffer.from(value as Uint8Array);
|
|
538
|
+
if (!res.write(chunk)) await new Promise<void>((resolve) => res.once("drain", resolve));
|
|
539
|
+
}
|
|
540
|
+
} catch {
|
|
541
|
+
// Stream errored or was aborted mid-flight — fall through to end the response.
|
|
542
|
+
}
|
|
543
|
+
if (!res.writableEnded) res.end();
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ── JSON shaping ───────────────────────────────────────────────────────────────
|
|
547
|
+
|
|
548
|
+
function runSummaryJSON(r: RunSummary) {
|
|
549
|
+
return {
|
|
550
|
+
runId: r.runId,
|
|
551
|
+
traceId: r.traceId,
|
|
552
|
+
startMs: nanosToMs(r.startTimeUnixNano),
|
|
553
|
+
durationMs: nanosToMs(runDuration(r)),
|
|
554
|
+
spanCount: r.spanCount,
|
|
555
|
+
errorCount: r.errorCount,
|
|
556
|
+
status: runStatus(r),
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
function runListJSON(store: Store, limit = 100) {
|
|
561
|
+
return store.listRuns(limit).map(runSummaryJSON);
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Parse the graph topology recorded for a run, or `undefined` when none exists. */
|
|
565
|
+
function graphSpec(store: Store, runId: string): GraphSpec | undefined {
|
|
566
|
+
const raw = store.getGraphSpec(runId);
|
|
567
|
+
if (raw === "") return undefined;
|
|
568
|
+
try {
|
|
569
|
+
return normalizeSpec(JSON.parse(raw));
|
|
570
|
+
} catch {
|
|
571
|
+
return undefined;
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* Render an arbitrary {@link GraphSpec} POSTed in the request body to SVG. Lets
|
|
577
|
+
* tools preview a topology without first recording a run. Accepts either spec
|
|
578
|
+
* encoding (see {@link normalizeSpec}); `?theme=dark|light` selects colours.
|
|
579
|
+
*/
|
|
580
|
+
async function renderPostedGraphSVG(req: Request, url: URL): Promise<Response> {
|
|
581
|
+
const text = await req.text();
|
|
582
|
+
// Reject an oversized body before decoding — a spec this large is almost
|
|
583
|
+
// certainly an attempt to amplify a small POST into a multi-MB SVG.
|
|
584
|
+
if (byteLength(text) > MAX_GRAPH_SVG_BODY) {
|
|
585
|
+
return json({ error: "spec too large: body exceeds limit" }, 400);
|
|
586
|
+
}
|
|
587
|
+
let body: unknown;
|
|
588
|
+
try {
|
|
589
|
+
body = JSON.parse(text);
|
|
590
|
+
} catch (e) {
|
|
591
|
+
return json({ error: `invalid JSON body: ${(e as Error).message}` }, 400);
|
|
592
|
+
}
|
|
593
|
+
const spec = normalizeSpec(body);
|
|
594
|
+
if (!spec) return json({ error: "body is not a valid graph spec" }, 400);
|
|
595
|
+
// Cap the node count: the SVG grows with the node count, so this bounds peak
|
|
596
|
+
// render memory even for a body that fits under the byte cap.
|
|
597
|
+
if (spec.nodes.length > MAX_GRAPH_NODES) {
|
|
598
|
+
return json({ error: "spec too large: node count exceeds limit" }, 400);
|
|
599
|
+
}
|
|
600
|
+
const theme = url.searchParams.get("theme") === "dark" ? "dark" : "light";
|
|
601
|
+
return svg(graphSVG("(custom)", spec, [], { standalone: true, theme }));
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/** Byte length of a UTF-8 string without allocating the full encoded buffer where possible. */
|
|
605
|
+
function byteLength(s: string): number {
|
|
606
|
+
return new TextEncoder().encode(s).length;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* Standalone graph-explorer page: a run picker that links to each run's recorded
|
|
611
|
+
* topology, plus a textarea to paste an arbitrary spec and render it via
|
|
612
|
+
* `POST /api/graph/svg`.
|
|
613
|
+
*/
|
|
614
|
+
function graphExplorerPage(store: Store, url: URL): string {
|
|
615
|
+
const runs = store.listRuns(100).filter((r) => store.getGraphSpec(r.runId) !== "");
|
|
616
|
+
// ?run=<id> selects a run directly; default to the latest run with a topology.
|
|
617
|
+
let selected = url.searchParams.get("run") ?? "";
|
|
618
|
+
if (selected === "" && runs.length > 0) selected = runs[0]!.runId;
|
|
619
|
+
const options = runs.length === 0
|
|
620
|
+
? `<li class="muted">(no runs with a recorded topology yet)</li>`
|
|
621
|
+
: runs.map((r) => {
|
|
622
|
+
const mark = r.runId === selected ? " ← selected" : "";
|
|
623
|
+
return `<li><a href="/graph?run=${enc(r.runId)}">${esc(r.runId)}</a>${mark} · <a href="/runs/${enc(r.runId)}">detail</a></li>`;
|
|
624
|
+
}).join("");
|
|
625
|
+
// Server-render the selected run's topology inline (clickable per-node SVG).
|
|
626
|
+
const theme = url.searchParams.get("theme") === "dark" ? "dark" : "light";
|
|
627
|
+
const spec = selected ? graphSpec(store, selected) : undefined;
|
|
628
|
+
const selectedSVG = spec
|
|
629
|
+
? `<h2>topology for <code>${esc(selected)}</code></h2>${graphSVG(selected, spec, store.spansForRun(selected), { standalone: false, theme })}`
|
|
630
|
+
: selected
|
|
631
|
+
? `<h2>topology for <code>${esc(selected)}</code></h2><p class="muted">no graph topology recorded for this run</p>`
|
|
632
|
+
: "";
|
|
633
|
+
const placeholder = esc('{ "entry": "a", "nodes": [{"name":"a"}], "edges": [], "conditional": [] }');
|
|
634
|
+
return shell(
|
|
635
|
+
"graph explorer",
|
|
636
|
+
`<h1>graph explorer</h1>
|
|
637
|
+
<h2>runs with a recorded topology</h2>
|
|
638
|
+
<ul class="runs-graph">${options}</ul>
|
|
639
|
+
${selectedSVG}
|
|
640
|
+
<h2>render an arbitrary spec</h2>
|
|
641
|
+
<p class="subtle">Paste a graph spec and render it to SVG.</p>
|
|
642
|
+
<textarea id="spec" rows="8" style="width:100%;font-family:var(--mono);" placeholder="${placeholder}"></textarea>
|
|
643
|
+
<p><button id="render">render</button></p>
|
|
644
|
+
<div id="out"></div>
|
|
645
|
+
<script>
|
|
646
|
+
document.getElementById('render').addEventListener('click', async () => {
|
|
647
|
+
const out = document.getElementById('out');
|
|
648
|
+
try {
|
|
649
|
+
const res = await fetch('/api/graph/svg', { method: 'POST', headers: { 'content-type': 'application/json' }, body: document.getElementById('spec').value });
|
|
650
|
+
if (!res.ok) { out.textContent = 'error: ' + (await res.json()).error; return; }
|
|
651
|
+
out.innerHTML = await res.text();
|
|
652
|
+
} catch (e) { out.textContent = 'error: ' + e.message; }
|
|
653
|
+
});
|
|
654
|
+
</script>`,
|
|
655
|
+
);
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
/**
|
|
659
|
+
* Normalize a recorded graph spec into {@link GraphSpec}.
|
|
660
|
+
*
|
|
661
|
+
* Accepts two equivalent encodings: nodes as plain names with `edges` /
|
|
662
|
+
* `conditional`, or nodes as `{name}` objects with `static_edges` /
|
|
663
|
+
* `conditional_edges` whose branches are `[{label,to}]`. Both describe the same
|
|
664
|
+
* topology, so either renders identically.
|
|
665
|
+
*/
|
|
666
|
+
function normalizeSpec(v: unknown): GraphSpec | undefined {
|
|
667
|
+
if (!v || typeof v !== "object") return undefined;
|
|
668
|
+
const o = v as Record<string, unknown>;
|
|
669
|
+
const entry = typeof o.entry === "string" ? o.entry : "";
|
|
670
|
+
|
|
671
|
+
const rawNodes = Array.isArray(o.nodes) ? o.nodes : [];
|
|
672
|
+
const nodes = rawNodes
|
|
673
|
+
.map((n): GraphSpec["nodes"][number] => {
|
|
674
|
+
if (typeof n === "string") return { name: n };
|
|
675
|
+
const obj = n as { name?: unknown; interrupt?: unknown };
|
|
676
|
+
if (obj && typeof obj.name === "string") {
|
|
677
|
+
return obj.interrupt ? { name: obj.name, interrupt: true } : { name: obj.name };
|
|
678
|
+
}
|
|
679
|
+
return { name: "" };
|
|
680
|
+
})
|
|
681
|
+
.filter((n) => n.name.length > 0);
|
|
682
|
+
|
|
683
|
+
const rawEdges = Array.isArray(o.edges) ? o.edges : Array.isArray(o.static_edges) ? o.static_edges : [];
|
|
684
|
+
const edges = rawEdges
|
|
685
|
+
.filter((e): e is { from: string; to: string } => !!e && typeof (e as { from?: unknown }).from === "string" && typeof (e as { to?: unknown }).to === "string")
|
|
686
|
+
.map((e) => ({ from: e.from, to: e.to }));
|
|
687
|
+
|
|
688
|
+
let conditional: GraphSpec["conditional"] = [];
|
|
689
|
+
if (Array.isArray(o.conditional)) {
|
|
690
|
+
conditional = o.conditional
|
|
691
|
+
.filter((c): c is { from: string; labels?: Record<string, string> } => !!c && typeof (c as { from?: unknown }).from === "string")
|
|
692
|
+
.map((c) => (c.labels ? { from: c.from, labels: c.labels } : { from: c.from }));
|
|
693
|
+
} else if (Array.isArray(o.conditional_edges)) {
|
|
694
|
+
conditional = (o.conditional_edges as Array<{ from?: unknown; branches?: Array<{ label?: unknown; to?: unknown }> }>)
|
|
695
|
+
.filter((c): c is { from: string; branches?: Array<{ label: string; to: string }> } => !!c && typeof c.from === "string")
|
|
696
|
+
.map((c) => {
|
|
697
|
+
if (Array.isArray(c.branches) && c.branches.length > 0) {
|
|
698
|
+
const labels: Record<string, string> = {};
|
|
699
|
+
for (const b of c.branches) {
|
|
700
|
+
if (b && typeof b.label === "string" && typeof b.to === "string") labels[b.label] = b.to;
|
|
701
|
+
}
|
|
702
|
+
return { from: c.from, labels };
|
|
703
|
+
}
|
|
704
|
+
return { from: c.from };
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (!entry && nodes.length === 0 && edges.length === 0 && conditional.length === 0) return undefined;
|
|
709
|
+
return { entry, nodes, edges, conditional };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function spanJSON(s: Span) {
|
|
713
|
+
return {
|
|
714
|
+
spanId: s.spanId,
|
|
715
|
+
parentSpanId: s.parentSpanId,
|
|
716
|
+
traceId: s.traceId,
|
|
717
|
+
name: s.name,
|
|
718
|
+
startMs: nanosToMs(s.startTimeUnixNano),
|
|
719
|
+
durationMs: Number(spanDuration(s)) / 1e6,
|
|
720
|
+
statusCode: s.statusCode,
|
|
721
|
+
statusMessage: s.statusMessage,
|
|
722
|
+
attributes: s.attributes,
|
|
723
|
+
events: s.events.map((e) => ({ name: e.name, timeMs: nanosToMs(e.timeUnixNano), attributes: e.attributes })),
|
|
724
|
+
};
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function spansJSON(store: Store, runId: string) {
|
|
728
|
+
return store.spansForRun(runId).map(spanJSON);
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function findSpan(store: Store, runId: string, spanId: string): Span | undefined {
|
|
732
|
+
return store.spansForRun(runId).find((s) => s.spanId === spanId);
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
// ── SSE live tail ───────────────────────────────────────────────────────────────
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Server-Sent Events feed of run summaries as new spans land. Mirrors the
|
|
739
|
+
* oracle's `handleStreamRuns`:
|
|
740
|
+
*
|
|
741
|
+
* event: run data: <run summary JSON> (one per newly-touched run)
|
|
742
|
+
* event: heartbeat data: <unix-ms int> (every tick, keeps proxies open)
|
|
743
|
+
*
|
|
744
|
+
* A rowid cursor (seeded at the current max) means a freshly-connected client
|
|
745
|
+
* only receives runs touched *after* it connected — the existing runs are
|
|
746
|
+
* already in the static page render. `?interval` sets the cadence (Go-style
|
|
747
|
+
* duration or plain ms; floored at {@link MIN_STREAM_INTERVAL_MS}); the internal
|
|
748
|
+
* `?_max-iterations` bounds the loop for tests.
|
|
749
|
+
*/
|
|
750
|
+
function sseLiveTail(store: Store, url: URL): Response {
|
|
751
|
+
const interval = parseInterval(url.searchParams.get("interval"), 1000);
|
|
752
|
+
const maxIters = Number.parseInt(url.searchParams.get("_max-iterations") ?? "", 10);
|
|
753
|
+
let timer: ReturnType<typeof setInterval> | undefined;
|
|
754
|
+
// Seed the cursor at the current max so historical runs aren't replayed.
|
|
755
|
+
let cursor = store.maxSpanRowid();
|
|
756
|
+
const emitted = new Map<string, bigint>(); // runId -> last emitted end time, dedups updates
|
|
757
|
+
let iter = 0;
|
|
758
|
+
|
|
759
|
+
const stream = new ReadableStream({
|
|
760
|
+
start(controller) {
|
|
761
|
+
const send = (event: string, data: string) => controller.enqueue(`event: ${event}\ndata: ${data}\n\n`);
|
|
762
|
+
const tick = () => {
|
|
763
|
+
try {
|
|
764
|
+
iter++;
|
|
765
|
+
const { spans, cursor: next } = store.spansSince(cursor, 500);
|
|
766
|
+
cursor = next;
|
|
767
|
+
const touched = new Set(spans.map((s) => s.traceId));
|
|
768
|
+
if (touched.size > 0) {
|
|
769
|
+
for (const summary of store.listRuns(500)) {
|
|
770
|
+
if (!touched.has(summary.traceId)) continue;
|
|
771
|
+
const prev = emitted.get(summary.runId);
|
|
772
|
+
if (prev !== undefined && prev === summary.endTimeUnixNano) continue;
|
|
773
|
+
emitted.set(summary.runId, summary.endTimeUnixNano);
|
|
774
|
+
send("run", toJSON(runSummaryJSON(summary)));
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
send("heartbeat", String(Date.now()));
|
|
778
|
+
if (Number.isFinite(maxIters) && maxIters > 0 && iter >= maxIters) {
|
|
779
|
+
if (timer) clearInterval(timer);
|
|
780
|
+
controller.close();
|
|
781
|
+
}
|
|
782
|
+
} catch {
|
|
783
|
+
if (timer) clearInterval(timer);
|
|
784
|
+
}
|
|
785
|
+
};
|
|
786
|
+
timer = setInterval(tick, interval);
|
|
787
|
+
timer.unref?.();
|
|
788
|
+
},
|
|
789
|
+
cancel() {
|
|
790
|
+
if (timer) clearInterval(timer);
|
|
791
|
+
},
|
|
792
|
+
});
|
|
793
|
+
return new Response(stream, {
|
|
794
|
+
headers: {
|
|
795
|
+
"content-type": "text/event-stream",
|
|
796
|
+
"cache-control": "no-cache",
|
|
797
|
+
connection: "keep-alive",
|
|
798
|
+
"x-accel-buffering": "no",
|
|
799
|
+
},
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ── formatting helpers ───────────────────────────────────────────────────────────
|
|
804
|
+
|
|
805
|
+
function esc(s: string): string {
|
|
806
|
+
return s.replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]!);
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
const enc = encodeURIComponent;
|
|
810
|
+
|
|
811
|
+
/** Format a nanosecond duration as a compact, human-readable string. */
|
|
812
|
+
function fmtDur(ns: bigint): string {
|
|
813
|
+
const ms = Number(ns) / 1e6;
|
|
814
|
+
if (ms >= 1000) return `${(ms / 1000).toFixed(2)} s`;
|
|
815
|
+
if (ms >= 1) return `${ms.toFixed(1)} ms`;
|
|
816
|
+
if (ms > 0) return `${ms.toFixed(2)} ms`;
|
|
817
|
+
return "0 ms";
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** Truncate a long id to `head…tail` form for compact display. */
|
|
821
|
+
function shortId(s: string): string {
|
|
822
|
+
return s.length > 18 ? `${s.slice(0, 8)}…${s.slice(-4)}` : s;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/** Strip the `galdor.` prefix from a span name for readability. */
|
|
826
|
+
function shortName(name: string): string {
|
|
827
|
+
return name.replace(/^galdor\./, "");
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/** Display label for a graph node, naming the sentinel start/end nodes. */
|
|
831
|
+
function nodeLabel(n: string): string {
|
|
832
|
+
return n === START ? "start" : n === END ? "end" : n;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
/** Read a string attribute, or `undefined` when absent or not a string. */
|
|
836
|
+
function attrStr(attrs: Record<string, unknown>, key: string): string | undefined {
|
|
837
|
+
const v = attrs[key];
|
|
838
|
+
return typeof v === "string" ? v : undefined;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
/** Read a numeric attribute, coercing numeric strings, or `undefined`. */
|
|
842
|
+
function attrNum(attrs: Record<string, unknown>, key: string): number | undefined {
|
|
843
|
+
const v = attrs[key];
|
|
844
|
+
if (typeof v === "number") return v;
|
|
845
|
+
if (typeof v === "string" && v !== "" && !Number.isNaN(Number(v))) return Number(v);
|
|
846
|
+
return undefined;
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
/** Render an arbitrary attribute value for the attributes table. */
|
|
850
|
+
function fmtVal(v: unknown): string {
|
|
851
|
+
if (typeof v === "string") return v;
|
|
852
|
+
if (typeof v === "number" || typeof v === "boolean") return String(v);
|
|
853
|
+
try {
|
|
854
|
+
return JSON.stringify(v);
|
|
855
|
+
} catch {
|
|
856
|
+
return String(v);
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ── span ordering ────────────────────────────────────────────────────────────────
|
|
861
|
+
|
|
862
|
+
interface Ordered {
|
|
863
|
+
span: Span;
|
|
864
|
+
depth: number;
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
/** Lay spans out in depth-first preorder (children under parents, by start time). */
|
|
868
|
+
function orderSpans(spans: Span[]): Ordered[] {
|
|
869
|
+
const byId = new Map(spans.map((s) => [s.spanId, s]));
|
|
870
|
+
const children = new Map<string, Span[]>();
|
|
871
|
+
const roots: Span[] = [];
|
|
872
|
+
for (const s of spans) {
|
|
873
|
+
const p = s.parentSpanId;
|
|
874
|
+
if (p && byId.has(p)) {
|
|
875
|
+
const arr = children.get(p) ?? [];
|
|
876
|
+
arr.push(s);
|
|
877
|
+
children.set(p, arr);
|
|
878
|
+
} else {
|
|
879
|
+
roots.push(s);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
const cmp = (a: Span, b: Span): number =>
|
|
883
|
+
a.startTimeUnixNano < b.startTimeUnixNano ? -1 : a.startTimeUnixNano > b.startTimeUnixNano ? 1 : 0;
|
|
884
|
+
const out: Ordered[] = [];
|
|
885
|
+
const walk = (s: Span, depth: number): void => {
|
|
886
|
+
out.push({ span: s, depth });
|
|
887
|
+
for (const c of (children.get(s.spanId) ?? []).sort(cmp)) walk(c, depth + 1);
|
|
888
|
+
};
|
|
889
|
+
for (const r of roots.sort(cmp)) walk(r, 0);
|
|
890
|
+
return out;
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
/** The earliest start and latest end across a set of spans. */
|
|
894
|
+
function runBounds(spans: Span[]): { t0: bigint; t1: bigint } {
|
|
895
|
+
let t0 = spans[0]!.startTimeUnixNano;
|
|
896
|
+
let t1 = spans[0]!.endTimeUnixNano;
|
|
897
|
+
for (const s of spans) {
|
|
898
|
+
if (s.startTimeUnixNano < t0) t0 = s.startTimeUnixNano;
|
|
899
|
+
if (s.endTimeUnixNano > t1) t1 = s.endTimeUnixNano;
|
|
900
|
+
}
|
|
901
|
+
return { t0, t1 };
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
// ── HTML shell ───────────────────────────────────────────────────────────────────
|
|
905
|
+
|
|
906
|
+
const STYLE = `
|
|
907
|
+
:root{
|
|
908
|
+
--bg:#131314; --bg2:#1b1c1d; --panel:#1e1f20; --panel2:#28292b; --raised:#2d2f31;
|
|
909
|
+
--border:#303134; --border2:#3c4043; --fg:#e3e3e3; --muted:#bdc1c6; --subtle:#9aa0a6;
|
|
910
|
+
--accent:#8ab4f8; --accent-soft:rgba(138,180,248,.12);
|
|
911
|
+
--blue:#8ab4f8; --violet:#c58af9; --slate:#9aa0a6;
|
|
912
|
+
--ok:#81c995; --err:#f28b82; --warn:#fdd663;
|
|
913
|
+
--edge:#5b636f; --node-stroke:#454a52; --grid:#34363a;
|
|
914
|
+
--radius:12px; --mono:"Roboto Mono",ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
|
|
915
|
+
--sans:"Google Sans","Google Sans Text","Product Sans",Inter,system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;
|
|
916
|
+
}
|
|
917
|
+
:root,:root[data-theme="dark"]{color-scheme:dark;}
|
|
918
|
+
:root[data-theme="light"]{
|
|
919
|
+
color-scheme:light;
|
|
920
|
+
--bg:#ffffff; --bg2:#f0f4f9; --panel:#ffffff; --panel2:#f3f6fc; --raised:#e9eef6;
|
|
921
|
+
--border:#dde3ea; --border2:#c4cad1; --fg:#1f1f1f; --muted:#444746; --subtle:#6f7378;
|
|
922
|
+
--accent:#0b57d0; --accent-soft:rgba(11,87,208,.08);
|
|
923
|
+
--blue:#0b57d0; --violet:#8430ce; --slate:#5f6368;
|
|
924
|
+
--ok:#188038; --err:#c5221f; --warn:#9a6700;
|
|
925
|
+
--edge:#80868b; --node-stroke:#9aa0a6; --grid:#c4cad1;
|
|
926
|
+
}
|
|
927
|
+
*{box-sizing:border-box;}
|
|
928
|
+
html,body{margin:0;}
|
|
929
|
+
body{
|
|
930
|
+
background:var(--bg);
|
|
931
|
+
color:var(--fg); font:14px/1.55 var(--sans); -webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
|
|
932
|
+
}
|
|
933
|
+
a{color:var(--accent); text-decoration:none;} a:hover{text-decoration:underline;}
|
|
934
|
+
code,pre,.mono{font-family:var(--mono);}
|
|
935
|
+
.muted{color:var(--muted);} .subtle{color:var(--subtle);}
|
|
936
|
+
|
|
937
|
+
.topbar{position:sticky; top:0; z-index:10; display:flex; align-items:center; gap:14px;
|
|
938
|
+
padding:12px 26px; background:var(--bg2); border-bottom:1px solid var(--border);}
|
|
939
|
+
.brand{display:flex; align-items:baseline; gap:9px;}
|
|
940
|
+
.brand .mark{font-weight:500; font-size:16px; letter-spacing:0; color:var(--fg);}
|
|
941
|
+
.brand .mark a{color:var(--fg);}
|
|
942
|
+
.brand .tag{font-size:12px; color:var(--subtle); letter-spacing:0;}
|
|
943
|
+
.topbar .spacer{flex:1;}
|
|
944
|
+
.live{display:inline-flex; align-items:center; gap:7px; font-size:12px; color:var(--subtle);}
|
|
945
|
+
.icon-btn{display:inline-flex; align-items:center; justify-content:center; width:34px; height:34px;
|
|
946
|
+
border:1px solid var(--border2); border-radius:9px; background:var(--panel2); color:var(--muted);
|
|
947
|
+
cursor:pointer; font:15px/1 var(--sans); transition:border-color .12s,color .12s,background .12s;}
|
|
948
|
+
.icon-btn:hover{border-color:var(--accent); color:var(--fg); background:var(--raised);}
|
|
949
|
+
.live .pulse{width:7px; height:7px; border-radius:50%; background:var(--ok); opacity:.9; animation:pulse 2.4s ease-in-out infinite;}
|
|
950
|
+
@keyframes pulse{0%,100%{opacity:.3;}50%{opacity:.9;}}
|
|
951
|
+
|
|
952
|
+
main{padding:26px; max-width:1180px; margin:0 auto;}
|
|
953
|
+
.crumbs{display:flex; gap:8px; align-items:center; font-size:13px; margin-bottom:14px; color:var(--subtle);}
|
|
954
|
+
.crumbs a{color:var(--muted);} .crumbs .sep{color:var(--border2);}
|
|
955
|
+
|
|
956
|
+
.page-head{display:flex; align-items:flex-start; gap:16px; flex-wrap:wrap; margin-bottom:20px;}
|
|
957
|
+
.page-head h1{margin:0; font-size:20px; letter-spacing:-.2px;}
|
|
958
|
+
.page-head .id{font-family:var(--mono); font-size:13px; color:var(--muted); word-break:break-all;}
|
|
959
|
+
.page-head .actions{margin-left:auto; display:flex; gap:8px;}
|
|
960
|
+
|
|
961
|
+
.btn{display:inline-block; padding:6px 13px; border:1px solid var(--border2); border-radius:8px;
|
|
962
|
+
background:var(--panel2); color:var(--fg); font-size:13px; cursor:pointer;}
|
|
963
|
+
.btn:hover{border-color:var(--accent); text-decoration:none; background:var(--raised);}
|
|
964
|
+
.btn.primary{background:var(--accent-soft); border-color:var(--accent);}
|
|
965
|
+
|
|
966
|
+
.stats{display:flex; gap:10px; flex-wrap:wrap; margin-bottom:18px;}
|
|
967
|
+
.stat{background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); padding:10px 16px; min-width:108px;}
|
|
968
|
+
.stat .k{font-size:11px; text-transform:uppercase; letter-spacing:.6px; color:var(--subtle);}
|
|
969
|
+
.stat .v{font-size:19px; font-weight:600; margin-top:2px; font-variant-numeric:tabular-nums;}
|
|
970
|
+
|
|
971
|
+
.panel{background:var(--panel); border:1px solid var(--border); border-radius:var(--radius); margin-bottom:20px; overflow:hidden;}
|
|
972
|
+
.panel > h2{margin:0; padding:13px 18px; font-size:13px; font-weight:500; letter-spacing:0;
|
|
973
|
+
color:var(--muted); border-bottom:1px solid var(--border); background:var(--bg2); display:flex; gap:10px; align-items:center;}
|
|
974
|
+
.panel > h2 .count{color:var(--subtle); font-weight:400; letter-spacing:0;}
|
|
975
|
+
.panel-body{padding:16px 18px;}
|
|
976
|
+
.panel-scroll{overflow-x:auto;}
|
|
977
|
+
|
|
978
|
+
.banner{margin:0 0 20px; padding:11px 15px; border-radius:var(--radius); font-size:13px;
|
|
979
|
+
background:rgba(253,214,99,.08); border:1px solid rgba(253,214,99,.32); color:var(--warn);}
|
|
980
|
+
|
|
981
|
+
table{width:100%; border-collapse:collapse;}
|
|
982
|
+
th,td{text-align:left; padding:10px 16px; border-bottom:1px solid var(--border);}
|
|
983
|
+
tr:last-child td{border-bottom:0;}
|
|
984
|
+
th{color:var(--subtle); font-weight:500; font-size:12px; letter-spacing:0;}
|
|
985
|
+
tbody tr{transition:background .12s;} tbody tr:hover{background:var(--panel2);}
|
|
986
|
+
td.num{font-variant-numeric:tabular-nums;}
|
|
987
|
+
|
|
988
|
+
.badge{display:inline-block; padding:1px 9px; border-radius:20px; font-size:11.5px; font-weight:600; line-height:1.6;}
|
|
989
|
+
.badge.ok{background:rgba(129,201,149,.14); color:var(--ok);}
|
|
990
|
+
.badge.err,.badge.error{background:rgba(242,139,130,.14); color:var(--err);}
|
|
991
|
+
.dot{display:inline-block; width:9px; height:9px; border-radius:50%; vertical-align:middle;}
|
|
992
|
+
.dot.ok{background:var(--ok);} .dot.error{background:var(--err);}
|
|
993
|
+
.dot.run{background:var(--accent);} .dot.node{background:var(--slate);}
|
|
994
|
+
.dot.model{background:var(--blue);} .dot.tool{background:var(--violet);} .dot.other{background:var(--subtle);}
|
|
995
|
+
|
|
996
|
+
/* span tree */
|
|
997
|
+
.tree{display:flex; flex-direction:column;}
|
|
998
|
+
.trow{display:flex; align-items:center; gap:12px; padding:7px 18px; border-bottom:1px solid var(--border);
|
|
999
|
+
color:var(--fg); font-size:13px;}
|
|
1000
|
+
.trow:last-child{border-bottom:0;}
|
|
1001
|
+
.trow:hover{background:var(--panel2); text-decoration:none;}
|
|
1002
|
+
.trow .nm{display:flex; align-items:center; gap:8px; min-width:240px; font-family:var(--mono);}
|
|
1003
|
+
.trow .nm .lbl{color:var(--subtle); font-family:var(--sans);}
|
|
1004
|
+
.trow .ex{margin-left:8px; color:var(--muted); font-family:var(--sans); font-size:12px; white-space:nowrap;}
|
|
1005
|
+
.trow .ex .exk{color:var(--subtle); margin-right:3px;}
|
|
1006
|
+
.trow .tbar{flex:1; height:8px; background:var(--bg2); border-radius:5px; position:relative; min-width:120px; overflow:hidden;}
|
|
1007
|
+
.trow .tbar i{position:absolute; top:0; bottom:0; border-radius:5px;}
|
|
1008
|
+
.trow .tdur{font-variant-numeric:tabular-nums; color:var(--muted); width:78px; text-align:right;}
|
|
1009
|
+
|
|
1010
|
+
/* timeline */
|
|
1011
|
+
.timeline{display:block; width:100%; height:auto; font-family:var(--mono);}
|
|
1012
|
+
.timeline .tl-grid{stroke:var(--grid); stroke-width:1;}
|
|
1013
|
+
.timeline .tl-axis{fill:var(--subtle); font-size:10px;}
|
|
1014
|
+
.timeline .tl-label{fill:var(--muted); font-size:11px;}
|
|
1015
|
+
.timeline .tl-dur{fill:var(--subtle); font-size:10px;}
|
|
1016
|
+
.timeline a .tl-hit{fill:transparent;}
|
|
1017
|
+
.timeline a:hover .tl-hit{fill:rgba(122,162,255,.08);}
|
|
1018
|
+
.timeline a:hover .tl-label{fill:var(--fg);}
|
|
1019
|
+
.legend{display:flex; gap:16px; flex-wrap:wrap; padding:10px 18px 0; font-size:12px; color:var(--muted);}
|
|
1020
|
+
.legend span{display:inline-flex; align-items:center; gap:6px;}
|
|
1021
|
+
|
|
1022
|
+
/* graph */
|
|
1023
|
+
.graph{display:block; width:100%; height:auto; font-family:var(--sans);}
|
|
1024
|
+
.graph .edge{fill:none; stroke:var(--edge); stroke-width:1.7;}
|
|
1025
|
+
.graph .edge.cond{stroke-dasharray:5 4; stroke:var(--slate);}
|
|
1026
|
+
.graph .arrow{fill:var(--edge);}
|
|
1027
|
+
.graph .edge-label{fill:var(--subtle); font-size:10px; text-anchor:middle;}
|
|
1028
|
+
.graph .gnode rect{fill:var(--raised); stroke:var(--node-stroke); stroke-width:1.5;}
|
|
1029
|
+
.graph .gnode:hover rect{stroke:var(--accent);}
|
|
1030
|
+
.graph .gnode.entry rect{stroke:var(--accent); fill:var(--accent-soft);}
|
|
1031
|
+
.graph .gnode.term rect{fill:var(--panel2); stroke:var(--slate);}
|
|
1032
|
+
.graph .gnode.interrupt rect{stroke:#d97706; fill:rgba(217,119,6,.16);}
|
|
1033
|
+
.graph .gnode.err rect{stroke:var(--err);}
|
|
1034
|
+
.graph .gnode-label{fill:var(--fg); font-size:12px; font-weight:600; text-anchor:middle;}
|
|
1035
|
+
.graph .gnode-sub{fill:var(--muted); font-size:10px; text-anchor:middle; font-family:var(--mono);}
|
|
1036
|
+
|
|
1037
|
+
/* topology lists */
|
|
1038
|
+
.topo{display:flex; gap:34px; flex-wrap:wrap;}
|
|
1039
|
+
.topo h3{font-size:12px; text-transform:uppercase; letter-spacing:.5px; color:var(--subtle); margin:0 0 8px;}
|
|
1040
|
+
.topo ul{margin:0; padding-left:18px; font-family:var(--mono); font-size:13px;}
|
|
1041
|
+
.topo li{margin:3px 0;}
|
|
1042
|
+
|
|
1043
|
+
/* meta grid */
|
|
1044
|
+
.meta{display:grid; grid-template-columns:repeat(auto-fill,minmax(220px,1fr)); gap:1px; background:var(--border); border-radius:var(--radius); overflow:hidden;}
|
|
1045
|
+
.meta .cell{background:var(--panel); padding:11px 16px;}
|
|
1046
|
+
.meta .k{font-size:11px; text-transform:uppercase; letter-spacing:.5px; color:var(--subtle);}
|
|
1047
|
+
.meta .v{margin-top:3px; font-family:var(--mono); font-size:13px; word-break:break-all;}
|
|
1048
|
+
|
|
1049
|
+
/* messages */
|
|
1050
|
+
.msgs{display:grid; grid-template-columns:1fr 1fr; gap:16px;}
|
|
1051
|
+
@media(max-width:820px){.msgs{grid-template-columns:1fr;}}
|
|
1052
|
+
.msgcol h3{font-size:12px; text-transform:uppercase; letter-spacing:.5px; color:var(--subtle); margin:0 0 10px;}
|
|
1053
|
+
.msg{border:1px solid var(--border); border-radius:9px; margin-bottom:10px; overflow:hidden; background:var(--bg2);}
|
|
1054
|
+
.msg-role{font-size:11px; text-transform:uppercase; letter-spacing:.6px; padding:5px 12px; color:var(--muted); border-bottom:1px solid var(--border); background:var(--panel2);}
|
|
1055
|
+
.msg.user .msg-role{color:var(--accent);}
|
|
1056
|
+
.msg.assistant .msg-role{color:var(--ok);}
|
|
1057
|
+
.msg.system .msg-role{color:var(--warn);}
|
|
1058
|
+
.msg.tool .msg-role{color:var(--violet);}
|
|
1059
|
+
.msg-body{padding:10px 12px;}
|
|
1060
|
+
.msg-body pre{margin:0; white-space:pre-wrap; word-break:break-word; font-size:12.5px; line-height:1.5;}
|
|
1061
|
+
.msg-body pre.text{font-family:var(--sans);}
|
|
1062
|
+
.toolcall{font-family:var(--mono); font-size:12.5px; color:var(--violet); margin:5px 0; word-break:break-word;}
|
|
1063
|
+
.toolcall .tname{color:var(--fg);}
|
|
1064
|
+
.toolcall code{color:var(--muted);}
|
|
1065
|
+
details.reason{margin:6px 0; border-left:2px solid var(--border2); padding-left:10px;}
|
|
1066
|
+
details.reason summary{cursor:pointer; color:var(--subtle); font-size:12px;}
|
|
1067
|
+
details.reason pre{margin:6px 0 0; white-space:pre-wrap; font-size:12px; color:var(--muted);}
|
|
1068
|
+
|
|
1069
|
+
/* steps */
|
|
1070
|
+
.step{border:1px solid var(--border); border-radius:var(--radius); margin-bottom:16px; overflow:hidden; background:var(--panel);}
|
|
1071
|
+
.step-head{display:flex; align-items:center; gap:12px; padding:11px 16px; background:var(--bg2); border-bottom:1px solid var(--border);}
|
|
1072
|
+
.step-head .n{width:26px; height:26px; border-radius:7px; background:var(--accent-soft); color:var(--accent); display:flex; align-items:center; justify-content:center; font-weight:700; font-size:13px;}
|
|
1073
|
+
.step-head .nm{font-weight:600;}
|
|
1074
|
+
.step-head .meta-inline{margin-left:auto; color:var(--muted); font-size:12px; font-variant-numeric:tabular-nums;}
|
|
1075
|
+
.step-body{padding:14px 16px;}
|
|
1076
|
+
.call{border:1px solid var(--border); border-radius:9px; padding:11px 13px; margin-bottom:12px; background:var(--bg2);}
|
|
1077
|
+
.call:last-child{margin-bottom:0;}
|
|
1078
|
+
.call-head{display:flex; gap:10px; align-items:baseline; flex-wrap:wrap; margin-bottom:8px; font-size:13px;}
|
|
1079
|
+
.call-head .kind{font-size:11px; text-transform:uppercase; letter-spacing:.5px; padding:1px 8px; border-radius:6px;}
|
|
1080
|
+
.call-head .kind.model{background:rgba(138,180,248,.14); color:var(--blue);}
|
|
1081
|
+
.call-head .kind.tool{background:rgba(197,138,249,.14); color:var(--violet);}
|
|
1082
|
+
.call-head .meta-inline{margin-left:auto; color:var(--subtle); font-size:12px; font-variant-numeric:tabular-nums;}
|
|
1083
|
+
.hint{font-size:12.5px; color:var(--subtle); font-style:italic;}
|
|
1084
|
+
|
|
1085
|
+
.empty{padding:30px; text-align:center; color:var(--subtle);}
|
|
1086
|
+
`;
|
|
1087
|
+
|
|
1088
|
+
/** Top navigation bar shared by every page. */
|
|
1089
|
+
function topbar(live: boolean): string {
|
|
1090
|
+
const liveDot = live
|
|
1091
|
+
? `<span class="live"><span class="pulse"></span> live</span>`
|
|
1092
|
+
: "";
|
|
1093
|
+
return `<div class="topbar">
|
|
1094
|
+
<div class="brand"><span class="mark"><a href="/">galdor</a></span><span class="tag">trace explorer</span></div>
|
|
1095
|
+
<span class="spacer"></span>${liveDot}
|
|
1096
|
+
<button id="theme-toggle" class="icon-btn" type="button" aria-label="Toggle light or dark theme" title="Toggle light / dark"></button>
|
|
1097
|
+
</div>`;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
/** Inline, render-blocking theme bootstrap: applies the saved/system theme before first paint (no flash). */
|
|
1101
|
+
const THEME_INIT = `<script>(function(){try{var s=localStorage.getItem('galdor-theme');var t=s||((window.matchMedia&&window.matchMedia('(prefers-color-scheme: light)').matches)?'light':'dark');document.documentElement.setAttribute('data-theme',t);}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();</script>`;
|
|
1102
|
+
|
|
1103
|
+
/** Wire the topbar toggle to flip and persist the theme. */
|
|
1104
|
+
const THEME_TOGGLE = `<script>(function(){var b=document.getElementById('theme-toggle');if(!b)return;function sync(){var t=document.documentElement.getAttribute('data-theme');b.textContent=t==='light'?'\\u263E':'\\u2600';}sync();b.addEventListener('click',function(){var next=document.documentElement.getAttribute('data-theme')==='light'?'dark':'light';document.documentElement.setAttribute('data-theme',next);try{localStorage.setItem('galdor-theme',next);}catch(e){}sync();});})();</script>`;
|
|
1105
|
+
|
|
1106
|
+
function shell(title: string, body: string, opts: { live?: boolean } = {}): string {
|
|
1107
|
+
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
1108
|
+
${THEME_INIT}<title>${esc(title)} · galdor</title><style>${STYLE}</style></head>
|
|
1109
|
+
<body>${topbar(opts.live ?? false)}<main>${body}</main>${THEME_TOGGLE}</body></html>`;
|
|
1110
|
+
}
|
|
1111
|
+
|
|
1112
|
+
// ── run list page ────────────────────────────────────────────────────────────────
|
|
1113
|
+
|
|
1114
|
+
function runListPage(store: Store, limit = 100): string {
|
|
1115
|
+
const runs = runListJSON(store, limit);
|
|
1116
|
+
const orphans = store.orphanSpanCount();
|
|
1117
|
+
const banner = orphans > 0
|
|
1118
|
+
? `<div class="banner">⚠ ${orphans} span(s) carry no run id and won't appear below — instrument through @galdor/core/observability so every span lands in a run.</div>`
|
|
1119
|
+
: "";
|
|
1120
|
+
const rowsHtml = runs.length === 0
|
|
1121
|
+
? `<tr><td colspan="5" class="empty">No runs recorded yet. Run a traced graph and they'll appear here.</td></tr>`
|
|
1122
|
+
: runs.map(runRow).join("");
|
|
1123
|
+
|
|
1124
|
+
// Live-tail: re-render the tbody as new runs arrive over SSE.
|
|
1125
|
+
const script = `<script>
|
|
1126
|
+
function row(r){
|
|
1127
|
+
var t=new Date(r.startMs).toLocaleTimeString();
|
|
1128
|
+
var dur=r.durationMs>=1000?(r.durationMs/1000).toFixed(2)+' s':r.durationMs+' ms';
|
|
1129
|
+
return '<tr><td><span class="dot '+r.status+'"></span> <a href="/runs/'+encodeURIComponent(r.runId)+'">'+
|
|
1130
|
+
(r.runId||'(no id)')+'</a></td><td><span class="badge '+r.status+'">'+r.status+'</span></td>'+
|
|
1131
|
+
'<td class="num">'+r.spanCount+'</td><td class="num">'+dur+'</td><td class="muted">'+t+'</td></tr>';
|
|
1132
|
+
}
|
|
1133
|
+
var es=new EventSource('/events');
|
|
1134
|
+
es.addEventListener('run',function(e){
|
|
1135
|
+
var r=JSON.parse(e.data); var tb=document.querySelector('tbody');
|
|
1136
|
+
var existing=document.getElementById('run-'+r.runId);
|
|
1137
|
+
var tmp=document.createElement('tbody'); tmp.innerHTML=row(r);
|
|
1138
|
+
var tr=tmp.firstElementChild; tr.id='run-'+r.runId;
|
|
1139
|
+
if(existing) existing.replaceWith(tr); else tb.insertBefore(tr, tb.firstChild);
|
|
1140
|
+
});
|
|
1141
|
+
</script>`;
|
|
1142
|
+
|
|
1143
|
+
const body = `${banner}
|
|
1144
|
+
<div class="page-head"><h1>Runs</h1></div>
|
|
1145
|
+
<div class="panel panel-scroll">
|
|
1146
|
+
<table><thead><tr><th>run</th><th>status</th><th>spans</th><th>duration</th><th>started</th></tr></thead>
|
|
1147
|
+
<tbody>${rowsHtml}</tbody></table>
|
|
1148
|
+
</div>${script}`;
|
|
1149
|
+
return shell("runs", body, { live: true });
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
function runRow(r: ReturnType<typeof runListJSON>[number]): string {
|
|
1153
|
+
const dur = r.durationMs >= 1000 ? `${(r.durationMs / 1000).toFixed(2)} s` : `${r.durationMs} ms`;
|
|
1154
|
+
const time = new Date(r.startMs).toLocaleTimeString();
|
|
1155
|
+
return `<tr>
|
|
1156
|
+
<td><span class="dot ${r.status}"></span> <a href="/runs/${enc(r.runId)}">${esc(r.runId || "(no id)")}</a></td>
|
|
1157
|
+
<td><span class="badge ${r.status}">${r.status}</span></td>
|
|
1158
|
+
<td class="num">${r.spanCount}</td>
|
|
1159
|
+
<td class="num">${dur}</td>
|
|
1160
|
+
<td class="muted">${esc(time)}</td>
|
|
1161
|
+
</tr>`;
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// ── run detail page ──────────────────────────────────────────────────────────────
|
|
1165
|
+
|
|
1166
|
+
function runDetailPage(store: Store, runId: string): string {
|
|
1167
|
+
const spans = store.spansForRun(runId);
|
|
1168
|
+
if (spans.length === 0) {
|
|
1169
|
+
return shell(
|
|
1170
|
+
runId,
|
|
1171
|
+
`${crumbs([["/", "runs"], [null, shortId(runId)]])}<div class="panel"><div class="empty">No spans recorded for <code>${esc(runId)}</code>.</div></div>`,
|
|
1172
|
+
);
|
|
1173
|
+
}
|
|
1174
|
+
const ordered = orderSpans(spans);
|
|
1175
|
+
const { t0, t1 } = runBounds(spans);
|
|
1176
|
+
const total = t1 > t0 ? t1 - t0 : 1n;
|
|
1177
|
+
const errors = spans.filter((s) => s.statusCode === "error").length;
|
|
1178
|
+
const status = errors > 0 ? "error" : "ok";
|
|
1179
|
+
const spec = graphSpec(store, runId);
|
|
1180
|
+
const tok = tokenTotals(spans);
|
|
1181
|
+
const tokStat = tok.input + tok.output > 0
|
|
1182
|
+
? `<div class="stat"><div class="k">tokens</div><div class="v">${tok.input + tok.output}<span class="subtle" style="font-size:12px"> · ${tok.input}↑ ${tok.output}↓</span></div></div>`
|
|
1183
|
+
: "";
|
|
1184
|
+
|
|
1185
|
+
const head = `<div class="page-head">
|
|
1186
|
+
<div><h1>Run</h1><div class="id">${esc(runId)}</div></div>
|
|
1187
|
+
<div class="actions"><a class="btn primary" href="/runs/${enc(runId)}/steps">Steps view →</a></div>
|
|
1188
|
+
</div>
|
|
1189
|
+
<div class="stats">
|
|
1190
|
+
<div class="stat"><div class="k">status</div><div class="v"><span class="badge ${status}">${status}</span></div></div>
|
|
1191
|
+
<div class="stat"><div class="k">duration</div><div class="v">${fmtDur(total)}</div></div>
|
|
1192
|
+
<div class="stat"><div class="k">spans</div><div class="v">${spans.length}</div></div>
|
|
1193
|
+
<div class="stat"><div class="k">errors</div><div class="v">${errors}</div></div>
|
|
1194
|
+
${tokStat}
|
|
1195
|
+
<div class="stat"><div class="k">started</div><div class="v" style="font-size:13px">${esc(new Date(nanosToMs(t0)).toLocaleString())}</div></div>
|
|
1196
|
+
</div>`;
|
|
1197
|
+
|
|
1198
|
+
const timeline = `<div class="panel">
|
|
1199
|
+
<h2>timeline <span class="count">${ordered.length} spans</span></h2>
|
|
1200
|
+
${timelineLegend()}
|
|
1201
|
+
<div class="panel-body panel-scroll">${timelineSVG(runId, ordered, t0, total)}</div>
|
|
1202
|
+
</div>`;
|
|
1203
|
+
|
|
1204
|
+
const tree = `<div class="panel">
|
|
1205
|
+
<h2>span tree</h2>
|
|
1206
|
+
<div class="tree">${ordered.map((o) => treeRow(runId, o, t0, total)).join("")}</div>
|
|
1207
|
+
</div>`;
|
|
1208
|
+
|
|
1209
|
+
const graph = spec ? graphPanel(runId, spec, spans) : "";
|
|
1210
|
+
|
|
1211
|
+
return shell(
|
|
1212
|
+
runId,
|
|
1213
|
+
`${crumbs([["/", "runs"], [null, shortId(runId)]])}${head}${timeline}${graph}${tree}`,
|
|
1214
|
+
);
|
|
1215
|
+
}
|
|
1216
|
+
|
|
1217
|
+
function timelineLegend(): string {
|
|
1218
|
+
const item = (k: Kind, label: string) => `<span><span class="dot ${k}"></span>${label}</span>`;
|
|
1219
|
+
return `<div class="legend">${item("run", "run")}${item("node", "node")}${item("model", "model call")}${item("tool", "tool")}<span><span class="dot error"></span>error</span></div>`;
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
/** Render the run's spans as a clickable SVG waterfall. */
|
|
1223
|
+
function timelineSVG(runId: string, ordered: Ordered[], t0: bigint, total: bigint): string {
|
|
1224
|
+
const labelW = 250;
|
|
1225
|
+
const barW = 760;
|
|
1226
|
+
const padTop = 26;
|
|
1227
|
+
const rowH = 26;
|
|
1228
|
+
const W = labelW + barW + 70;
|
|
1229
|
+
const H = padTop + ordered.length * rowH + 8;
|
|
1230
|
+
const totalNum = Number(total);
|
|
1231
|
+
const xOf = (ns: bigint): number => labelW + (Number(ns - t0) * barW) / totalNum;
|
|
1232
|
+
|
|
1233
|
+
// Time axis: 6 evenly-spaced gridlines with relative-ms labels.
|
|
1234
|
+
let axis = "";
|
|
1235
|
+
for (let i = 0; i <= 5; i++) {
|
|
1236
|
+
const frac = i / 5;
|
|
1237
|
+
const x = labelW + frac * barW;
|
|
1238
|
+
const ms = (totalNum * frac) / 1e6;
|
|
1239
|
+
const lbl = ms >= 1000 ? `${(ms / 1000).toFixed(1)}s` : `${ms.toFixed(ms < 10 ? 1 : 0)}ms`;
|
|
1240
|
+
axis += `<line class="tl-grid" x1="${x.toFixed(1)}" y1="${padTop}" x2="${x.toFixed(1)}" y2="${H - 6}"/>`;
|
|
1241
|
+
axis += `<text class="tl-axis" x="${x.toFixed(1)}" y="${padTop - 8}" text-anchor="${i === 5 ? "end" : "middle"}">${lbl}</text>`;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
let rows = "";
|
|
1245
|
+
ordered.forEach(({ span, depth }, i) => {
|
|
1246
|
+
const y = padTop + i * rowH;
|
|
1247
|
+
const cy = y + rowH / 2;
|
|
1248
|
+
const dur = spanDuration(span);
|
|
1249
|
+
let x = xOf(span.startTimeUnixNano);
|
|
1250
|
+
let w = (Number(dur) * barW) / totalNum;
|
|
1251
|
+
if (w < 3) w = 3;
|
|
1252
|
+
if (x + w > labelW + barW) x = labelW + barW - w;
|
|
1253
|
+
const kind = kindOf(span.name);
|
|
1254
|
+
const fill = span.statusCode === "error" ? "var(--err)" : KIND_FILL[kind];
|
|
1255
|
+
const lx = 10 + depth * 11;
|
|
1256
|
+
const label = esc(truncate(shortName(span.name), 30 - depth * 2));
|
|
1257
|
+
rows += `<a href="/runs/${enc(runId)}/spans/${enc(span.spanId)}">
|
|
1258
|
+
<rect class="tl-hit" x="0" y="${y}" width="${W}" height="${rowH}"/>
|
|
1259
|
+
<text class="tl-label" x="${lx}" y="${cy + 4}">${label}</text>
|
|
1260
|
+
<rect x="${x.toFixed(1)}" y="${y + (rowH - 12) / 2}" width="${w.toFixed(1)}" height="12" rx="3" fill="${fill}"><title>${esc(span.name)} — ${fmtDur(dur)}</title></rect>
|
|
1261
|
+
<text class="tl-dur" x="${(x + w + 6).toFixed(1)}" y="${cy + 4}">${fmtDur(dur)}</text>
|
|
1262
|
+
</a>`;
|
|
1263
|
+
});
|
|
1264
|
+
|
|
1265
|
+
return `<svg class="timeline" viewBox="0 0 ${W} ${H}" width="${W}" preserveAspectRatio="xMinYMin meet" role="img">${axis}${rows}</svg>`;
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function truncate(s: string, n: number): string {
|
|
1269
|
+
return s.length > n ? `${s.slice(0, n - 1)}…` : s;
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
/** One row of the textual span tree, with a proportional latency bar. */
|
|
1273
|
+
function treeRow(runId: string, { span, depth }: Ordered, t0: bigint, total: bigint): string {
|
|
1274
|
+
const dur = spanDuration(span);
|
|
1275
|
+
const kind = kindOf(span.name);
|
|
1276
|
+
const fill = span.statusCode === "error" ? "var(--err)" : KIND_FILL[kind];
|
|
1277
|
+
const totalNum = Number(total);
|
|
1278
|
+
const left = (Number(span.startTimeUnixNano - t0) * 100) / totalNum;
|
|
1279
|
+
const width = Math.max(1.5, (Number(dur) * 100) / totalNum);
|
|
1280
|
+
const err = span.statusCode === "error" ? `<span class="badge err">error</span>` : "";
|
|
1281
|
+
return `<a class="trow" href="/runs/${enc(runId)}/spans/${enc(span.spanId)}">
|
|
1282
|
+
<span class="nm" style="padding-left:${depth * 16}px"><span class="dot ${kind}"></span>${esc(shortName(span.name))}${spanExtras(span.attributes)}</span>
|
|
1283
|
+
<span class="tbar"><i style="left:${left.toFixed(2)}%;width:${width.toFixed(2)}%;background:${fill}"></i></span>
|
|
1284
|
+
<span class="tdur">${fmtDur(dur)}</span>${err}
|
|
1285
|
+
</a>`;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
/** Inline key/value chips (label, node, provider, model, tokens) shown beside a span. */
|
|
1289
|
+
function spanExtras(a: Record<string, unknown>): string {
|
|
1290
|
+
const bits: string[] = [];
|
|
1291
|
+
const push = (k: string, v: string | undefined): void => {
|
|
1292
|
+
if (v) bits.push(`<span class="ex"><span class="exk">${k}</span>${esc(v)}</span>`);
|
|
1293
|
+
};
|
|
1294
|
+
push("label", attrStr(a, A.label));
|
|
1295
|
+
push("node", attrStr(a, A.node));
|
|
1296
|
+
push("provider", attrStr(a, A.provider) ?? attrStr(a, A.system));
|
|
1297
|
+
push("model", attrStr(a, A.reqModel));
|
|
1298
|
+
push("tool", attrStr(a, A.toolName));
|
|
1299
|
+
const inT = attrNum(a, A.inTokens);
|
|
1300
|
+
const outT = attrNum(a, A.outTokens);
|
|
1301
|
+
if (inT && inT > 0) push("in", String(inT));
|
|
1302
|
+
if (outT && outT > 0) push("out", String(outT));
|
|
1303
|
+
return bits.join("");
|
|
1304
|
+
}
|
|
1305
|
+
|
|
1306
|
+
/** Sum input/output token usage across a run's provider spans. */
|
|
1307
|
+
function tokenTotals(spans: Span[]): { input: number; output: number } {
|
|
1308
|
+
let input = 0;
|
|
1309
|
+
let output = 0;
|
|
1310
|
+
for (const s of spans) {
|
|
1311
|
+
input += attrNum(s.attributes, A.inTokens) ?? 0;
|
|
1312
|
+
output += attrNum(s.attributes, A.outTokens) ?? 0;
|
|
1313
|
+
}
|
|
1314
|
+
return { input, output };
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
// ── graph panel + SVG ────────────────────────────────────────────────────────────
|
|
1318
|
+
|
|
1319
|
+
interface NodeStat {
|
|
1320
|
+
dur: bigint;
|
|
1321
|
+
status: "ok" | "error";
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
/** Aggregate recorded duration/status for the spans belonging to a graph node. */
|
|
1325
|
+
function nodeStat(spans: Span[], nodeName: string): NodeStat | undefined {
|
|
1326
|
+
let dur = 0n;
|
|
1327
|
+
let status: "ok" | "error" = "ok";
|
|
1328
|
+
let found = false;
|
|
1329
|
+
for (const s of spans) {
|
|
1330
|
+
if (s.name === SPAN.node && s.attributes[A.node] === nodeName) {
|
|
1331
|
+
found = true;
|
|
1332
|
+
dur += spanDuration(s);
|
|
1333
|
+
if (s.statusCode === "error") status = "error";
|
|
1334
|
+
}
|
|
1335
|
+
}
|
|
1336
|
+
return found ? { dur, status } : undefined;
|
|
1337
|
+
}
|
|
1338
|
+
|
|
1339
|
+
function graphPanel(runId: string, spec: GraphSpec, spans: Span[]): string {
|
|
1340
|
+
const api = `<span class="count" style="margin-left:auto;font-size:12px">
|
|
1341
|
+
API:
|
|
1342
|
+
<a href="/api/runs/${enc(runId)}/graph/model">JSON</a> ·
|
|
1343
|
+
<a href="/api/runs/${enc(runId)}/graph/svg">SVG</a> ·
|
|
1344
|
+
<a href="/api/runs/${enc(runId)}/graph">spec</a></span>`;
|
|
1345
|
+
return `<div class="panel">
|
|
1346
|
+
<h2>graph topology${api}</h2>
|
|
1347
|
+
<div class="panel-body panel-scroll">${graphSVG(runId, spec, spans)}</div>
|
|
1348
|
+
<div class="panel-body" style="border-top:1px solid var(--border)">${topologyLists(spec)}</div>
|
|
1349
|
+
</div>`;
|
|
1350
|
+
}
|
|
1351
|
+
|
|
1352
|
+
interface Edge {
|
|
1353
|
+
from: string;
|
|
1354
|
+
to: string;
|
|
1355
|
+
cond: boolean;
|
|
1356
|
+
label: string;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
/** Collect the full node set, flattened edge list, and interrupt-gated node names of a spec. */
|
|
1360
|
+
function collectGraph(spec: GraphSpec): { nodes: string[]; edges: Edge[]; interrupt: Set<string> } {
|
|
1361
|
+
const interrupt = new Set<string>();
|
|
1362
|
+
const nodeSet = new Set<string>();
|
|
1363
|
+
for (const n of spec.nodes) {
|
|
1364
|
+
nodeSet.add(n.name);
|
|
1365
|
+
if (n.interrupt) interrupt.add(n.name);
|
|
1366
|
+
}
|
|
1367
|
+
const edges: Edge[] = [];
|
|
1368
|
+
for (const e of spec.edges) {
|
|
1369
|
+
nodeSet.add(e.from);
|
|
1370
|
+
nodeSet.add(e.to);
|
|
1371
|
+
edges.push({ from: e.from, to: e.to, cond: false, label: "" });
|
|
1372
|
+
}
|
|
1373
|
+
for (const c of spec.conditional) {
|
|
1374
|
+
nodeSet.add(c.from);
|
|
1375
|
+
if (c.labels) {
|
|
1376
|
+
for (const [label, to] of Object.entries(c.labels)) {
|
|
1377
|
+
nodeSet.add(to);
|
|
1378
|
+
edges.push({ from: c.from, to, cond: true, label });
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
return { nodes: [...nodeSet], edges, interrupt };
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
/**
|
|
1386
|
+
* The graph as the dashboard models it: the recorded topology enriched with each
|
|
1387
|
+
* node's measured duration and status. This is the same data the SVG is drawn
|
|
1388
|
+
* from, exposed as plain JSON so it can be rendered or analysed elsewhere.
|
|
1389
|
+
*/
|
|
1390
|
+
function graphModel(spec: GraphSpec, spans: Span[]) {
|
|
1391
|
+
const { nodes, edges, interrupt } = collectGraph(spec);
|
|
1392
|
+
return {
|
|
1393
|
+
entry: spec.entry,
|
|
1394
|
+
nodes: nodes.map((n) => {
|
|
1395
|
+
const stat = nodeStat(spans, n);
|
|
1396
|
+
return {
|
|
1397
|
+
name: n,
|
|
1398
|
+
role: n === START ? "start" : n === END ? "end" : "node",
|
|
1399
|
+
entry: n === spec.entry,
|
|
1400
|
+
interrupt: interrupt.has(n),
|
|
1401
|
+
durationMs: stat ? Number(stat.dur) / 1e6 : null,
|
|
1402
|
+
status: stat ? stat.status : null,
|
|
1403
|
+
};
|
|
1404
|
+
}),
|
|
1405
|
+
edges: edges.map((e) => ({ from: e.from, to: e.to, conditional: e.cond, label: e.cond ? e.label : null })),
|
|
1406
|
+
};
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
/** Options controlling how {@link graphSVG} emits its SVG. */
|
|
1410
|
+
interface GraphSVGOptions {
|
|
1411
|
+
/** Emit a self-contained SVG (embedded styles, no page links) for use outside the dashboard. */
|
|
1412
|
+
standalone?: boolean;
|
|
1413
|
+
/** Colour scheme for a standalone SVG. Ignored in-page, where the active page theme applies. */
|
|
1414
|
+
theme?: "dark" | "light";
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
/** Render the recorded graph as a layered SVG with per-node run metrics. */
|
|
1418
|
+
function graphSVG(runId: string, spec: GraphSpec, spans: Span[], opts: GraphSVGOptions = {}): string {
|
|
1419
|
+
const { nodes, edges, interrupt } = collectGraph(spec);
|
|
1420
|
+
|
|
1421
|
+
// Longest-path layering (start sinks at layer 0). Iteration count bounds cycles.
|
|
1422
|
+
const layer = new Map<string, number>(nodes.map((n) => [n, 0]));
|
|
1423
|
+
for (let iter = 0; iter < nodes.length; iter++) {
|
|
1424
|
+
let changed = false;
|
|
1425
|
+
for (const e of edges) {
|
|
1426
|
+
const lu = layer.get(e.from) ?? 0;
|
|
1427
|
+
if ((layer.get(e.to) ?? 0) < lu + 1) {
|
|
1428
|
+
layer.set(e.to, lu + 1);
|
|
1429
|
+
changed = true;
|
|
1430
|
+
}
|
|
1431
|
+
}
|
|
1432
|
+
if (!changed) break;
|
|
1433
|
+
}
|
|
1434
|
+
|
|
1435
|
+
const NODE_H = 42;
|
|
1436
|
+
const V_GAP = 100;
|
|
1437
|
+
const PAD = 24;
|
|
1438
|
+
const widthOf = (n: string): number => Math.max(96, nodeLabel(n).length * 8 + 30);
|
|
1439
|
+
|
|
1440
|
+
const layers: string[][] = [];
|
|
1441
|
+
for (const n of nodes) {
|
|
1442
|
+
const L = layer.get(n) ?? 0;
|
|
1443
|
+
(layers[L] ??= []).push(n);
|
|
1444
|
+
}
|
|
1445
|
+
const maxPer = Math.max(1, ...layers.map((a) => a?.length ?? 0));
|
|
1446
|
+
const maxNodeW = Math.max(96, ...nodes.map(widthOf));
|
|
1447
|
+
const CW = Math.max(720, maxPer * (maxNodeW + 56));
|
|
1448
|
+
const CH = PAD * 2 + Math.max(0, layers.length - 1) * V_GAP + NODE_H;
|
|
1449
|
+
|
|
1450
|
+
const pos = new Map<string, { cx: number; cy: number; w: number }>();
|
|
1451
|
+
layers.forEach((arr, L) => {
|
|
1452
|
+
if (!arr) return;
|
|
1453
|
+
const m = arr.length;
|
|
1454
|
+
arr.forEach((n, i) => {
|
|
1455
|
+
pos.set(n, { cx: (CW * (i + 0.5)) / m, cy: PAD + NODE_H / 2 + L * V_GAP, w: widthOf(n) });
|
|
1456
|
+
});
|
|
1457
|
+
});
|
|
1458
|
+
|
|
1459
|
+
let edgeSvg = "";
|
|
1460
|
+
for (const e of edges) {
|
|
1461
|
+
const a = pos.get(e.from);
|
|
1462
|
+
const b = pos.get(e.to);
|
|
1463
|
+
if (!a || !b) continue;
|
|
1464
|
+
const x1 = a.cx;
|
|
1465
|
+
const y1 = a.cy + NODE_H / 2;
|
|
1466
|
+
const x2 = b.cx;
|
|
1467
|
+
const y2 = b.cy - NODE_H / 2;
|
|
1468
|
+
const my = (y1 + y2) / 2;
|
|
1469
|
+
const d = `M${x1.toFixed(1)},${y1.toFixed(1)} C${x1.toFixed(1)},${my.toFixed(1)} ${x2.toFixed(1)},${my.toFixed(1)} ${x2.toFixed(1)},${y2.toFixed(1)}`;
|
|
1470
|
+
edgeSvg += `<path class="edge${e.cond ? " cond" : ""}" d="${d}" marker-end="url(#gv-arrow)"/>`;
|
|
1471
|
+
if (e.cond && e.label) {
|
|
1472
|
+
edgeSvg += `<text class="edge-label" x="${((x1 + x2) / 2).toFixed(1)}" y="${my.toFixed(1)}">${esc(e.label)}</text>`;
|
|
1473
|
+
}
|
|
1474
|
+
}
|
|
1475
|
+
|
|
1476
|
+
let nodeSvg = "";
|
|
1477
|
+
for (const n of nodes) {
|
|
1478
|
+
const p = pos.get(n);
|
|
1479
|
+
if (!p) continue;
|
|
1480
|
+
const term = n === START || n === END;
|
|
1481
|
+
const entry = n === spec.entry;
|
|
1482
|
+
const stat = nodeStat(spans, n);
|
|
1483
|
+
const cls = ["gnode"];
|
|
1484
|
+
if (term) cls.push("term");
|
|
1485
|
+
if (entry) cls.push("entry");
|
|
1486
|
+
if (interrupt.has(n)) cls.push("interrupt");
|
|
1487
|
+
if (stat?.status === "error") cls.push("err");
|
|
1488
|
+
const x = p.cx - p.w / 2;
|
|
1489
|
+
const y = p.cy - NODE_H / 2;
|
|
1490
|
+
const sub = stat ? fmtDur(stat.dur) : "";
|
|
1491
|
+
const tip = stat ? `${n} — ${fmtDur(stat.dur)} (${stat.status})` : n;
|
|
1492
|
+
// Link nodes to their step in-page; standalone SVGs carry no page links.
|
|
1493
|
+
const linkable = !opts.standalone && !!stat;
|
|
1494
|
+
const open = linkable
|
|
1495
|
+
? `<a href="/runs/${enc(runId)}/steps#step-${enc(n)}" class="${cls.join(" ")}">`
|
|
1496
|
+
: `<g class="${cls.join(" ")}">`;
|
|
1497
|
+
const close = linkable ? "</a>" : "</g>";
|
|
1498
|
+
const labelY = sub ? p.cy - 2 : p.cy + 4;
|
|
1499
|
+
nodeSvg += `${open}
|
|
1500
|
+
<rect x="${x.toFixed(1)}" y="${y.toFixed(1)}" width="${p.w}" height="${NODE_H}" rx="10"/>
|
|
1501
|
+
<text class="gnode-label" x="${p.cx.toFixed(1)}" y="${labelY.toFixed(1)}">${esc(nodeLabel(n))}</text>
|
|
1502
|
+
${sub ? `<text class="gnode-sub" x="${p.cx.toFixed(1)}" y="${(p.cy + 14).toFixed(1)}">${esc(sub)}</text>` : ""}
|
|
1503
|
+
<title>${esc(tip)}</title>
|
|
1504
|
+
${close}`;
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
const defs = `<defs><marker id="gv-arrow" markerWidth="9" markerHeight="9" refX="7.5" refY="3" orient="auto" markerUnits="userSpaceOnUse"><path class="arrow" d="M0,0 L7,3 L0,6 z"/></marker></defs>`;
|
|
1508
|
+
const style = opts.standalone ? graphStyleBlock(opts.theme ?? "light") : "";
|
|
1509
|
+
const ns = opts.standalone ? ` xmlns="http://www.w3.org/2000/svg"` : "";
|
|
1510
|
+
return `<svg class="graph"${ns} viewBox="0 0 ${CW.toFixed(0)} ${CH.toFixed(0)}" width="${CW.toFixed(0)}" preserveAspectRatio="xMinYMin meet" role="img">${style}${defs}${edgeSvg}${nodeSvg}</svg>`;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
/** Embedded stylesheet for a standalone graph SVG (concrete colours, no CSS variables). */
|
|
1514
|
+
function graphStyleBlock(theme: "dark" | "light"): string {
|
|
1515
|
+
const c = theme === "light"
|
|
1516
|
+
? { edge: "#80868b", cond: "#5f6368", nodeFill: "#ffffff", nodeStroke: "#9aa0a6", termFill: "#f1f3f4", entryStroke: "#0b57d0", entryFill: "#e8f0fe", err: "#c5221f", label: "#1f1f1f", sub: "#5f6368", elabel: "#6f7378" }
|
|
1517
|
+
: { edge: "#5b636f", cond: "#9aa0a6", nodeFill: "#2d2f31", nodeStroke: "#454a52", termFill: "#28292b", entryStroke: "#8ab4f8", entryFill: "rgba(138,180,248,.14)", err: "#f28b82", label: "#e3e3e3", sub: "#9aa0a6", elabel: "#9aa0a6" };
|
|
1518
|
+
return `<style>
|
|
1519
|
+
.graph{font-family:system-ui,-apple-system,"Segoe UI",Roboto,Arial,sans-serif;}
|
|
1520
|
+
.graph .edge{fill:none;stroke:${c.edge};stroke-width:1.7;}
|
|
1521
|
+
.graph .edge.cond{stroke-dasharray:5 4;stroke:${c.cond};}
|
|
1522
|
+
.graph .arrow{fill:${c.edge};}
|
|
1523
|
+
.graph .edge-label{fill:${c.elabel};font-size:10px;text-anchor:middle;}
|
|
1524
|
+
.graph .gnode rect{fill:${c.nodeFill};stroke:${c.nodeStroke};stroke-width:1.5;}
|
|
1525
|
+
.graph .gnode.term rect{fill:${c.termFill};stroke:${c.cond};}
|
|
1526
|
+
.graph .gnode.entry rect{stroke:${c.entryStroke};fill:${c.entryFill};}
|
|
1527
|
+
.graph .gnode.interrupt rect{stroke:#d97706;fill:${theme === "light" ? "#fef3c7" : "rgba(217,119,6,.16)"};}
|
|
1528
|
+
.graph .gnode.err rect{stroke:${c.err};}
|
|
1529
|
+
.graph .gnode-label{fill:${c.label};font-size:12px;font-weight:600;text-anchor:middle;}
|
|
1530
|
+
.graph .gnode-sub{fill:${c.sub};font-size:10px;text-anchor:middle;font-family:ui-monospace,Menlo,monospace;}
|
|
1531
|
+
</style>`;
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
/** Textual node/edge listing beneath the graph SVG. */
|
|
1535
|
+
function topologyLists(spec: GraphSpec): string {
|
|
1536
|
+
const nodes = spec.nodes.length === 0
|
|
1537
|
+
? `<li class="muted">(none)</li>`
|
|
1538
|
+
: spec.nodes
|
|
1539
|
+
.map((n) => {
|
|
1540
|
+
const tags = [
|
|
1541
|
+
n.name === spec.entry ? ` <span class="subtle">(entry)</span>` : "",
|
|
1542
|
+
n.interrupt ? ` <span class="subtle">(interrupt)</span>` : "",
|
|
1543
|
+
].join("");
|
|
1544
|
+
return `<li>${esc(n.name)}${tags}</li>`;
|
|
1545
|
+
})
|
|
1546
|
+
.join("");
|
|
1547
|
+
const staticEdges = spec.edges.map((e) => `<li>${esc(nodeLabel(e.from))} → ${esc(nodeLabel(e.to))}</li>`).join("");
|
|
1548
|
+
const condEdges = spec.conditional
|
|
1549
|
+
.map((c) => {
|
|
1550
|
+
if (!c.labels) return `<li>${esc(nodeLabel(c.from))} → <span class="subtle">(router)</span></li>`;
|
|
1551
|
+
const labels = Object.entries(c.labels).map(([l, to]) => `${esc(l)}:${esc(nodeLabel(to))}`).join(", ");
|
|
1552
|
+
return `<li>${esc(nodeLabel(c.from))} → <span class="subtle">{${labels}}</span></li>`;
|
|
1553
|
+
})
|
|
1554
|
+
.join("");
|
|
1555
|
+
const edges = staticEdges + condEdges || `<li class="muted">(none)</li>`;
|
|
1556
|
+
return `<div class="topo">
|
|
1557
|
+
<div><h3>nodes</h3><ul>${nodes}</ul></div>
|
|
1558
|
+
<div><h3>edges</h3><ul>${edges}</ul></div>
|
|
1559
|
+
</div>`;
|
|
1560
|
+
}
|
|
1561
|
+
|
|
1562
|
+
// ── span detail page ─────────────────────────────────────────────────────────────
|
|
1563
|
+
|
|
1564
|
+
function spanDetailPage(store: Store, runId: string, spanId: string): string {
|
|
1565
|
+
const span = findSpan(store, runId, spanId);
|
|
1566
|
+
if (!span) {
|
|
1567
|
+
return shell(
|
|
1568
|
+
"span",
|
|
1569
|
+
`${crumbs([["/", "runs"], [`/runs/${enc(runId)}`, shortId(runId)], [null, "span"]])}<div class="panel"><div class="empty">Span not found.</div></div>`,
|
|
1570
|
+
);
|
|
1571
|
+
}
|
|
1572
|
+
const dur = spanDuration(span);
|
|
1573
|
+
const kind = kindOf(span.name);
|
|
1574
|
+
const status = span.statusCode === "error" ? "error" : span.statusCode === "ok" ? "ok" : "unset";
|
|
1575
|
+
|
|
1576
|
+
const head = `<div class="page-head">
|
|
1577
|
+
<div><h1><span class="dot ${kind}"></span> ${esc(shortName(span.name))}</h1><div class="id">${esc(span.spanId)}</div></div>
|
|
1578
|
+
</div>`;
|
|
1579
|
+
|
|
1580
|
+
const cell = (k: string, v: string) => `<div class="cell"><div class="k">${k}</div><div class="v">${v}</div></div>`;
|
|
1581
|
+
const parentLink = span.parentSpanId
|
|
1582
|
+
? `<a href="/runs/${enc(runId)}/spans/${enc(span.parentSpanId)}">${esc(shortId(span.parentSpanId))}</a>`
|
|
1583
|
+
: `<span class="subtle">(root)</span>`;
|
|
1584
|
+
const meta = `<div class="meta">
|
|
1585
|
+
${cell("status", status === "unset" ? `<span class="subtle">unset</span>` : `<span class="badge ${status}">${status}</span>`)}
|
|
1586
|
+
${cell("duration", fmtDur(dur))}
|
|
1587
|
+
${cell("started", esc(new Date(nanosToMs(span.startTimeUnixNano)).toLocaleString()))}
|
|
1588
|
+
${cell("parent", parentLink)}
|
|
1589
|
+
${cell("trace", esc(shortId(span.traceId)))}
|
|
1590
|
+
${span.statusMessage ? cell("message", esc(span.statusMessage)) : ""}
|
|
1591
|
+
</div>`;
|
|
1592
|
+
|
|
1593
|
+
const modelPanel = modelCallPanel(span);
|
|
1594
|
+
const messages = messagesPanel(span);
|
|
1595
|
+
const toolPanel = toolIOPanel(span);
|
|
1596
|
+
const attrs = attributesPanel(span);
|
|
1597
|
+
const events = eventsPanel(span);
|
|
1598
|
+
|
|
1599
|
+
return shell(
|
|
1600
|
+
span.name,
|
|
1601
|
+
`${crumbs([["/", "runs"], [`/runs/${enc(runId)}`, shortId(runId)], [null, "span"]])}${head}
|
|
1602
|
+
<div class="panel"><div class="panel-body">${meta}</div></div>${modelPanel}${messages}${toolPanel}${attrs}${events}`,
|
|
1603
|
+
);
|
|
1604
|
+
}
|
|
1605
|
+
|
|
1606
|
+
/** Provider/model/token summary for a provider span (empty for other spans). */
|
|
1607
|
+
function modelCallPanel(span: Span): string {
|
|
1608
|
+
const a = span.attributes;
|
|
1609
|
+
const provider = attrStr(a, A.provider) ?? attrStr(a, A.system);
|
|
1610
|
+
const reqModel = attrStr(a, A.reqModel);
|
|
1611
|
+
const respModel = attrStr(a, A.respModel);
|
|
1612
|
+
if (!provider && !reqModel && !respModel) return "";
|
|
1613
|
+
const inT = attrNum(a, A.inTokens);
|
|
1614
|
+
const outT = attrNum(a, A.outTokens);
|
|
1615
|
+
const finish = attrStr(a, A.finish);
|
|
1616
|
+
const streaming = a[A.streaming];
|
|
1617
|
+
const cell = (k: string, v: string | undefined): string =>
|
|
1618
|
+
v !== undefined && v !== "" ? `<div class="cell"><div class="k">${k}</div><div class="v">${v}</div></div>` : "";
|
|
1619
|
+
const total = inT !== undefined || outT !== undefined ? String((inT ?? 0) + (outT ?? 0)) : undefined;
|
|
1620
|
+
return `<div class="panel"><h2>model call</h2><div class="panel-body"><div class="meta">
|
|
1621
|
+
${cell("provider", provider ? esc(provider) : undefined)}
|
|
1622
|
+
${cell("request model", reqModel ? esc(reqModel) : undefined)}
|
|
1623
|
+
${cell("response model", respModel ? esc(respModel) : undefined)}
|
|
1624
|
+
${cell("input tokens", inT !== undefined ? String(inT) : undefined)}
|
|
1625
|
+
${cell("output tokens", outT !== undefined ? String(outT) : undefined)}
|
|
1626
|
+
${cell("total tokens", total)}
|
|
1627
|
+
${cell("finish reason", finish ? esc(finish) : undefined)}
|
|
1628
|
+
${cell("streaming", typeof streaming === "boolean" ? String(streaming) : undefined)}
|
|
1629
|
+
</div></div></div>`;
|
|
1630
|
+
}
|
|
1631
|
+
|
|
1632
|
+
/** Parse `gen_ai.prompt` / `gen_ai.completion` into message arrays. */
|
|
1633
|
+
function parseMessages(raw: string | undefined): Message[] {
|
|
1634
|
+
if (!raw) return [];
|
|
1635
|
+
try {
|
|
1636
|
+
const v = JSON.parse(raw);
|
|
1637
|
+
return Array.isArray(v) ? (v as Message[]) : [v as Message];
|
|
1638
|
+
} catch {
|
|
1639
|
+
return [];
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
|
|
1643
|
+
/** Render a single captured message (text, reasoning, tool calls). */
|
|
1644
|
+
function renderMessage(m: Message): string {
|
|
1645
|
+
const role = String((m as { role?: string }).role ?? "?");
|
|
1646
|
+
let body = "";
|
|
1647
|
+
for (const p of m.content ?? []) {
|
|
1648
|
+
if (p.type === "text" && p.text) body += `<pre class="text">${esc(p.text)}</pre>`;
|
|
1649
|
+
else if ((p.type === "thinking" || p.type === "redacted_thinking") && p.text)
|
|
1650
|
+
body += `<details class="reason"><summary>reasoning</summary><pre>${esc(p.text)}</pre></details>`;
|
|
1651
|
+
else if (p.type === "image") body += `<div class="muted">[image]</div>`;
|
|
1652
|
+
}
|
|
1653
|
+
// Tool calls may arrive as `toolCalls` or the snake_case `tool_calls`.
|
|
1654
|
+
const toolCalls = m.toolCalls ?? (m as { tool_calls?: Message["toolCalls"] }).tool_calls ?? [];
|
|
1655
|
+
for (const tc of toolCalls) {
|
|
1656
|
+
let args = "";
|
|
1657
|
+
try {
|
|
1658
|
+
args = JSON.stringify(tc.arguments);
|
|
1659
|
+
} catch {
|
|
1660
|
+
args = String(tc.arguments);
|
|
1661
|
+
}
|
|
1662
|
+
body += `<div class="toolcall">→ <span class="tname">${esc(tc.name)}</span>(<code>${esc(truncate(args, 400))}</code>)</div>`;
|
|
1663
|
+
}
|
|
1664
|
+
if (!body) body = `<div class="muted">(empty)</div>`;
|
|
1665
|
+
return `<div class="msg ${esc(role)}"><div class="msg-role">${esc(role)}</div><div class="msg-body">${body}</div></div>`;
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
/** Side-by-side prompt/completion panel when content was captured. */
|
|
1669
|
+
function messagesPanel(span: Span): string {
|
|
1670
|
+
const prompt = parseMessages(attrStr(span.attributes, A.prompt));
|
|
1671
|
+
const completion = parseMessages(attrStr(span.attributes, A.completion));
|
|
1672
|
+
const reasoning = parseReasoning(attrStr(span.attributes, A.reasoning));
|
|
1673
|
+
if (prompt.length === 0 && completion.length === 0 && reasoning.length === 0) return "";
|
|
1674
|
+
|
|
1675
|
+
const left = prompt.length
|
|
1676
|
+
? prompt.map(renderMessage).join("")
|
|
1677
|
+
: `<div class="muted">(no prompt captured)</div>`;
|
|
1678
|
+
let right = completion.length ? completion.map(renderMessage).join("") : "";
|
|
1679
|
+
if (reasoning.length) {
|
|
1680
|
+
right += `<details class="reason" open><summary>reasoning (${reasoning.length})</summary>${reasoning.map((r) => `<pre>${esc(r)}</pre>`).join("")}</details>`;
|
|
1681
|
+
}
|
|
1682
|
+
if (!right) right = `<div class="muted">(no completion captured)</div>`;
|
|
1683
|
+
|
|
1684
|
+
return `<div class="panel">
|
|
1685
|
+
<h2>messages</h2>
|
|
1686
|
+
<div class="panel-body"><div class="msgs">
|
|
1687
|
+
<div class="msgcol"><h3>prompt → API</h3>${left}</div>
|
|
1688
|
+
<div class="msgcol"><h3>completion ← API</h3>${right}</div>
|
|
1689
|
+
</div></div>
|
|
1690
|
+
</div>`;
|
|
1691
|
+
}
|
|
1692
|
+
|
|
1693
|
+
function parseReasoning(raw: string | undefined): string[] {
|
|
1694
|
+
if (!raw) return [];
|
|
1695
|
+
try {
|
|
1696
|
+
const v = JSON.parse(raw);
|
|
1697
|
+
return Array.isArray(v) ? v.map(String) : [String(v)];
|
|
1698
|
+
} catch {
|
|
1699
|
+
return [raw];
|
|
1700
|
+
}
|
|
1701
|
+
}
|
|
1702
|
+
|
|
1703
|
+
/** Tool-call summary (name + input/output sizes) for tool spans. */
|
|
1704
|
+
function toolIOPanel(span: Span): string {
|
|
1705
|
+
const name = attrStr(span.attributes, A.toolName);
|
|
1706
|
+
if (!name) return "";
|
|
1707
|
+
const inB = attrNum(span.attributes, A.toolIn);
|
|
1708
|
+
const outB = attrNum(span.attributes, A.toolOut);
|
|
1709
|
+
return `<div class="panel">
|
|
1710
|
+
<h2>tool call</h2>
|
|
1711
|
+
<div class="panel-body"><div class="meta">
|
|
1712
|
+
<div class="cell"><div class="k">tool</div><div class="v">${esc(name)}</div></div>
|
|
1713
|
+
<div class="cell"><div class="k">input size</div><div class="v">${inB ?? "—"} bytes</div></div>
|
|
1714
|
+
<div class="cell"><div class="k">output size</div><div class="v">${outB ?? "—"} bytes</div></div>
|
|
1715
|
+
</div></div>
|
|
1716
|
+
</div>`;
|
|
1717
|
+
}
|
|
1718
|
+
|
|
1719
|
+
/** Full attribute table (content attributes are shown in their own panels). */
|
|
1720
|
+
function attributesPanel(span: Span): string {
|
|
1721
|
+
const hidden = new Set<string>([A.prompt, A.completion, A.reasoning]);
|
|
1722
|
+
const entries = Object.entries(span.attributes).filter(([k]) => !hidden.has(k)).sort(([a], [b]) => a.localeCompare(b));
|
|
1723
|
+
if (entries.length === 0) return "";
|
|
1724
|
+
const rows = entries
|
|
1725
|
+
.map(([k, v]) => `<tr><td class="mono">${esc(k)}</td><td class="mono">${esc(truncate(fmtVal(v), 600))}</td></tr>`)
|
|
1726
|
+
.join("");
|
|
1727
|
+
return `<div class="panel panel-scroll">
|
|
1728
|
+
<h2>attributes <span class="count">${entries.length}</span></h2>
|
|
1729
|
+
<table><tbody>${rows}</tbody></table>
|
|
1730
|
+
</div>`;
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
function eventsPanel(span: Span): string {
|
|
1734
|
+
if (span.events.length === 0) return "";
|
|
1735
|
+
const rows = span.events
|
|
1736
|
+
.map((e) => `<tr><td class="mono">${esc(e.name)}</td><td class="muted">${esc(new Date(nanosToMs(e.timeUnixNano)).toISOString())}</td></tr>`)
|
|
1737
|
+
.join("");
|
|
1738
|
+
return `<div class="panel panel-scroll"><h2>events <span class="count">${span.events.length}</span></h2><table><tbody>${rows}</tbody></table></div>`;
|
|
1739
|
+
}
|
|
1740
|
+
|
|
1741
|
+
// ── steps page ───────────────────────────────────────────────────────────────────
|
|
1742
|
+
|
|
1743
|
+
function stepsPage(store: Store, runId: string): string {
|
|
1744
|
+
const spans = store.spansForRun(runId);
|
|
1745
|
+
if (spans.length === 0) {
|
|
1746
|
+
return shell(
|
|
1747
|
+
runId,
|
|
1748
|
+
`${crumbs([["/", "runs"], [`/runs/${enc(runId)}`, shortId(runId)], [null, "steps"]])}<div class="panel"><div class="empty">No spans for this run.</div></div>`,
|
|
1749
|
+
);
|
|
1750
|
+
}
|
|
1751
|
+
const byParent = new Map<string, Span[]>();
|
|
1752
|
+
for (const s of spans) {
|
|
1753
|
+
const arr = byParent.get(s.parentSpanId) ?? [];
|
|
1754
|
+
arr.push(s);
|
|
1755
|
+
byParent.set(s.parentSpanId, arr);
|
|
1756
|
+
}
|
|
1757
|
+
const cmp = (a: Span, b: Span): number =>
|
|
1758
|
+
a.startTimeUnixNano < b.startTimeUnixNano ? -1 : a.startTimeUnixNano > b.startTimeUnixNano ? 1 : 0;
|
|
1759
|
+
|
|
1760
|
+
const nodeSpans = spans.filter((s) => s.name === SPAN.node).sort(cmp);
|
|
1761
|
+
const anyCaptured = spans.some((s) => s.name === SPAN.generate || s.name === SPAN.stream
|
|
1762
|
+
? attrStr(s.attributes, A.prompt) !== undefined
|
|
1763
|
+
: false);
|
|
1764
|
+
|
|
1765
|
+
const replayHint = anyCaptured
|
|
1766
|
+
? ""
|
|
1767
|
+
: `<div class="banner">No prompts/completions were captured for this run. Re-run with content capture enabled — <code>instrumentProvider(provider, tracer, { captureContent: true })</code> — to see what is sent to and received from the API here.</div>`;
|
|
1768
|
+
|
|
1769
|
+
const head = `<div class="page-head">
|
|
1770
|
+
<div><h1>Steps</h1><div class="id">${esc(runId)}</div></div>
|
|
1771
|
+
<div class="actions"><a class="btn" href="/runs/${enc(runId)}">← run detail</a></div>
|
|
1772
|
+
</div>`;
|
|
1773
|
+
|
|
1774
|
+
let cards = "";
|
|
1775
|
+
if (nodeSpans.length === 0) {
|
|
1776
|
+
// No graph-node spans: fall back to listing model/tool calls flatly.
|
|
1777
|
+
const calls = spans.filter((s) => s.name !== SPAN.run).sort(cmp);
|
|
1778
|
+
cards = `<div class="step"><div class="step-body">${calls.map((s) => callCard(runId, s)).join("") || `<div class="empty">No calls recorded.</div>`}</div></div>`;
|
|
1779
|
+
} else {
|
|
1780
|
+
cards = nodeSpans
|
|
1781
|
+
.map((node, i) => {
|
|
1782
|
+
const nodeName = attrStr(node.attributes, A.node) ?? attrStr(node.attributes, A.label) ?? `step ${i + 1}`;
|
|
1783
|
+
const children = (byParent.get(node.spanId) ?? []).sort(cmp);
|
|
1784
|
+
const status = node.statusCode === "error" ? "error" : "ok";
|
|
1785
|
+
const inner = children.length
|
|
1786
|
+
? children.map((c) => callCard(runId, c)).join("")
|
|
1787
|
+
: `<div class="hint">No provider or tool calls in this step.</div>`;
|
|
1788
|
+
return `<div class="step" id="step-${esc(nodeName)}">
|
|
1789
|
+
<div class="step-head"><span class="n">${i + 1}</span><span class="nm">${esc(nodeName)}</span>
|
|
1790
|
+
<span class="badge ${status}">${status}</span>
|
|
1791
|
+
<span class="meta-inline">${fmtDur(spanDuration(node))}</span></div>
|
|
1792
|
+
<div class="step-body">${inner}</div>
|
|
1793
|
+
</div>`;
|
|
1794
|
+
})
|
|
1795
|
+
.join("");
|
|
1796
|
+
}
|
|
1797
|
+
|
|
1798
|
+
return shell(
|
|
1799
|
+
runId,
|
|
1800
|
+
`${crumbs([["/", "runs"], [`/runs/${enc(runId)}`, shortId(runId)], [null, "steps"]])}${replayHint}${head}${cards}`,
|
|
1801
|
+
);
|
|
1802
|
+
}
|
|
1803
|
+
|
|
1804
|
+
/** Render a provider or tool call inside a step card. */
|
|
1805
|
+
function callCard(runId: string, span: Span): string {
|
|
1806
|
+
const link = `/runs/${enc(runId)}/spans/${enc(span.spanId)}`;
|
|
1807
|
+
if (span.name === SPAN.generate || span.name === SPAN.stream) {
|
|
1808
|
+
const model = attrStr(span.attributes, A.respModel) ?? attrStr(span.attributes, A.reqModel) ?? "model";
|
|
1809
|
+
const provider = attrStr(span.attributes, A.provider) ?? attrStr(span.attributes, A.system);
|
|
1810
|
+
const finish = attrStr(span.attributes, A.finish);
|
|
1811
|
+
const inTok = attrNum(span.attributes, A.inTokens);
|
|
1812
|
+
const outTok = attrNum(span.attributes, A.outTokens);
|
|
1813
|
+
const tokens = inTok !== undefined || outTok !== undefined ? ` · in ${inTok ?? "?"} / out ${outTok ?? "?"} tok` : "";
|
|
1814
|
+
const tail = `${tokens}${finish ? ` · ${esc(finish)}` : ""}`;
|
|
1815
|
+
const provLabel = provider ? `<span class="subtle">${esc(provider)}</span> ` : "";
|
|
1816
|
+
const prompt = parseMessages(attrStr(span.attributes, A.prompt));
|
|
1817
|
+
const completion = parseMessages(attrStr(span.attributes, A.completion));
|
|
1818
|
+
const reasoning = parseReasoning(attrStr(span.attributes, A.reasoning));
|
|
1819
|
+
let content = "";
|
|
1820
|
+
if (prompt.length || completion.length || reasoning.length) {
|
|
1821
|
+
const left = prompt.length ? prompt.map(renderMessage).join("") : `<div class="muted">(no prompt)</div>`;
|
|
1822
|
+
let right = completion.map(renderMessage).join("");
|
|
1823
|
+
if (reasoning.length) right += `<details class="reason"><summary>reasoning</summary>${reasoning.map((r) => `<pre>${esc(r)}</pre>`).join("")}</details>`;
|
|
1824
|
+
if (!right) right = `<div class="muted">(no completion)</div>`;
|
|
1825
|
+
content = `<div class="msgs"><div class="msgcol"><h3>prompt → API</h3>${left}</div><div class="msgcol"><h3>completion ← API</h3>${right}</div></div>`;
|
|
1826
|
+
} else {
|
|
1827
|
+
content = `<div class="hint">No captured content. <a href="${link}">span details →</a></div>`;
|
|
1828
|
+
}
|
|
1829
|
+
return `<div class="call">
|
|
1830
|
+
<div class="call-head"><span class="kind model">model call</span>${provLabel}<a href="${link}">${esc(model)}</a><span class="meta-inline">${fmtDur(spanDuration(span))}${tail}</span></div>
|
|
1831
|
+
${content}
|
|
1832
|
+
</div>`;
|
|
1833
|
+
}
|
|
1834
|
+
if (span.name === SPAN.tool) {
|
|
1835
|
+
const name = attrStr(span.attributes, A.toolName) ?? shortName(span.name);
|
|
1836
|
+
const inB = attrNum(span.attributes, A.toolIn);
|
|
1837
|
+
const outB = attrNum(span.attributes, A.toolOut);
|
|
1838
|
+
const sizes = `in ${inB ?? "?"}B / out ${outB ?? "?"}B`;
|
|
1839
|
+
const err = span.statusCode === "error" ? ` <span class="badge err">error</span>` : "";
|
|
1840
|
+
return `<div class="call">
|
|
1841
|
+
<div class="call-head"><span class="kind tool">tool</span><a href="${link}">${esc(name)}</a>${err}<span class="meta-inline">${fmtDur(spanDuration(span))} · ${sizes}</span></div>
|
|
1842
|
+
</div>`;
|
|
1843
|
+
}
|
|
1844
|
+
// Any other nested span: a compact line.
|
|
1845
|
+
return `<div class="call"><div class="call-head"><span class="kind tool">${esc(shortName(span.name))}</span><a href="${link}">details</a><span class="meta-inline">${fmtDur(spanDuration(span))}</span></div></div>`;
|
|
1846
|
+
}
|
|
1847
|
+
|
|
1848
|
+
// ── shared bits ──────────────────────────────────────────────────────────────────
|
|
1849
|
+
|
|
1850
|
+
/** Render a breadcrumb trail; a `null` href makes the segment plain text. */
|
|
1851
|
+
function crumbs(items: Array<[string | null, string]>): string {
|
|
1852
|
+
const parts = items.map(([href, label]) =>
|
|
1853
|
+
href ? `<a href="${href}">${esc(label)}</a>` : `<span>${esc(label)}</span>`,
|
|
1854
|
+
);
|
|
1855
|
+
return `<div class="crumbs">${parts.join(`<span class="sep">/</span>`)}</div>`;
|
|
1856
|
+
}
|