@klars/agentobs 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,502 @@
1
+ /**
2
+ * Dashboard client.
3
+ *
4
+ * Plain ES modules, no build step and no framework - the page is a handful of
5
+ * tables and one chart, and keeping it dependency-free means the dashboard
6
+ * ships inside the npm package with nothing to compile.
7
+ */
8
+
9
+ const state = {
10
+ range: '7d',
11
+ status: '',
12
+ timeline: [],
13
+ };
14
+
15
+ const RANGE_LABEL = {
16
+ today: 'today',
17
+ '7d': 'this week',
18
+ '30d': 'this month',
19
+ all: 'all time',
20
+ };
21
+
22
+ /* ---------- formatting ---------- */
23
+
24
+ /**
25
+ * Money formatter.
26
+ *
27
+ * `null` renders as an em dash, never as $0.00: a missing price for a model
28
+ * is not the same fact as a call that cost nothing, and conflating them is
29
+ * exactly the fabrication this product promises not to do.
30
+ */
31
+ function money(value) {
32
+ if (value === null || value === undefined) return '—';
33
+ if (value === 0) return '$0.00';
34
+ if (value < 0.01) return `$${value.toFixed(4)}`;
35
+ return `$${value.toFixed(2)}`;
36
+ }
37
+
38
+ function count(value) {
39
+ if (value === null || value === undefined) return '—';
40
+ return new Intl.NumberFormat().format(value);
41
+ }
42
+
43
+ function ms(value) {
44
+ if (value === null || value === undefined) return '—';
45
+ if (value < 1000) return `${Math.round(value)}`;
46
+ return `${(value / 1000).toFixed(1)}s`;
47
+ }
48
+
49
+ function percent(value) {
50
+ if (value === null || value === undefined) return '—';
51
+ return `${(value * 100).toFixed(1)}%`;
52
+ }
53
+
54
+ function relativeTime(iso) {
55
+ const then = Date.parse(iso);
56
+ if (Number.isNaN(then)) return iso ?? '—';
57
+ const secs = Math.round((Date.now() - then) / 1000);
58
+ if (secs < 60) return `${secs}s ago`;
59
+ if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
60
+ if (secs < 86400) return `${Math.round(secs / 3600)}h ago`;
61
+ return new Date(then).toLocaleDateString();
62
+ }
63
+
64
+ /** Always build DOM via textContent - tool inputs are untrusted strings. */
65
+ function cell(text, className) {
66
+ const td = document.createElement('td');
67
+ td.textContent = text;
68
+ if (className) td.className = className;
69
+ return td;
70
+ }
71
+
72
+ function statusPill(status) {
73
+ const span = document.createElement('span');
74
+ const known = ['success', 'error', 'blocked', 'pending'].includes(status);
75
+ span.className = `pill pill-${known ? status : 'pending'}`;
76
+ span.textContent = status;
77
+ return span;
78
+ }
79
+
80
+ /* ---------- data ---------- */
81
+
82
+ async function fetchJson(path) {
83
+ const url = new URL(path, window.location.origin);
84
+ url.searchParams.set('range', state.range);
85
+ const token = new URLSearchParams(window.location.search).get('token');
86
+ if (token) url.searchParams.set('token', token);
87
+ const res = await fetch(url, { headers: { Accept: 'application/json' } });
88
+ if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
89
+ return res.json();
90
+ }
91
+
92
+ function setConnection(ok, message) {
93
+ const el = document.getElementById('conn-state');
94
+ el.className = `conn ${ok ? 'is-live' : 'is-down'}`;
95
+ el.textContent = message;
96
+ }
97
+
98
+ async function refresh() {
99
+ try {
100
+ const [summary, timeline, tools, calls, sessions] = await Promise.all([
101
+ fetchJson('/api/summary'),
102
+ fetchJson('/api/timeline'),
103
+ fetchJson('/api/tools-breakdown'),
104
+ fetchJson(`/api/tool-calls${state.status ? `?status=${state.status}` : ''}`),
105
+ fetchJson('/api/sessions'),
106
+ ]);
107
+
108
+ renderSummary(summary);
109
+ state.timeline = timeline;
110
+ drawTimeline();
111
+ renderTimelineTable(timeline);
112
+ renderTools(tools);
113
+ renderActivity(calls.calls ?? []);
114
+ renderSessions(sessions.sessions ?? []);
115
+ setConnection(true, `Live · updated ${new Date().toLocaleTimeString()}`);
116
+ } catch (err) {
117
+ setConnection(false, `Disconnected: ${err.message}`);
118
+ }
119
+ }
120
+
121
+ /* ---------- render ---------- */
122
+
123
+ function renderSummary(s) {
124
+ document.getElementById('hero-cost').textContent = money(s.total_cost_usd);
125
+ document.getElementById('hero-tokens-in').textContent = count(s.tokens_in);
126
+ document.getElementById('hero-tokens-out').textContent = count(s.tokens_out);
127
+ document.getElementById('hero-duration').textContent = ms(s.avg_duration_ms);
128
+
129
+ // Say plainly when the cost figure is incomplete, rather than presenting a
130
+ // partial total as if it were the whole spend.
131
+ const note = document.getElementById('hero-note');
132
+ if (s.tool_calls === 0) {
133
+ note.textContent = 'No activity recorded yet.';
134
+ } else if (s.uncosted_calls > 0) {
135
+ note.textContent = `${count(s.uncosted_calls)} call${s.uncosted_calls === 1 ? '' : 's'} have no price for their model — add it to ~/.agentobs/pricing.json to include them.`;
136
+ } else {
137
+ note.textContent = `Across ${count(s.tool_calls)} tool calls in ${count(s.sessions)} session${s.sessions === 1 ? '' : 's'}.`;
138
+ }
139
+
140
+ document.getElementById('stat-calls').textContent = count(s.tool_calls);
141
+ document.getElementById('stat-calls-sub').textContent = `${count(s.tokens_in + s.tokens_out)} tokens`;
142
+ document.getElementById('stat-sessions').textContent = count(s.sessions);
143
+ document.getElementById('stat-sessions-sub').textContent = RANGE_LABEL[s.range] ?? '';
144
+ document.getElementById('stat-errors').textContent = percent(s.error_rate);
145
+ document.getElementById('stat-errors-sub').textContent = `${count(s.errors)} failed`;
146
+ document.getElementById('stat-blocked').textContent = count(s.blocked);
147
+
148
+ for (const el of document.querySelectorAll('[data-range-label]')) {
149
+ el.textContent = RANGE_LABEL[state.range] ?? '';
150
+ }
151
+ }
152
+
153
+ function renderTimelineTable(rows) {
154
+ const body = document.getElementById('timeline-table-body');
155
+ body.replaceChildren();
156
+ for (const row of rows) {
157
+ const tr = document.createElement('tr');
158
+ tr.append(
159
+ cell(row.bucket),
160
+ cell(count(row.calls), 'num'),
161
+ cell(count(row.errors), 'num'),
162
+ cell(money(row.cost_usd), 'num'),
163
+ );
164
+ body.append(tr);
165
+ }
166
+ }
167
+
168
+ function renderTools(rows) {
169
+ const body = document.getElementById('tools-body');
170
+ body.replaceChildren();
171
+ if (rows.length === 0) {
172
+ const tr = document.createElement('tr');
173
+ tr.append(Object.assign(cell('No tool calls recorded yet.', 'empty'), { colSpan: 5 }));
174
+ body.append(tr);
175
+ return;
176
+ }
177
+ for (const row of rows) {
178
+ const tr = document.createElement('tr');
179
+ const name = cell('');
180
+ name.append(document.createTextNode(row.tool_name));
181
+ if (row.blocked > 0) {
182
+ const badge = document.createElement('span');
183
+ badge.className = 'badge';
184
+ badge.textContent = `${row.blocked} blocked`;
185
+ name.append(badge);
186
+ }
187
+ tr.append(
188
+ name,
189
+ cell(count(row.calls), 'num'),
190
+ cell(count(row.errors), 'num'),
191
+ cell(ms(row.avg_duration_ms), 'num'),
192
+ cell(money(row.cost_usd), 'num'),
193
+ );
194
+ body.append(tr);
195
+ }
196
+ }
197
+
198
+ function renderSessions(rows) {
199
+ const body = document.getElementById('sessions-body');
200
+ body.replaceChildren();
201
+ if (rows.length === 0) {
202
+ const tr = document.createElement('tr');
203
+ tr.append(Object.assign(cell('No sessions yet.', 'empty'), { colSpan: 4 }));
204
+ body.append(tr);
205
+ return;
206
+ }
207
+ for (const row of rows) {
208
+ const tr = document.createElement('tr');
209
+ const agent = cell('');
210
+ agent.append(document.createTextNode(row.agent_name));
211
+ // Coarse sessions know only duration and exit code. Labelling them keeps
212
+ // the UI from implying per-tool-call detail it does not have.
213
+ if (row.fidelity === 'coarse') {
214
+ const badge = document.createElement('span');
215
+ badge.className = 'badge';
216
+ badge.textContent = 'coarse';
217
+ badge.title = 'Process-wrapped: duration and exit code only, no per-tool-call detail.';
218
+ agent.append(badge);
219
+ }
220
+ tr.append(
221
+ agent,
222
+ cell(relativeTime(row.started_at)),
223
+ cell(row.fidelity === 'coarse' ? '—' : count(row.tool_call_count), 'num'),
224
+ cell(money(row.total_cost_usd), 'num'),
225
+ );
226
+ body.append(tr);
227
+ }
228
+ }
229
+
230
+ function renderActivity(rows) {
231
+ const body = document.getElementById('activity-body');
232
+ body.replaceChildren();
233
+ if (rows.length === 0) {
234
+ const tr = document.createElement('tr');
235
+ tr.append(
236
+ Object.assign(cell('Nothing yet. Run an agent to see activity here.', 'empty'), {
237
+ colSpan: 6,
238
+ }),
239
+ );
240
+ body.append(tr);
241
+ return;
242
+ }
243
+ for (const row of rows) {
244
+ const tr = document.createElement('tr');
245
+
246
+ const status = document.createElement('td');
247
+ status.append(statusPill(row.status));
248
+ if (row.rule_matched) {
249
+ const badge = document.createElement('span');
250
+ badge.className = 'badge';
251
+ badge.textContent = row.rule_matched;
252
+ badge.title = 'Policy rule that produced this decision';
253
+ status.append(badge);
254
+ }
255
+
256
+ const input = document.createElement('td');
257
+ const code = document.createElement('code');
258
+ code.className = 'mono truncate';
259
+ code.textContent = row.input_summary || '—';
260
+ code.title = row.error_message || row.output_summary || row.input_summary || '';
261
+ input.append(code);
262
+
263
+ tr.append(
264
+ status,
265
+ cell(row.tool_name),
266
+ input,
267
+ cell(relativeTime(row.started_at)),
268
+ cell(ms(row.duration_ms), 'num'),
269
+ cell(money(row.cost_usd), 'num'),
270
+ );
271
+ body.append(tr);
272
+ }
273
+ }
274
+
275
+ /* ---------- chart ---------- */
276
+
277
+ const canvas = document.getElementById('timeline');
278
+ const tip = document.getElementById('timeline-tip');
279
+ let bars = [];
280
+
281
+ function cssVar(name) {
282
+ return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
283
+ }
284
+
285
+ /**
286
+ * Grouped bar chart on a plain canvas.
287
+ *
288
+ * One y-scale only - calls and errors are both counts, so they share it. A
289
+ * second axis for cost would be a dual-axis chart, which misleads by making
290
+ * two unrelated scales look comparable.
291
+ */
292
+ function drawTimeline() {
293
+ const rows = state.timeline;
294
+ const dpr = window.devicePixelRatio || 1;
295
+ const cssWidth = canvas.clientWidth || canvas.parentElement.clientWidth;
296
+ const cssHeight = 220;
297
+
298
+ canvas.width = cssWidth * dpr;
299
+ canvas.height = cssHeight * dpr;
300
+ canvas.style.height = `${cssHeight}px`;
301
+
302
+ const ctx = canvas.getContext('2d');
303
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
304
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
305
+
306
+ const pad = { top: 12, right: 8, bottom: 26, left: 40 };
307
+ const plotW = cssWidth - pad.left - pad.right;
308
+ const plotH = cssHeight - pad.top - pad.bottom;
309
+ bars = [];
310
+
311
+ const muted = cssVar('--text-muted');
312
+ const gridColor = cssVar('--grid');
313
+ const axisColor = cssVar('--axis');
314
+
315
+ if (rows.length === 0) {
316
+ ctx.fillStyle = muted;
317
+ ctx.font = '13px ' + cssVar('--font');
318
+ ctx.textAlign = 'center';
319
+ ctx.fillText('No activity in this range', cssWidth / 2, cssHeight / 2);
320
+ return;
321
+ }
322
+
323
+ const maxCalls = Math.max(1, ...rows.map((r) => r.calls));
324
+ const ticks = niceTicks(maxCalls, 4);
325
+ const top = ticks[ticks.length - 1];
326
+ const y = (v) => pad.top + plotH - (v / top) * plotH;
327
+
328
+ // Recessive gridlines, drawn under the data.
329
+ ctx.strokeStyle = gridColor;
330
+ ctx.lineWidth = 1;
331
+ ctx.fillStyle = muted;
332
+ ctx.font = '11px ' + cssVar('--font');
333
+ ctx.textAlign = 'right';
334
+ ctx.textBaseline = 'middle';
335
+ for (const t of ticks) {
336
+ const yy = Math.round(y(t)) + 0.5;
337
+ ctx.beginPath();
338
+ ctx.moveTo(pad.left, yy);
339
+ ctx.lineTo(pad.left + plotW, yy);
340
+ ctx.stroke();
341
+ ctx.fillText(String(t), pad.left - 8, yy);
342
+ }
343
+
344
+ const slot = plotW / rows.length;
345
+ // Cap the width so a 7-bucket week doesn't render as slabs, but keep bars
346
+ // substantial enough to read as data rather than hairlines.
347
+ const barW = Math.max(4, Math.min(46, slot * 0.62));
348
+ const errW = Math.max(3, barW * 0.4);
349
+ const radius = 4;
350
+
351
+ rows.forEach((row, i) => {
352
+ const cx = pad.left + slot * i + slot / 2;
353
+ const x = cx - barW / 2;
354
+ const h = Math.max(row.calls > 0 ? 2 : 0, plotH - (y(row.calls) - pad.top));
355
+
356
+ if (h > 0) {
357
+ ctx.fillStyle = cssVar('--series-1');
358
+ roundedTop(ctx, x, y(row.calls), barW, h, radius);
359
+ ctx.fill();
360
+ }
361
+
362
+ // Errors ride in front, inset, in the reserved critical status color -
363
+ // never a categorical slot, so a status never impersonates a series.
364
+ if (row.errors > 0) {
365
+ const eh = Math.max(2, plotH - (y(row.errors) - pad.top));
366
+ ctx.fillStyle = cssVar('--surface');
367
+ roundedTop(ctx, cx - errW / 2 - 1, y(row.errors) - 1, errW + 2, eh + 1, radius);
368
+ ctx.fill();
369
+ ctx.fillStyle = cssVar('--status-critical');
370
+ roundedTop(ctx, cx - errW / 2, y(row.errors), errW, eh, radius);
371
+ ctx.fill();
372
+ }
373
+
374
+ bars.push({ x: pad.left + slot * i, w: slot, row });
375
+ });
376
+
377
+ // Baseline.
378
+ ctx.strokeStyle = axisColor;
379
+ ctx.beginPath();
380
+ ctx.moveTo(pad.left, pad.top + plotH + 0.5);
381
+ ctx.lineTo(pad.left + plotW, pad.top + plotH + 0.5);
382
+ ctx.stroke();
383
+
384
+ // Thin out x labels so they never collide.
385
+ ctx.fillStyle = muted;
386
+ ctx.textAlign = 'center';
387
+ ctx.textBaseline = 'top';
388
+ const step = Math.max(1, Math.ceil(rows.length / Math.floor(plotW / 70)));
389
+ rows.forEach((row, i) => {
390
+ if (i % step !== 0 && i !== rows.length - 1) return;
391
+ ctx.fillText(shortLabel(row.bucket), pad.left + slot * i + slot / 2, pad.top + plotH + 8);
392
+ });
393
+ }
394
+
395
+ function roundedTop(ctx, x, y, w, h, r) {
396
+ const rr = Math.min(r, w / 2, h);
397
+ ctx.beginPath();
398
+ ctx.moveTo(x, y + h);
399
+ ctx.lineTo(x, y + rr);
400
+ ctx.quadraticCurveTo(x, y, x + rr, y);
401
+ ctx.lineTo(x + w - rr, y);
402
+ ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
403
+ ctx.lineTo(x + w, y + h);
404
+ ctx.closePath();
405
+ }
406
+
407
+ function niceTicks(max, target) {
408
+ const raw = max / target;
409
+ const mag = 10 ** Math.floor(Math.log10(raw));
410
+ const norm = raw / mag;
411
+ const stepMult = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
412
+ const step = stepMult * mag;
413
+ const out = [];
414
+ for (let v = 0; v <= max + step; v += step) out.push(Math.round(v));
415
+ return out;
416
+ }
417
+
418
+ function shortLabel(bucket) {
419
+ if (bucket.includes('T')) return bucket.split('T')[1];
420
+ const parts = bucket.split('-');
421
+ return parts.length === 3 ? `${parts[1]}/${parts[2]}` : bucket;
422
+ }
423
+
424
+ canvas.addEventListener('mousemove', (event) => {
425
+ const rect = canvas.getBoundingClientRect();
426
+ const x = event.clientX - rect.left;
427
+ const hit = bars.find((b) => x >= b.x && x < b.x + b.w);
428
+ if (!hit) {
429
+ tip.classList.remove('is-visible');
430
+ return;
431
+ }
432
+ tip.textContent = `${hit.row.bucket} · ${hit.row.calls} calls · ${hit.row.errors} errors · ${money(hit.row.cost_usd)}`;
433
+ tip.classList.add('is-visible');
434
+ const wrapRect = canvas.parentElement.getBoundingClientRect();
435
+ const left = Math.min(
436
+ Math.max(8, event.clientX - wrapRect.left + 12),
437
+ wrapRect.width - tip.offsetWidth - 8,
438
+ );
439
+ tip.style.left = `${left}px`;
440
+ tip.style.top = `${event.clientY - wrapRect.top - 40}px`;
441
+ });
442
+
443
+ canvas.addEventListener('mouseleave', () => tip.classList.remove('is-visible'));
444
+
445
+ /* ---------- controls ---------- */
446
+
447
+ function bindGroup(selector, attr, onPick) {
448
+ for (const btn of document.querySelectorAll(selector)) {
449
+ btn.addEventListener('click', () => {
450
+ for (const sibling of btn.parentElement.children) {
451
+ sibling.setAttribute('aria-pressed', String(sibling === btn));
452
+ }
453
+ onPick(btn.dataset[attr] ?? '');
454
+ });
455
+ }
456
+ }
457
+
458
+ bindGroup('.rangeset button', 'range', (value) => {
459
+ state.range = value;
460
+ refresh();
461
+ });
462
+
463
+ bindGroup('.filterset button', 'status', (value) => {
464
+ state.status = value;
465
+ refresh();
466
+ });
467
+
468
+ for (const btn of document.querySelectorAll('[data-toggle-table]')) {
469
+ btn.addEventListener('click', () => {
470
+ const wrap = document.querySelector(`[data-table="${btn.dataset.toggleTable}"]`);
471
+ const hidden = wrap.classList.toggle('is-hidden');
472
+ btn.textContent = hidden ? 'Show data table' : 'Hide data table';
473
+ });
474
+ }
475
+
476
+ const themeToggle = document.getElementById('theme-toggle');
477
+ themeToggle.addEventListener('click', () => {
478
+ const current =
479
+ document.documentElement.getAttribute('data-theme') ||
480
+ (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
481
+ const next = current === 'dark' ? 'light' : 'dark';
482
+ document.documentElement.setAttribute('data-theme', next);
483
+ try {
484
+ localStorage.setItem('agentobs-theme', next);
485
+ } catch {
486
+ // Private windows and blocked site data throw here; the page must still
487
+ // render, it just won't remember the choice.
488
+ }
489
+ drawTimeline();
490
+ });
491
+
492
+ try {
493
+ const saved = localStorage.getItem('agentobs-theme');
494
+ if (saved) document.documentElement.setAttribute('data-theme', saved);
495
+ } catch {
496
+ /* ignore */
497
+ }
498
+
499
+ window.addEventListener('resize', drawTimeline);
500
+
501
+ refresh();
502
+ setInterval(refresh, 5000);
@@ -0,0 +1,196 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <meta name="color-scheme" content="light dark" />
7
+ <title>AgentObs</title>
8
+ <link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>&#128200;</text></svg>" />
9
+ <link rel="stylesheet" href="/app.css" />
10
+ </head>
11
+ <body>
12
+ <a class="skip-link" href="#main">Skip to content</a>
13
+
14
+ <header class="topbar">
15
+ <div class="brand">
16
+ <span class="brand-mark" aria-hidden="true"></span>
17
+ <div>
18
+ <h1>AgentObs</h1>
19
+ <p class="brand-sub">Agent observability &amp; control</p>
20
+ </div>
21
+ </div>
22
+
23
+ <div class="topbar-actions">
24
+ <div class="rangeset" role="group" aria-label="Time range">
25
+ <button type="button" data-range="today">Today</button>
26
+ <button type="button" data-range="7d" aria-pressed="true">7 days</button>
27
+ <button type="button" data-range="30d">30 days</button>
28
+ <button type="button" data-range="all">All</button>
29
+ </div>
30
+ <button type="button" id="theme-toggle" class="icon-btn" aria-label="Switch theme">
31
+ <span aria-hidden="true">◐</span>
32
+ </button>
33
+ </div>
34
+ </header>
35
+
36
+ <main id="main">
37
+ <!-- Hero: exactly one per view - the number this dashboard exists to report. -->
38
+ <section class="hero" aria-labelledby="hero-label">
39
+ <div>
40
+ <p class="hero-label" id="hero-label">Spend <span data-range-label>this week</span></p>
41
+ <p class="hero-figure" id="hero-cost">—</p>
42
+ <p class="hero-note" id="hero-note">No activity recorded yet.</p>
43
+ </div>
44
+ <dl class="hero-side">
45
+ <div><dt>Tokens in</dt><dd id="hero-tokens-in">—</dd></div>
46
+ <div><dt>Tokens out</dt><dd id="hero-tokens-out">—</dd></div>
47
+ <div><dt>Avg duration</dt><dd id="hero-duration">—</dd></div>
48
+ </dl>
49
+ </section>
50
+
51
+ <section class="tiles" aria-label="Key metrics">
52
+ <article class="tile">
53
+ <p class="tile-label">Tool calls</p>
54
+ <p class="tile-value" id="stat-calls">—</p>
55
+ <p class="tile-sub" id="stat-calls-sub">&nbsp;</p>
56
+ </article>
57
+ <article class="tile">
58
+ <p class="tile-label">Sessions</p>
59
+ <p class="tile-value" id="stat-sessions">—</p>
60
+ <p class="tile-sub" id="stat-sessions-sub">&nbsp;</p>
61
+ </article>
62
+ <article class="tile">
63
+ <p class="tile-label">Error rate</p>
64
+ <p class="tile-value" id="stat-errors">—</p>
65
+ <p class="tile-sub" id="stat-errors-sub">&nbsp;</p>
66
+ </article>
67
+ <article class="tile">
68
+ <p class="tile-label">Blocked</p>
69
+ <p class="tile-value" id="stat-blocked">—</p>
70
+ <p class="tile-sub" id="stat-blocked-sub">by policy</p>
71
+ </article>
72
+ </section>
73
+
74
+ <section class="panel" aria-labelledby="timeline-title">
75
+ <div class="panel-head">
76
+ <div>
77
+ <h2 id="timeline-title">Activity over time</h2>
78
+ <p class="panel-sub">Tool calls per bucket, errors marked separately.</p>
79
+ </div>
80
+ <div class="legend" id="timeline-legend">
81
+ <span class="legend-item"><i class="swatch swatch-calls"></i>Calls</span>
82
+ <span class="legend-item"><i class="swatch swatch-errors"></i>Errors</span>
83
+ </div>
84
+ </div>
85
+ <div class="chart-wrap">
86
+ <canvas id="timeline" height="220" aria-describedby="timeline-table-note"></canvas>
87
+ <div class="tooltip" id="timeline-tip" role="status" aria-live="polite"></div>
88
+ </div>
89
+ <p class="panel-note" id="timeline-table-note">
90
+ <button type="button" class="linklike" data-toggle-table="timeline">
91
+ Show data table
92
+ </button>
93
+ </p>
94
+ <div class="table-wrap is-hidden" data-table="timeline">
95
+ <table>
96
+ <caption class="sr-only">Activity over time, as a table</caption>
97
+ <thead>
98
+ <tr><th scope="col">Bucket</th><th scope="col">Calls</th><th scope="col">Errors</th><th scope="col">Cost</th></tr>
99
+ </thead>
100
+ <tbody id="timeline-table-body"></tbody>
101
+ </table>
102
+ </div>
103
+ </section>
104
+
105
+ <div class="split">
106
+ <section class="panel" aria-labelledby="tools-title">
107
+ <div class="panel-head">
108
+ <div>
109
+ <h2 id="tools-title">Tools</h2>
110
+ <p class="panel-sub">Where the calls and the money go.</p>
111
+ </div>
112
+ </div>
113
+ <div class="table-wrap">
114
+ <table>
115
+ <thead>
116
+ <tr>
117
+ <th scope="col">Tool</th>
118
+ <th scope="col" class="num">Calls</th>
119
+ <th scope="col" class="num">Errors</th>
120
+ <th scope="col" class="num">Avg ms</th>
121
+ <th scope="col" class="num">Cost</th>
122
+ </tr>
123
+ </thead>
124
+ <tbody id="tools-body">
125
+ <tr><td colspan="5" class="empty">No tool calls recorded yet.</td></tr>
126
+ </tbody>
127
+ </table>
128
+ </div>
129
+ </section>
130
+
131
+ <section class="panel" aria-labelledby="sessions-title">
132
+ <div class="panel-head">
133
+ <div>
134
+ <h2 id="sessions-title">Sessions</h2>
135
+ <p class="panel-sub">Coarse sessions show only duration and exit code.</p>
136
+ </div>
137
+ </div>
138
+ <div class="table-wrap">
139
+ <table>
140
+ <thead>
141
+ <tr>
142
+ <th scope="col">Agent</th>
143
+ <th scope="col">Started</th>
144
+ <th scope="col" class="num">Calls</th>
145
+ <th scope="col" class="num">Cost</th>
146
+ </tr>
147
+ </thead>
148
+ <tbody id="sessions-body">
149
+ <tr><td colspan="4" class="empty">No sessions yet.</td></tr>
150
+ </tbody>
151
+ </table>
152
+ </div>
153
+ </section>
154
+ </div>
155
+
156
+ <section class="panel" aria-labelledby="activity-title">
157
+ <div class="panel-head">
158
+ <div>
159
+ <h2 id="activity-title">Recent activity</h2>
160
+ <p class="panel-sub">Inputs are truncated and secret-redacted before storage.</p>
161
+ </div>
162
+ <div class="filterset" role="group" aria-label="Filter by status">
163
+ <button type="button" data-status="" aria-pressed="true">All</button>
164
+ <button type="button" data-status="success">Success</button>
165
+ <button type="button" data-status="error">Errors</button>
166
+ <button type="button" data-status="blocked">Blocked</button>
167
+ </div>
168
+ </div>
169
+ <div class="table-wrap">
170
+ <table>
171
+ <thead>
172
+ <tr>
173
+ <th scope="col">Status</th>
174
+ <th scope="col">Tool</th>
175
+ <th scope="col">Input</th>
176
+ <th scope="col">When</th>
177
+ <th scope="col" class="num">ms</th>
178
+ <th scope="col" class="num">Cost</th>
179
+ </tr>
180
+ </thead>
181
+ <tbody id="activity-body">
182
+ <tr><td colspan="6" class="empty">Nothing yet. Run an agent to see activity here.</td></tr>
183
+ </tbody>
184
+ </table>
185
+ </div>
186
+ </section>
187
+ </main>
188
+
189
+ <footer class="footer">
190
+ <span id="conn-state" class="conn">Connecting…</span>
191
+ <span>Local data only · <code>~/.agentobs/agentobs.db</code></span>
192
+ </footer>
193
+
194
+ <script type="module" src="/app.js"></script>
195
+ </body>
196
+ </html>