@klars/agentobs 0.1.0 → 0.1.1
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/README.md +211 -190
- package/dist/core/queries.js +117 -42
- package/dist/server/index.js +4 -2
- package/dist/server/public/app.css +961 -668
- package/dist/server/public/app.js +647 -502
- package/dist/server/public/index.html +212 -196
- package/dist/server/public/logo.svg +18 -0
- package/package.json +1 -1
|
@@ -1,502 +1,647 @@
|
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
'
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (value ===
|
|
34
|
-
if (value
|
|
35
|
-
return `$${value.toFixed(
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
if (value
|
|
46
|
-
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
if (secs <
|
|
60
|
-
if (secs <
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
td
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
span.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
url
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
el
|
|
95
|
-
el.
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
fetchJson('/api/
|
|
103
|
-
fetchJson('/api/
|
|
104
|
-
fetchJson(
|
|
105
|
-
fetchJson(
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
document.getElementById('hero-
|
|
126
|
-
document.getElementById('hero-tokens-
|
|
127
|
-
document.getElementById('hero-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
//
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
document.getElementById('stat-calls
|
|
142
|
-
document.getElementById('stat-
|
|
143
|
-
document.getElementById('stat-sessions
|
|
144
|
-
document.getElementById('stat-
|
|
145
|
-
document.getElementById('stat-errors
|
|
146
|
-
document.getElementById('stat-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
)
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
tr.
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
)
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
const
|
|
307
|
-
const
|
|
308
|
-
const
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
const
|
|
324
|
-
const
|
|
325
|
-
const
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
ctx.beginPath();
|
|
398
|
-
ctx.moveTo(
|
|
399
|
-
ctx.lineTo(
|
|
400
|
-
ctx.
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
ctx.
|
|
404
|
-
ctx.
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
const
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
);
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
for (const
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
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
|
+
sparklines: null,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const RANGE_LABEL = {
|
|
17
|
+
today: 'today',
|
|
18
|
+
'7d': 'this week',
|
|
19
|
+
'30d': 'this month',
|
|
20
|
+
all: 'all time',
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/* ---------- formatting ---------- */
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Money formatter.
|
|
27
|
+
*
|
|
28
|
+
* `null` renders as an em dash, never as $0.00: a missing price for a model
|
|
29
|
+
* is not the same fact as a call that cost nothing, and conflating them is
|
|
30
|
+
* exactly the fabrication this product promises not to do.
|
|
31
|
+
*/
|
|
32
|
+
function money(value) {
|
|
33
|
+
if (value === null || value === undefined) return '—';
|
|
34
|
+
if (value === 0) return '$0.00';
|
|
35
|
+
if (value < 0.01) return `$${value.toFixed(4)}`;
|
|
36
|
+
return `$${value.toFixed(2)}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function count(value) {
|
|
40
|
+
if (value === null || value === undefined) return '—';
|
|
41
|
+
return new Intl.NumberFormat().format(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function ms(value) {
|
|
45
|
+
if (value === null || value === undefined) return '—';
|
|
46
|
+
if (value < 1000) return `${Math.round(value)}`;
|
|
47
|
+
return `${(value / 1000).toFixed(1)}s`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function percent(value) {
|
|
51
|
+
if (value === null || value === undefined) return '—';
|
|
52
|
+
return `${(value * 100).toFixed(1)}%`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function relativeTime(iso) {
|
|
56
|
+
const then = Date.parse(iso);
|
|
57
|
+
if (Number.isNaN(then)) return iso ?? '—';
|
|
58
|
+
const secs = Math.round((Date.now() - then) / 1000);
|
|
59
|
+
if (secs < 60) return `${secs}s ago`;
|
|
60
|
+
if (secs < 3600) return `${Math.round(secs / 60)}m ago`;
|
|
61
|
+
if (secs < 86400) return `${Math.round(secs / 3600)}h ago`;
|
|
62
|
+
return new Date(then).toLocaleDateString();
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Always build DOM via textContent - tool inputs are untrusted strings. */
|
|
66
|
+
function cell(text, className) {
|
|
67
|
+
const td = document.createElement('td');
|
|
68
|
+
td.textContent = text;
|
|
69
|
+
if (className) td.className = className;
|
|
70
|
+
return td;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function statusPill(status) {
|
|
74
|
+
const span = document.createElement('span');
|
|
75
|
+
const known = ['success', 'error', 'blocked', 'pending'].includes(status);
|
|
76
|
+
span.className = `pill pill-${known ? status : 'pending'}`;
|
|
77
|
+
span.textContent = status;
|
|
78
|
+
return span;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* ---------- data ---------- */
|
|
82
|
+
|
|
83
|
+
async function fetchJson(path) {
|
|
84
|
+
const url = new URL(path, window.location.origin);
|
|
85
|
+
url.searchParams.set('range', state.range);
|
|
86
|
+
const token = new URLSearchParams(window.location.search).get('token');
|
|
87
|
+
if (token) url.searchParams.set('token', token);
|
|
88
|
+
const res = await fetch(url, { headers: { Accept: 'application/json' } });
|
|
89
|
+
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
|
|
90
|
+
return res.json();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function setConnection(ok, message) {
|
|
94
|
+
const el = document.getElementById('conn-state');
|
|
95
|
+
el.className = `conn ${ok ? 'is-live' : 'is-down'}`;
|
|
96
|
+
el.textContent = message;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function refresh() {
|
|
100
|
+
try {
|
|
101
|
+
const [summary, timeline, tools, calls, sessions] = await Promise.all([
|
|
102
|
+
fetchJson('/api/summary'),
|
|
103
|
+
fetchJson('/api/timeline'),
|
|
104
|
+
fetchJson('/api/tools-breakdown'),
|
|
105
|
+
fetchJson(`/api/tool-calls${state.status ? `?status=${state.status}` : ''}`),
|
|
106
|
+
fetchJson('/api/sessions'),
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
renderSummary(summary);
|
|
110
|
+
state.timeline = timeline;
|
|
111
|
+
drawTimeline();
|
|
112
|
+
renderTimelineTable(timeline);
|
|
113
|
+
renderTools(tools);
|
|
114
|
+
renderActivity(calls.calls ?? []);
|
|
115
|
+
renderSessions(sessions.sessions ?? []);
|
|
116
|
+
setConnection(true, `Live · updated ${new Date().toLocaleTimeString()}`);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
setConnection(false, `Disconnected: ${err.message}`);
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/* ---------- render ---------- */
|
|
123
|
+
|
|
124
|
+
function renderSummary(s) {
|
|
125
|
+
document.getElementById('hero-cost').textContent = money(s.total_cost_usd);
|
|
126
|
+
document.getElementById('hero-tokens-in').textContent = count(s.tokens_in);
|
|
127
|
+
document.getElementById('hero-tokens-out').textContent = count(s.tokens_out);
|
|
128
|
+
document.getElementById('hero-duration').textContent = ms(s.avg_duration_ms);
|
|
129
|
+
|
|
130
|
+
// Say plainly when the cost figure is incomplete, rather than presenting a
|
|
131
|
+
// partial total as if it were the whole spend.
|
|
132
|
+
const note = document.getElementById('hero-note');
|
|
133
|
+
if (s.tool_calls === 0) {
|
|
134
|
+
note.textContent = 'No activity recorded yet.';
|
|
135
|
+
} else if (s.uncosted_calls > 0) {
|
|
136
|
+
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.`;
|
|
137
|
+
} else {
|
|
138
|
+
note.textContent = `Across ${count(s.tool_calls)} tool calls in ${count(s.sessions)} session${s.sessions === 1 ? '' : 's'}.`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
document.getElementById('stat-calls').textContent = count(s.tool_calls);
|
|
142
|
+
document.getElementById('stat-calls-sub').textContent = `${count(s.tokens_in + s.tokens_out)} tokens`;
|
|
143
|
+
document.getElementById('stat-sessions').textContent = count(s.sessions);
|
|
144
|
+
document.getElementById('stat-sessions-sub').textContent = RANGE_LABEL[s.range] ?? '';
|
|
145
|
+
document.getElementById('stat-errors').textContent = percent(s.error_rate);
|
|
146
|
+
document.getElementById('stat-errors-sub').textContent = `${count(s.errors)} failed`;
|
|
147
|
+
document.getElementById('stat-blocked').textContent = count(s.blocked);
|
|
148
|
+
|
|
149
|
+
const prev = s.previous;
|
|
150
|
+
renderDelta(document.getElementById('delta-calls'), s.tool_calls, prev?.tool_calls);
|
|
151
|
+
renderDelta(document.getElementById('delta-sessions'), s.sessions, prev?.sessions);
|
|
152
|
+
// Up is bad for an error rate - see renderDelta's goodWhenUp.
|
|
153
|
+
renderDelta(document.getElementById('delta-errors'), s.error_rate, prev?.error_rate, {
|
|
154
|
+
goodWhenUp: false,
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
state.sparklines = s.sparklines ?? null;
|
|
158
|
+
drawAllSparks();
|
|
159
|
+
|
|
160
|
+
for (const el of document.querySelectorAll('[data-range-label]')) {
|
|
161
|
+
el.textContent = RANGE_LABEL[state.range] ?? '';
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function renderTimelineTable(rows) {
|
|
166
|
+
const body = document.getElementById('timeline-table-body');
|
|
167
|
+
body.replaceChildren();
|
|
168
|
+
for (const row of rows) {
|
|
169
|
+
const tr = document.createElement('tr');
|
|
170
|
+
tr.append(
|
|
171
|
+
cell(row.bucket),
|
|
172
|
+
cell(count(row.calls), 'num'),
|
|
173
|
+
cell(count(row.errors), 'num'),
|
|
174
|
+
cell(money(row.cost_usd), 'num'),
|
|
175
|
+
);
|
|
176
|
+
body.append(tr);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function renderTools(rows) {
|
|
181
|
+
const body = document.getElementById('tools-body');
|
|
182
|
+
body.replaceChildren();
|
|
183
|
+
if (rows.length === 0) {
|
|
184
|
+
const tr = document.createElement('tr');
|
|
185
|
+
tr.append(Object.assign(cell('No tool calls recorded yet.', 'empty'), { colSpan: 5 }));
|
|
186
|
+
body.append(tr);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
for (const row of rows) {
|
|
190
|
+
const tr = document.createElement('tr');
|
|
191
|
+
const name = cell('');
|
|
192
|
+
name.append(document.createTextNode(row.tool_name));
|
|
193
|
+
if (row.blocked > 0) {
|
|
194
|
+
const badge = document.createElement('span');
|
|
195
|
+
badge.className = 'badge';
|
|
196
|
+
badge.textContent = `${row.blocked} blocked`;
|
|
197
|
+
name.append(badge);
|
|
198
|
+
}
|
|
199
|
+
tr.append(
|
|
200
|
+
name,
|
|
201
|
+
cell(count(row.calls), 'num'),
|
|
202
|
+
cell(count(row.errors), 'num'),
|
|
203
|
+
cell(ms(row.avg_duration_ms), 'num'),
|
|
204
|
+
cell(money(row.cost_usd), 'num'),
|
|
205
|
+
);
|
|
206
|
+
body.append(tr);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function renderSessions(rows) {
|
|
211
|
+
const body = document.getElementById('sessions-body');
|
|
212
|
+
body.replaceChildren();
|
|
213
|
+
if (rows.length === 0) {
|
|
214
|
+
const tr = document.createElement('tr');
|
|
215
|
+
tr.append(Object.assign(cell('No sessions yet.', 'empty'), { colSpan: 4 }));
|
|
216
|
+
body.append(tr);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
for (const row of rows) {
|
|
220
|
+
const tr = document.createElement('tr');
|
|
221
|
+
const agent = cell('');
|
|
222
|
+
agent.append(document.createTextNode(row.agent_name));
|
|
223
|
+
// Coarse sessions know only duration and exit code. Labelling them keeps
|
|
224
|
+
// the UI from implying per-tool-call detail it does not have.
|
|
225
|
+
if (row.fidelity === 'coarse') {
|
|
226
|
+
const badge = document.createElement('span');
|
|
227
|
+
badge.className = 'badge';
|
|
228
|
+
badge.textContent = 'coarse';
|
|
229
|
+
badge.title = 'Process-wrapped: duration and exit code only, no per-tool-call detail.';
|
|
230
|
+
agent.append(badge);
|
|
231
|
+
}
|
|
232
|
+
tr.append(
|
|
233
|
+
agent,
|
|
234
|
+
cell(relativeTime(row.started_at)),
|
|
235
|
+
cell(row.fidelity === 'coarse' ? '—' : count(row.tool_call_count), 'num'),
|
|
236
|
+
cell(money(row.total_cost_usd), 'num'),
|
|
237
|
+
);
|
|
238
|
+
body.append(tr);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function renderActivity(rows) {
|
|
243
|
+
const body = document.getElementById('activity-body');
|
|
244
|
+
body.replaceChildren();
|
|
245
|
+
if (rows.length === 0) {
|
|
246
|
+
const tr = document.createElement('tr');
|
|
247
|
+
tr.append(
|
|
248
|
+
Object.assign(cell('Nothing yet. Run an agent to see activity here.', 'empty'), {
|
|
249
|
+
colSpan: 6,
|
|
250
|
+
}),
|
|
251
|
+
);
|
|
252
|
+
body.append(tr);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
for (const row of rows) {
|
|
256
|
+
const tr = document.createElement('tr');
|
|
257
|
+
|
|
258
|
+
const status = document.createElement('td');
|
|
259
|
+
status.append(statusPill(row.status));
|
|
260
|
+
if (row.rule_matched) {
|
|
261
|
+
const badge = document.createElement('span');
|
|
262
|
+
badge.className = 'badge';
|
|
263
|
+
badge.textContent = row.rule_matched;
|
|
264
|
+
badge.title = 'Policy rule that produced this decision';
|
|
265
|
+
status.append(badge);
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const input = document.createElement('td');
|
|
269
|
+
const code = document.createElement('code');
|
|
270
|
+
code.className = 'mono truncate';
|
|
271
|
+
code.textContent = row.input_summary || '—';
|
|
272
|
+
code.title = row.error_message || row.output_summary || row.input_summary || '';
|
|
273
|
+
input.append(code);
|
|
274
|
+
|
|
275
|
+
tr.append(
|
|
276
|
+
status,
|
|
277
|
+
cell(row.tool_name),
|
|
278
|
+
input,
|
|
279
|
+
cell(relativeTime(row.started_at)),
|
|
280
|
+
cell(ms(row.duration_ms), 'num'),
|
|
281
|
+
cell(money(row.cost_usd), 'num'),
|
|
282
|
+
);
|
|
283
|
+
body.append(tr);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/* ---------- chart ---------- */
|
|
288
|
+
|
|
289
|
+
const canvas = document.getElementById('timeline');
|
|
290
|
+
const tip = document.getElementById('timeline-tip');
|
|
291
|
+
let bars = [];
|
|
292
|
+
|
|
293
|
+
function cssVar(name) {
|
|
294
|
+
return getComputedStyle(document.documentElement).getPropertyValue(name).trim();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Grouped bar chart on a plain canvas.
|
|
299
|
+
*
|
|
300
|
+
* One y-scale only - calls and errors are both counts, so they share it. A
|
|
301
|
+
* second axis for cost would be a dual-axis chart, which misleads by making
|
|
302
|
+
* two unrelated scales look comparable.
|
|
303
|
+
*/
|
|
304
|
+
function drawTimeline() {
|
|
305
|
+
const rows = state.timeline;
|
|
306
|
+
const dpr = window.devicePixelRatio || 1;
|
|
307
|
+
const cssWidth = canvas.clientWidth || canvas.parentElement.clientWidth;
|
|
308
|
+
const cssHeight = 220;
|
|
309
|
+
|
|
310
|
+
canvas.width = cssWidth * dpr;
|
|
311
|
+
canvas.height = cssHeight * dpr;
|
|
312
|
+
canvas.style.height = `${cssHeight}px`;
|
|
313
|
+
|
|
314
|
+
const ctx = canvas.getContext('2d');
|
|
315
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
316
|
+
ctx.clearRect(0, 0, cssWidth, cssHeight);
|
|
317
|
+
|
|
318
|
+
const pad = { top: 12, right: 8, bottom: 26, left: 40 };
|
|
319
|
+
const plotW = cssWidth - pad.left - pad.right;
|
|
320
|
+
const plotH = cssHeight - pad.top - pad.bottom;
|
|
321
|
+
bars = [];
|
|
322
|
+
|
|
323
|
+
const muted = cssVar('--text-muted');
|
|
324
|
+
const gridColor = cssVar('--grid');
|
|
325
|
+
const axisColor = cssVar('--axis');
|
|
326
|
+
|
|
327
|
+
if (rows.length === 0) {
|
|
328
|
+
ctx.fillStyle = muted;
|
|
329
|
+
ctx.font = '13px ' + cssVar('--font');
|
|
330
|
+
ctx.textAlign = 'center';
|
|
331
|
+
ctx.fillText('No activity in this range', cssWidth / 2, cssHeight / 2);
|
|
332
|
+
return;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
const maxCalls = Math.max(1, ...rows.map((r) => r.calls));
|
|
336
|
+
const ticks = niceTicks(maxCalls, 4);
|
|
337
|
+
const top = ticks[ticks.length - 1];
|
|
338
|
+
const y = (v) => pad.top + plotH - (v / top) * plotH;
|
|
339
|
+
|
|
340
|
+
// Recessive gridlines, drawn under the data.
|
|
341
|
+
ctx.strokeStyle = gridColor;
|
|
342
|
+
ctx.lineWidth = 1;
|
|
343
|
+
ctx.fillStyle = muted;
|
|
344
|
+
ctx.font = '11px ' + cssVar('--font');
|
|
345
|
+
ctx.textAlign = 'right';
|
|
346
|
+
ctx.textBaseline = 'middle';
|
|
347
|
+
for (const t of ticks) {
|
|
348
|
+
const yy = Math.round(y(t)) + 0.5;
|
|
349
|
+
ctx.beginPath();
|
|
350
|
+
ctx.moveTo(pad.left, yy);
|
|
351
|
+
ctx.lineTo(pad.left + plotW, yy);
|
|
352
|
+
ctx.stroke();
|
|
353
|
+
ctx.fillText(String(t), pad.left - 8, yy);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const slot = plotW / rows.length;
|
|
357
|
+
// Cap the width so a 7-bucket week doesn't render as slabs, but keep bars
|
|
358
|
+
// substantial enough to read as data rather than hairlines.
|
|
359
|
+
const barW = Math.max(4, Math.min(46, slot * 0.62));
|
|
360
|
+
const errW = Math.max(3, barW * 0.4);
|
|
361
|
+
const radius = 4;
|
|
362
|
+
|
|
363
|
+
rows.forEach((row, i) => {
|
|
364
|
+
const cx = pad.left + slot * i + slot / 2;
|
|
365
|
+
const x = cx - barW / 2;
|
|
366
|
+
const h = Math.max(row.calls > 0 ? 2 : 0, plotH - (y(row.calls) - pad.top));
|
|
367
|
+
|
|
368
|
+
if (h > 0) {
|
|
369
|
+
// Vertical gradient rather than a flat fill: it gives the bar body
|
|
370
|
+
// depth while keeping full saturation at the data end, where the
|
|
371
|
+
// value is actually read.
|
|
372
|
+
const grad = ctx.createLinearGradient(0, y(row.calls), 0, pad.top + plotH);
|
|
373
|
+
grad.addColorStop(0, cssVar('--series-1'));
|
|
374
|
+
grad.addColorStop(1, hexToRgba(cssVar('--series-1'), 0.55));
|
|
375
|
+
ctx.fillStyle = grad;
|
|
376
|
+
roundedTop(ctx, x, y(row.calls), barW, h, radius);
|
|
377
|
+
ctx.fill();
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Errors ride in front, inset, in the reserved critical status color -
|
|
381
|
+
// never a categorical slot, so a status never impersonates a series.
|
|
382
|
+
if (row.errors > 0) {
|
|
383
|
+
const eh = Math.max(2, plotH - (y(row.errors) - pad.top));
|
|
384
|
+
ctx.fillStyle = cssVar('--surface');
|
|
385
|
+
roundedTop(ctx, cx - errW / 2 - 1, y(row.errors) - 1, errW + 2, eh + 1, radius);
|
|
386
|
+
ctx.fill();
|
|
387
|
+
ctx.fillStyle = cssVar('--status-critical');
|
|
388
|
+
roundedTop(ctx, cx - errW / 2, y(row.errors), errW, eh, radius);
|
|
389
|
+
ctx.fill();
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
bars.push({ x: pad.left + slot * i, w: slot, row });
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
// Baseline.
|
|
396
|
+
ctx.strokeStyle = axisColor;
|
|
397
|
+
ctx.beginPath();
|
|
398
|
+
ctx.moveTo(pad.left, pad.top + plotH + 0.5);
|
|
399
|
+
ctx.lineTo(pad.left + plotW, pad.top + plotH + 0.5);
|
|
400
|
+
ctx.stroke();
|
|
401
|
+
|
|
402
|
+
// Thin out x labels so they never collide.
|
|
403
|
+
ctx.fillStyle = muted;
|
|
404
|
+
ctx.textAlign = 'center';
|
|
405
|
+
ctx.textBaseline = 'top';
|
|
406
|
+
const step = Math.max(1, Math.ceil(rows.length / Math.floor(plotW / 70)));
|
|
407
|
+
rows.forEach((row, i) => {
|
|
408
|
+
if (i % step !== 0 && i !== rows.length - 1) return;
|
|
409
|
+
ctx.fillText(shortLabel(row.bucket), pad.left + slot * i + slot / 2, pad.top + plotH + 8);
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function roundedTop(ctx, x, y, w, h, r) {
|
|
414
|
+
const rr = Math.min(r, w / 2, h);
|
|
415
|
+
ctx.beginPath();
|
|
416
|
+
ctx.moveTo(x, y + h);
|
|
417
|
+
ctx.lineTo(x, y + rr);
|
|
418
|
+
ctx.quadraticCurveTo(x, y, x + rr, y);
|
|
419
|
+
ctx.lineTo(x + w - rr, y);
|
|
420
|
+
ctx.quadraticCurveTo(x + w, y, x + w, y + rr);
|
|
421
|
+
ctx.lineTo(x + w, y + h);
|
|
422
|
+
ctx.closePath();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function niceTicks(max, target) {
|
|
426
|
+
const raw = max / target;
|
|
427
|
+
const mag = 10 ** Math.floor(Math.log10(raw));
|
|
428
|
+
const norm = raw / mag;
|
|
429
|
+
const stepMult = norm <= 1 ? 1 : norm <= 2 ? 2 : norm <= 5 ? 5 : 10;
|
|
430
|
+
const step = stepMult * mag;
|
|
431
|
+
const out = [];
|
|
432
|
+
for (let v = 0; v <= max + step; v += step) out.push(Math.round(v));
|
|
433
|
+
return out;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
function shortLabel(bucket) {
|
|
437
|
+
if (bucket.includes('T')) return bucket.split('T')[1];
|
|
438
|
+
const parts = bucket.split('-');
|
|
439
|
+
return parts.length === 3 ? `${parts[1]}/${parts[2]}` : bucket;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
canvas.addEventListener('mousemove', (event) => {
|
|
443
|
+
const rect = canvas.getBoundingClientRect();
|
|
444
|
+
const x = event.clientX - rect.left;
|
|
445
|
+
const hit = bars.find((b) => x >= b.x && x < b.x + b.w);
|
|
446
|
+
if (!hit) {
|
|
447
|
+
tip.classList.remove('is-visible');
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
tip.textContent = `${hit.row.bucket} · ${hit.row.calls} calls · ${hit.row.errors} errors · ${money(hit.row.cost_usd)}`;
|
|
451
|
+
tip.classList.add('is-visible');
|
|
452
|
+
const wrapRect = canvas.parentElement.getBoundingClientRect();
|
|
453
|
+
const left = Math.min(
|
|
454
|
+
Math.max(8, event.clientX - wrapRect.left + 12),
|
|
455
|
+
wrapRect.width - tip.offsetWidth - 8,
|
|
456
|
+
);
|
|
457
|
+
tip.style.left = `${left}px`;
|
|
458
|
+
tip.style.top = `${event.clientY - wrapRect.top - 40}px`;
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
canvas.addEventListener('mouseleave', () => tip.classList.remove('is-visible'));
|
|
462
|
+
|
|
463
|
+
/* ---------- controls ---------- */
|
|
464
|
+
|
|
465
|
+
function bindGroup(selector, attr, onPick) {
|
|
466
|
+
for (const btn of document.querySelectorAll(selector)) {
|
|
467
|
+
btn.addEventListener('click', () => {
|
|
468
|
+
for (const sibling of btn.parentElement.children) {
|
|
469
|
+
sibling.setAttribute('aria-pressed', String(sibling === btn));
|
|
470
|
+
}
|
|
471
|
+
onPick(btn.dataset[attr] ?? '');
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
bindGroup('.rangeset button', 'range', (value) => {
|
|
477
|
+
state.range = value;
|
|
478
|
+
refresh();
|
|
479
|
+
});
|
|
480
|
+
|
|
481
|
+
bindGroup('.filterset button', 'status', (value) => {
|
|
482
|
+
state.status = value;
|
|
483
|
+
refresh();
|
|
484
|
+
});
|
|
485
|
+
|
|
486
|
+
for (const btn of document.querySelectorAll('[data-toggle-table]')) {
|
|
487
|
+
btn.addEventListener('click', () => {
|
|
488
|
+
const wrap = document.querySelector(`[data-table="${btn.dataset.toggleTable}"]`);
|
|
489
|
+
const hidden = wrap.classList.toggle('is-hidden');
|
|
490
|
+
btn.textContent = hidden ? 'Show data table' : 'Hide data table';
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
const themeToggle = document.getElementById('theme-toggle');
|
|
495
|
+
themeToggle.addEventListener('click', () => {
|
|
496
|
+
const current =
|
|
497
|
+
document.documentElement.getAttribute('data-theme') ||
|
|
498
|
+
(window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light');
|
|
499
|
+
const next = current === 'dark' ? 'light' : 'dark';
|
|
500
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
501
|
+
try {
|
|
502
|
+
localStorage.setItem('agentobs-theme', next);
|
|
503
|
+
} catch {
|
|
504
|
+
// Private windows and blocked site data throw here; the page must still
|
|
505
|
+
// render, it just won't remember the choice.
|
|
506
|
+
}
|
|
507
|
+
drawTimeline();
|
|
508
|
+
drawAllSparks();
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
try {
|
|
512
|
+
const saved = localStorage.getItem('agentobs-theme');
|
|
513
|
+
if (saved) document.documentElement.setAttribute('data-theme', saved);
|
|
514
|
+
} catch {
|
|
515
|
+
/* ignore */
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
window.addEventListener('resize', () => {
|
|
519
|
+
drawTimeline();
|
|
520
|
+
drawAllSparks();
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
refresh();
|
|
524
|
+
setInterval(refresh, 5000);
|
|
525
|
+
|
|
526
|
+
/* ---------- sparklines & deltas ---------- */
|
|
527
|
+
|
|
528
|
+
/**
|
|
529
|
+
* Draws a filled sparkline into a canvas.
|
|
530
|
+
*
|
|
531
|
+
* Deliberately axis-less and label-less: a sparkline's job is shape, not
|
|
532
|
+
* value. The number it accompanies is the value, so adding ticks here would
|
|
533
|
+
* duplicate it and crowd the tile.
|
|
534
|
+
*/
|
|
535
|
+
function drawSpark(canvas, values, color, { fill = true, dot = true } = {}) {
|
|
536
|
+
if (!canvas || !values || values.length === 0) return;
|
|
537
|
+
const dpr = window.devicePixelRatio || 1;
|
|
538
|
+
const w = canvas.clientWidth || 200;
|
|
539
|
+
const h = canvas.clientHeight || 30;
|
|
540
|
+
canvas.width = w * dpr;
|
|
541
|
+
canvas.height = h * dpr;
|
|
542
|
+
|
|
543
|
+
const ctx = canvas.getContext('2d');
|
|
544
|
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
|
545
|
+
ctx.clearRect(0, 0, w, h);
|
|
546
|
+
|
|
547
|
+
const max = Math.max(...values, 1);
|
|
548
|
+
const pad = 3;
|
|
549
|
+
const stepX = values.length > 1 ? (w - pad * 2) / (values.length - 1) : 0;
|
|
550
|
+
const y = (v) => h - pad - (v / max) * (h - pad * 2);
|
|
551
|
+
const pts = values.map((v, i) => [pad + i * stepX, y(v)]);
|
|
552
|
+
|
|
553
|
+
if (fill) {
|
|
554
|
+
// Fade the fill to transparent so the tile's own tint shows through
|
|
555
|
+
// rather than the sparkline reading as a solid block.
|
|
556
|
+
const grad = ctx.createLinearGradient(0, 0, 0, h);
|
|
557
|
+
grad.addColorStop(0, hexToRgba(color, 0.28));
|
|
558
|
+
grad.addColorStop(1, hexToRgba(color, 0));
|
|
559
|
+
ctx.beginPath();
|
|
560
|
+
ctx.moveTo(pts[0][0], h);
|
|
561
|
+
for (const [px, py] of pts) ctx.lineTo(px, py);
|
|
562
|
+
ctx.lineTo(pts[pts.length - 1][0], h);
|
|
563
|
+
ctx.closePath();
|
|
564
|
+
ctx.fillStyle = grad;
|
|
565
|
+
ctx.fill();
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
ctx.beginPath();
|
|
569
|
+
pts.forEach(([px, py], i) => (i === 0 ? ctx.moveTo(px, py) : ctx.lineTo(px, py)));
|
|
570
|
+
ctx.strokeStyle = color;
|
|
571
|
+
ctx.lineWidth = 2;
|
|
572
|
+
ctx.lineJoin = 'round';
|
|
573
|
+
ctx.lineCap = 'round';
|
|
574
|
+
ctx.stroke();
|
|
575
|
+
|
|
576
|
+
if (dot) {
|
|
577
|
+
// 2px surface ring on the end marker, per the mark spec, so the dot
|
|
578
|
+
// stays legible where it overlaps the line or fill.
|
|
579
|
+
const [lx, ly] = pts[pts.length - 1];
|
|
580
|
+
ctx.beginPath();
|
|
581
|
+
ctx.arc(lx, ly, 3.5, 0, Math.PI * 2);
|
|
582
|
+
ctx.fillStyle = cssVar('--surface');
|
|
583
|
+
ctx.fill();
|
|
584
|
+
ctx.beginPath();
|
|
585
|
+
ctx.arc(lx, ly, 2.5, 0, Math.PI * 2);
|
|
586
|
+
ctx.fillStyle = color;
|
|
587
|
+
ctx.fill();
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
/** #rrggbb -> rgba(). Canvas gradients need a concrete alpha value. */
|
|
592
|
+
function hexToRgba(hex, alpha) {
|
|
593
|
+
const h = hex.trim().replace('#', '');
|
|
594
|
+
const full = h.length === 3 ? h.split('').map((c) => c + c).join('') : h;
|
|
595
|
+
const n = parseInt(full, 16);
|
|
596
|
+
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* Renders a delta chip.
|
|
601
|
+
*
|
|
602
|
+
* `goodWhenUp` matters: more tool calls is neutral-to-good, but a higher
|
|
603
|
+
* error rate is bad. Coloring purely by sign would congratulate the user on
|
|
604
|
+
* a rising error rate.
|
|
605
|
+
*/
|
|
606
|
+
function renderDelta(el, current, previous, { goodWhenUp = true } = {}) {
|
|
607
|
+
if (!el) return;
|
|
608
|
+
if (previous === null || previous === undefined || previous === 0) {
|
|
609
|
+
el.hidden = true;
|
|
610
|
+
el.textContent = '';
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
const change = ((current - previous) / previous) * 100;
|
|
614
|
+
if (!Number.isFinite(change) || Math.abs(change) < 0.5) {
|
|
615
|
+
// Below half a percent is noise; a chip there implies a signal that
|
|
616
|
+
// isn't real.
|
|
617
|
+
el.hidden = true;
|
|
618
|
+
el.textContent = '';
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
const up = change > 0;
|
|
622
|
+
const magnitude = Math.abs(change);
|
|
623
|
+
const tone = up === goodWhenUp ? 'delta-good' : 'delta-bad';
|
|
624
|
+
el.className = `delta ${tone}`;
|
|
625
|
+
el.textContent = `${up ? '▲' : '▼'} ${magnitude < 10 ? magnitude.toFixed(1) : Math.round(magnitude)}%`;
|
|
626
|
+
el.title = 'vs the previous period of the same length';
|
|
627
|
+
el.removeAttribute('hidden');
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
/** Repaints every sparkline. Called on refresh, resize and theme change. */
|
|
631
|
+
function drawAllSparks() {
|
|
632
|
+
const s = state.sparklines;
|
|
633
|
+
if (!s) return;
|
|
634
|
+
drawSpark(document.getElementById('hero-spark'), s.cost, cssVar('--series-1'));
|
|
635
|
+
drawSpark(document.getElementById('spark-calls'), s.calls, cssVar('--series-1'));
|
|
636
|
+
drawSpark(document.getElementById('spark-sessions'), s.sessions, cssVar('--series-3'));
|
|
637
|
+
drawSpark(document.getElementById('spark-errors'), s.errors, cssVar('--status-critical'));
|
|
638
|
+
const blockedCanvas = document.getElementById('spark-blocked');
|
|
639
|
+
if (s.blocked && s.blocked.some((v) => v > 0)) {
|
|
640
|
+
drawSpark(blockedCanvas, s.blocked, cssVar('--status-serious'));
|
|
641
|
+
} else if (blockedCanvas) {
|
|
642
|
+
// Nothing blocked in this range: clear rather than draw a flat line,
|
|
643
|
+
// which would read as a real series sitting at zero.
|
|
644
|
+
const ctx = blockedCanvas.getContext('2d');
|
|
645
|
+
ctx.clearRect(0, 0, blockedCanvas.width, blockedCanvas.height);
|
|
646
|
+
}
|
|
647
|
+
}
|