tokenjam 0.2.0__py3-none-any.whl

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.
Files changed (86) hide show
  1. tokenjam/__init__.py +1 -0
  2. tokenjam/api/__init__.py +0 -0
  3. tokenjam/api/app.py +104 -0
  4. tokenjam/api/deps.py +18 -0
  5. tokenjam/api/middleware.py +28 -0
  6. tokenjam/api/routes/__init__.py +0 -0
  7. tokenjam/api/routes/agents.py +33 -0
  8. tokenjam/api/routes/alerts.py +77 -0
  9. tokenjam/api/routes/budget.py +96 -0
  10. tokenjam/api/routes/cost.py +43 -0
  11. tokenjam/api/routes/drift.py +63 -0
  12. tokenjam/api/routes/logs.py +511 -0
  13. tokenjam/api/routes/metrics.py +81 -0
  14. tokenjam/api/routes/otlp.py +63 -0
  15. tokenjam/api/routes/spans.py +202 -0
  16. tokenjam/api/routes/status.py +84 -0
  17. tokenjam/api/routes/tools.py +22 -0
  18. tokenjam/api/routes/traces.py +92 -0
  19. tokenjam/cli/__init__.py +0 -0
  20. tokenjam/cli/cmd_alerts.py +94 -0
  21. tokenjam/cli/cmd_budget.py +119 -0
  22. tokenjam/cli/cmd_cost.py +90 -0
  23. tokenjam/cli/cmd_demo.py +82 -0
  24. tokenjam/cli/cmd_doctor.py +173 -0
  25. tokenjam/cli/cmd_drift.py +238 -0
  26. tokenjam/cli/cmd_export.py +200 -0
  27. tokenjam/cli/cmd_mcp.py +78 -0
  28. tokenjam/cli/cmd_onboard.py +779 -0
  29. tokenjam/cli/cmd_serve.py +85 -0
  30. tokenjam/cli/cmd_status.py +153 -0
  31. tokenjam/cli/cmd_stop.py +87 -0
  32. tokenjam/cli/cmd_tools.py +45 -0
  33. tokenjam/cli/cmd_traces.py +161 -0
  34. tokenjam/cli/cmd_uninstall.py +159 -0
  35. tokenjam/cli/main.py +110 -0
  36. tokenjam/core/__init__.py +0 -0
  37. tokenjam/core/alerts.py +619 -0
  38. tokenjam/core/api_backend.py +235 -0
  39. tokenjam/core/config.py +360 -0
  40. tokenjam/core/cost.py +102 -0
  41. tokenjam/core/db.py +718 -0
  42. tokenjam/core/drift.py +256 -0
  43. tokenjam/core/ingest.py +265 -0
  44. tokenjam/core/models.py +225 -0
  45. tokenjam/core/pricing.py +54 -0
  46. tokenjam/core/retention.py +21 -0
  47. tokenjam/core/schema_validator.py +156 -0
  48. tokenjam/demo/__init__.py +0 -0
  49. tokenjam/demo/env.py +96 -0
  50. tokenjam/mcp/__init__.py +0 -0
  51. tokenjam/mcp/server.py +1067 -0
  52. tokenjam/otel/__init__.py +0 -0
  53. tokenjam/otel/exporters.py +26 -0
  54. tokenjam/otel/provider.py +207 -0
  55. tokenjam/otel/semconv.py +144 -0
  56. tokenjam/pricing/models.toml +70 -0
  57. tokenjam/py.typed +0 -0
  58. tokenjam/sdk/__init__.py +21 -0
  59. tokenjam/sdk/agent.py +206 -0
  60. tokenjam/sdk/bootstrap.py +120 -0
  61. tokenjam/sdk/http_exporter.py +109 -0
  62. tokenjam/sdk/integrations/__init__.py +0 -0
  63. tokenjam/sdk/integrations/anthropic.py +200 -0
  64. tokenjam/sdk/integrations/autogen.py +97 -0
  65. tokenjam/sdk/integrations/base.py +27 -0
  66. tokenjam/sdk/integrations/bedrock.py +103 -0
  67. tokenjam/sdk/integrations/crewai.py +96 -0
  68. tokenjam/sdk/integrations/gemini.py +131 -0
  69. tokenjam/sdk/integrations/langchain.py +156 -0
  70. tokenjam/sdk/integrations/langgraph.py +101 -0
  71. tokenjam/sdk/integrations/litellm.py +323 -0
  72. tokenjam/sdk/integrations/llamaindex.py +52 -0
  73. tokenjam/sdk/integrations/nemoclaw.py +139 -0
  74. tokenjam/sdk/integrations/openai.py +159 -0
  75. tokenjam/sdk/integrations/openai_agents_sdk.py +47 -0
  76. tokenjam/sdk/transport.py +98 -0
  77. tokenjam/ui/index.html +1213 -0
  78. tokenjam/utils/__init__.py +0 -0
  79. tokenjam/utils/formatting.py +43 -0
  80. tokenjam/utils/ids.py +15 -0
  81. tokenjam/utils/time_parse.py +54 -0
  82. tokenjam-0.2.0.dist-info/METADATA +622 -0
  83. tokenjam-0.2.0.dist-info/RECORD +86 -0
  84. tokenjam-0.2.0.dist-info/WHEEL +4 -0
  85. tokenjam-0.2.0.dist-info/entry_points.txt +2 -0
  86. tokenjam-0.2.0.dist-info/licenses/LICENSE +21 -0
tokenjam/ui/index.html ADDED
@@ -0,0 +1,1213 @@
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.0">
6
+ <title>Token Juice</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
9
+ <link href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:wght@600;700;800&display=swap" rel="stylesheet">
10
+ <style>
11
+ :root {
12
+ --bg: #070d1a;
13
+ --surface: #0f1729;
14
+ --surface2: #1a2840;
15
+ --border: #1e2d4a;
16
+ --text: #e2e8f0;
17
+ --text-dim: #64748b;
18
+ --accent: #3d8eff;
19
+ --warn: #f59e0b;
20
+ --error: #ef4444;
21
+ --success: #22c55e;
22
+ --info: #3d8eff;
23
+ }
24
+ * { margin:0; padding:0; box-sizing:border-box; }
25
+ body {
26
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ display: flex;
30
+ min-height: 100vh;
31
+ }
32
+ code, .mono {
33
+ font-family: 'IBM Plex Mono', monospace;
34
+ }
35
+
36
+ /* Sidebar */
37
+ .sidebar {
38
+ width: 200px;
39
+ background: var(--surface);
40
+ border-right: 1px solid var(--border);
41
+ display: flex;
42
+ flex-direction: column;
43
+ padding: 16px 0;
44
+ flex-shrink: 0;
45
+ position: fixed;
46
+ top: 0;
47
+ left: 0;
48
+ bottom: 0;
49
+ z-index: 10;
50
+ }
51
+ .sidebar-brand {
52
+ font-family: 'Bricolage Grotesque', sans-serif;
53
+ font-weight: 700;
54
+ font-size: 14px;
55
+ color: var(--text);
56
+ padding: 0 16px 16px;
57
+ border-bottom: 1px solid var(--border);
58
+ margin-bottom: 8px;
59
+ display: flex;
60
+ align-items: center;
61
+ gap: 8px;
62
+ }
63
+ .sidebar-brand .brand-claw { color: var(--accent); }
64
+ .sidebar a {
65
+ display: flex;
66
+ align-items: center;
67
+ gap: 10px;
68
+ padding: 8px 16px;
69
+ color: var(--text-dim);
70
+ text-decoration: none;
71
+ font-family: 'IBM Plex Mono', monospace;
72
+ font-size: 13px;
73
+ transition: background 0.15s, color 0.15s;
74
+ }
75
+ .sidebar a:hover { background: rgba(255,255,255,0.04); color: var(--text); }
76
+ .sidebar a.active { color: var(--accent); background: rgba(61,142,255,0.08); }
77
+ .sidebar a .icon { width: 16px; text-align: center; font-size: 14px; }
78
+ .sidebar-footer {
79
+ margin-top: auto;
80
+ border-top: 1px solid var(--border);
81
+ padding-top: 8px;
82
+ }
83
+ .sidebar-footer a.footer-link {
84
+ display: flex;
85
+ align-items: center;
86
+ gap: 10px;
87
+ padding: 8px 16px;
88
+ color: var(--text-dim);
89
+ text-decoration: none;
90
+ font-family: 'IBM Plex Mono', monospace;
91
+ font-size: 13px;
92
+ transition: background 0.15s, color 0.15s;
93
+ }
94
+ .sidebar-footer a.footer-link:hover { background: rgba(255,255,255,0.04); color: var(--text); }
95
+ .sidebar-footer .version {
96
+ padding: 4px 16px 8px;
97
+ font-size: 10px;
98
+ color: var(--text-dim);
99
+ font-family: 'IBM Plex Mono', monospace;
100
+ }
101
+
102
+ /* Main */
103
+ .main {
104
+ flex: 1;
105
+ margin-left: 200px;
106
+ padding: 24px 32px;
107
+ min-width: 0;
108
+ }
109
+ .page-title {
110
+ font-family: 'Bricolage Grotesque', sans-serif;
111
+ font-size: 20px;
112
+ font-weight: 700;
113
+ margin-bottom: 20px;
114
+ color: var(--text);
115
+ }
116
+
117
+ /* Cards */
118
+ .cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 16px; margin-bottom: 24px; }
119
+ .card {
120
+ background: var(--surface);
121
+ border: 1px solid var(--border);
122
+ border-radius: 8px;
123
+ padding: 16px;
124
+ }
125
+ .card.clickable { cursor: pointer; transition: border-color 0.15s; }
126
+ .card.clickable:hover { border-color: var(--accent); }
127
+ .card-header {
128
+ display: flex;
129
+ align-items: center;
130
+ gap: 8px;
131
+ margin-bottom: 12px;
132
+ font-family: 'IBM Plex Mono', monospace;
133
+ font-weight: 600;
134
+ font-size: 14px;
135
+ }
136
+ .status-dot {
137
+ width: 8px; height: 8px; border-radius: 50%;
138
+ display: inline-block;
139
+ }
140
+ .status-dot.active { background: var(--success); }
141
+ .status-dot.completed { background: var(--success); }
142
+ .status-dot.idle { background: var(--text-dim); }
143
+ .status-dot.error { background: var(--error); }
144
+ .status-dot.stale { background: var(--warn); }
145
+ .card-row {
146
+ display: flex;
147
+ justify-content: space-between;
148
+ padding: 3px 0;
149
+ font-size: 13px;
150
+ font-family: 'IBM Plex Mono', monospace;
151
+ }
152
+ .card-row .label { color: var(--text-dim); }
153
+ .card-row .value { color: var(--text); }
154
+ .budget-bar {
155
+ height: 4px;
156
+ background: var(--border);
157
+ border-radius: 2px;
158
+ margin-top: 6px;
159
+ overflow: hidden;
160
+ }
161
+ .budget-bar-fill { height: 100%; border-radius: 2px; background: var(--success); transition: width 0.3s; }
162
+ .budget-bar-fill.warn { background: var(--warn); }
163
+ .budget-bar-fill.over { background: var(--error); }
164
+
165
+ /* Info tooltip */
166
+ .info-btn {
167
+ display: inline-flex;
168
+ align-items: center;
169
+ justify-content: center;
170
+ width: 14px; height: 14px;
171
+ border-radius: 50%;
172
+ background: var(--border);
173
+ color: var(--text-dim);
174
+ font-size: 10px;
175
+ font-family: sans-serif;
176
+ cursor: default;
177
+ position: relative;
178
+ margin-left: 4px;
179
+ vertical-align: middle;
180
+ line-height: 1;
181
+ }
182
+ .info-btn .info-tip {
183
+ display: none;
184
+ position: absolute;
185
+ top: calc(100% + 6px);
186
+ left: 50%;
187
+ transform: translateX(-50%);
188
+ background: var(--surface2);
189
+ color: var(--text);
190
+ border: 1px solid #2e4a70;
191
+ border-radius: 4px;
192
+ padding: 7px 10px;
193
+ font-size: 11px;
194
+ line-height: 1.5;
195
+ font-family: 'IBM Plex Mono', monospace;
196
+ white-space: normal;
197
+ max-width: 260px;
198
+ z-index: 100;
199
+ pointer-events: none;
200
+ box-shadow: 0 4px 12px rgba(0,0,0,0.5);
201
+ }
202
+ .info-btn:hover .info-tip { display: block; }
203
+
204
+ /* Tables */
205
+ .table-wrap { overflow-x: auto; }
206
+ table {
207
+ width: 100%;
208
+ border-collapse: collapse;
209
+ font-family: 'IBM Plex Mono', monospace;
210
+ font-size: 13px;
211
+ }
212
+ th {
213
+ text-align: left;
214
+ padding: 8px 12px;
215
+ color: var(--text-dim);
216
+ font-weight: 500;
217
+ font-size: 11px;
218
+ text-transform: uppercase;
219
+ letter-spacing: 0.5px;
220
+ border-bottom: 1px solid var(--border);
221
+ white-space: nowrap;
222
+ }
223
+ td {
224
+ padding: 8px 12px;
225
+ border-bottom: 1px solid var(--border);
226
+ white-space: nowrap;
227
+ }
228
+ tr:hover td { background: rgba(255,255,255,0.02); }
229
+ tr.clickable { cursor: pointer; }
230
+ tr.clickable:hover td { background: rgba(61,142,255,0.06); }
231
+ td.chevron { color: var(--text-dim); font-size: 14px; text-align: center; width: 24px; }
232
+ tr.clickable:hover td.chevron { color: var(--accent); }
233
+
234
+ /* Badges */
235
+ .badge {
236
+ display: inline-block;
237
+ padding: 2px 8px;
238
+ border-radius: 10px;
239
+ font-size: 11px;
240
+ font-weight: 600;
241
+ font-family: 'IBM Plex Mono', monospace;
242
+ }
243
+ .badge-ok, .badge-completed { background: rgba(34,197,94,0.15); color: var(--success); }
244
+ .badge-error { background: rgba(239,68,68,0.15); color: var(--error); }
245
+ .badge-active { background: rgba(34,197,94,0.15); color: var(--success); }
246
+ .badge-stale { background: rgba(245,158,11,0.15); color: var(--warn); }
247
+ .badge-critical { background: rgba(239,68,68,0.15); color: var(--error); }
248
+ .badge-warning { background: rgba(245,158,11,0.15); color: var(--warn); }
249
+ .badge-info { background: rgba(61,142,255,0.15); color: var(--info); }
250
+
251
+ /* Filters */
252
+ .filters {
253
+ display: flex;
254
+ gap: 12px;
255
+ margin-bottom: 16px;
256
+ flex-wrap: wrap;
257
+ align-items: center;
258
+ }
259
+ .filters select, .filters input {
260
+ background: var(--surface);
261
+ border: 1px solid var(--border);
262
+ color: var(--text);
263
+ padding: 6px 10px;
264
+ border-radius: 6px;
265
+ font-family: 'IBM Plex Mono', monospace;
266
+ font-size: 12px;
267
+ }
268
+ .filters select:focus, .filters input:focus { outline: none; border-color: var(--accent); }
269
+ .btn {
270
+ background: var(--surface);
271
+ border: 1px solid var(--border);
272
+ color: var(--text);
273
+ padding: 6px 12px;
274
+ border-radius: 6px;
275
+ cursor: pointer;
276
+ font-family: 'IBM Plex Mono', monospace;
277
+ font-size: 12px;
278
+ }
279
+ .btn:hover { border-color: var(--accent); color: var(--accent); }
280
+ .btn-back { margin-bottom: 16px; }
281
+
282
+ /* Waterfall */
283
+ .waterfall { margin: 16px 0; }
284
+ .wf-row {
285
+ display: flex;
286
+ align-items: center;
287
+ height: 28px;
288
+ cursor: pointer;
289
+ border-radius: 4px;
290
+ transition: background 0.1s;
291
+ }
292
+ .wf-row:hover { background: rgba(255,255,255,0.04); }
293
+ .wf-row:hover .wf-label { color: var(--text); }
294
+ .wf-row:hover .wf-bar { box-shadow: 0 0 8px rgba(61,142,255,0.25); }
295
+ .wf-row.selected { background: rgba(61,142,255,0.08); border-left: 2px solid var(--accent); }
296
+ .wf-row.selected .wf-label { color: var(--accent); }
297
+ .wf-label {
298
+ flex-shrink: 0;
299
+ font-family: 'IBM Plex Mono', monospace;
300
+ font-size: 12px;
301
+ color: var(--text-dim);
302
+ overflow: hidden;
303
+ text-overflow: ellipsis;
304
+ white-space: nowrap;
305
+ padding-right: 8px;
306
+ }
307
+ .wf-track { flex: 1; position: relative; height: 18px; }
308
+ .wf-bar-wrap { position: absolute; height: 100%; }
309
+ .wf-tip {
310
+ display: none;
311
+ position: absolute;
312
+ top: calc(100% + 6px);
313
+ left: 0;
314
+ right: auto;
315
+ background: #1a2236;
316
+ border: 1px solid var(--border);
317
+ border-radius: 6px;
318
+ padding: 8px 10px;
319
+ font-family: 'IBM Plex Mono', monospace;
320
+ font-size: 11px;
321
+ color: var(--text);
322
+ white-space: pre;
323
+ z-index: 100;
324
+ pointer-events: none;
325
+ line-height: 1.5;
326
+ box-shadow: 0 4px 12px rgba(0,0,0,0.4);
327
+ }
328
+ .wf-bar-wrap:hover .wf-tip { display: block; }
329
+ .wf-bar-wrap.tip-flip .wf-tip { left: auto; right: 0; }
330
+ .wf-bar {
331
+ position: absolute;
332
+ height: 100%;
333
+ border-radius: 3px;
334
+ min-width: 2px;
335
+ display: flex;
336
+ align-items: center;
337
+ padding: 0 6px;
338
+ font-family: 'IBM Plex Mono', monospace;
339
+ font-size: 10px;
340
+ color: #fff;
341
+ overflow: hidden;
342
+ white-space: nowrap;
343
+ }
344
+ .wf-bar.agent { background: var(--success); color: #070d1a; }
345
+ .wf-bar.llm { background: var(--accent); }
346
+ .wf-bar.tool { background: var(--warn); color: #070d1a; }
347
+ .wf-bar.other { background: var(--text-dim); }
348
+
349
+ /* Span detail */
350
+ .span-detail {
351
+ background: var(--surface);
352
+ border: 1px solid var(--border);
353
+ border-radius: 8px;
354
+ padding: 16px;
355
+ margin-top: 16px;
356
+ font-family: 'IBM Plex Mono', monospace;
357
+ font-size: 12px;
358
+ }
359
+ .span-detail h3 {
360
+ font-size: 14px;
361
+ margin-bottom: 12px;
362
+ color: var(--accent);
363
+ }
364
+ .detail-grid {
365
+ display: grid;
366
+ grid-template-columns: 140px 1fr;
367
+ gap: 4px 12px;
368
+ }
369
+ .detail-grid .dk { color: var(--text-dim); }
370
+ .detail-grid .dv { color: var(--text); word-break: break-all; }
371
+
372
+ /* Summary row */
373
+ .summary {
374
+ display: flex;
375
+ gap: 24px;
376
+ margin-bottom: 16px;
377
+ flex-wrap: wrap;
378
+ }
379
+ .summary-item {
380
+ background: var(--surface);
381
+ border: 1px solid var(--border);
382
+ border-radius: 8px;
383
+ padding: 12px 16px;
384
+ min-width: 140px;
385
+ }
386
+ .summary-label {
387
+ font-family: 'IBM Plex Mono', monospace;
388
+ font-size: 11px;
389
+ color: var(--text-dim);
390
+ text-transform: uppercase;
391
+ margin-bottom: 4px;
392
+ }
393
+ .summary-value {
394
+ font-family: 'IBM Plex Mono', monospace;
395
+ font-size: 20px;
396
+ font-weight: 600;
397
+ }
398
+
399
+ /* Drift */
400
+ .drift-section {
401
+ background: var(--surface);
402
+ border: 1px solid var(--border);
403
+ border-radius: 8px;
404
+ padding: 16px;
405
+ margin-bottom: 16px;
406
+ }
407
+ .drift-section h3 {
408
+ font-family: 'Bricolage Grotesque', sans-serif;
409
+ font-size: 15px;
410
+ font-weight: 700;
411
+ margin-bottom: 12px;
412
+ }
413
+
414
+ /* Empty state */
415
+ .empty {
416
+ text-align: center;
417
+ padding: 48px 24px;
418
+ color: var(--text-dim);
419
+ font-size: 14px;
420
+ }
421
+ .empty code {
422
+ display: block;
423
+ margin-top: 12px;
424
+ color: var(--accent);
425
+ font-size: 13px;
426
+ }
427
+
428
+ /* Expand toggle */
429
+ .expand-toggle { display: inline-block; width: 16px; font-size: 10px; color: var(--text-dim); transition: transform 0.15s; }
430
+ .expand-toggle.open { transform: rotate(90deg); }
431
+
432
+ /* Waterfall hint */
433
+ .wf-hint { font-size: 11px; color: var(--text-dim); margin-top: 8px; font-family: 'IBM Plex Mono', monospace; }
434
+
435
+ /* Alert detail expand */
436
+ .alert-detail {
437
+ background: var(--bg);
438
+ border: 1px solid var(--border);
439
+ border-radius: 6px;
440
+ padding: 12px;
441
+ margin: 8px 12px;
442
+ font-family: 'IBM Plex Mono', monospace;
443
+ font-size: 11px;
444
+ white-space: pre-wrap;
445
+ word-break: break-all;
446
+ color: var(--text-dim);
447
+ }
448
+ </style>
449
+ </head>
450
+ <body>
451
+
452
+ <nav class="sidebar">
453
+ <div class="sidebar-brand"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" width="24" height="24" style="flex-shrink:0"><circle cx="16" cy="16" r="5" fill="var(--accent)"/><path d="M16 4v5M16 23v5M4 16h5M23 16h5" stroke="var(--accent)" stroke-width="2.5" stroke-linecap="round"/><path d="M8.2 8.2l3.5 3.5M20.3 20.3l3.5 3.5M20.3 11.7l3.5-3.5M8.2 23.8l3.5-3.5" stroke="var(--accent)" stroke-width="2" stroke-linecap="round" opacity="0.6"/></svg><span>Open<span class="brand-claw">Claw</span>Watch</span></div>
454
+ <a href="#/status" class="nav-link" data-view="status"><span class="icon">&bull;</span> Status</a>
455
+ <a href="#/traces" class="nav-link" data-view="traces"><span class="icon">&diamond;</span> Traces</a>
456
+ <a href="#/cost" class="nav-link" data-view="cost"><span class="icon">$</span> Cost</a>
457
+ <a href="#/alerts" class="nav-link" data-view="alerts"><span class="icon">!</span> Alerts</a>
458
+ <a href="#/drift" class="nav-link" data-view="drift"><span class="icon">~</span> Drift</a>
459
+ <a href="#/budget" class="nav-link" data-view="budget"><span class="icon">$</span> Budget</a>
460
+ <div class="sidebar-footer">
461
+ <a href="/docs" target="_blank" class="footer-link"><span class="icon">&#x2737;</span> API docs</a>
462
+ <a href="https://github.com/Metabuilder-Labs/tokenjuice" target="_blank" class="footer-link"><span class="icon">&#x2197;</span> GitHub</a>
463
+ <div class="version">v0.1.4</div>
464
+ </div>
465
+ </nav>
466
+
467
+ <div class="main" id="app"></div>
468
+
469
+ <script type="module">
470
+ import { h, render, Component } from 'https://esm.sh/preact@10.25.4';
471
+ import { useState, useEffect, useCallback } from 'https://esm.sh/preact@10.25.4/hooks';
472
+ import htm from 'https://esm.sh/htm@3.1.1';
473
+
474
+ const html = htm.bind(h);
475
+
476
+ // --- API ---
477
+ const apiKeyMeta = document.querySelector('meta[name="ocw-api-key"]');
478
+ const API_KEY = apiKeyMeta ? apiKeyMeta.getAttribute('content') : null;
479
+
480
+ async function api(path, params = {}) {
481
+ const qs = new URLSearchParams();
482
+ for (const [k, v] of Object.entries(params)) {
483
+ if (v != null && v !== '') qs.set(k, v);
484
+ }
485
+ const url = '/api/v1' + path + (qs.toString() ? '?' + qs : '');
486
+ const headers = {};
487
+ if (API_KEY) headers['Authorization'] = 'Bearer ' + API_KEY;
488
+ const resp = await fetch(url, { headers });
489
+ if (!resp.ok) throw new Error(`API ${resp.status}`);
490
+ return resp.json();
491
+ }
492
+
493
+ async function apiPost(path, body) {
494
+ const headers = { 'Content-Type': 'application/json' };
495
+ if (API_KEY) headers['Authorization'] = 'Bearer ' + API_KEY;
496
+ const resp = await fetch('/api/v1' + path, {
497
+ method: 'POST', headers, body: JSON.stringify(body),
498
+ });
499
+ if (!resp.ok) throw new Error(`API ${resp.status}`);
500
+ return resp.json();
501
+ }
502
+
503
+ // --- Helpers ---
504
+ function fmtCost(usd) {
505
+ if (usd == null) return '$0.0000';
506
+ if (usd === 0) return '$0.0000';
507
+ return usd < 0.001 ? '$' + usd.toFixed(6) : '$' + usd.toFixed(4);
508
+ }
509
+ function fmtTokens(n) {
510
+ if (n == null) return '-';
511
+ if (n >= 1e6) return (n/1e6).toFixed(1) + 'M';
512
+ if (n >= 1e3) return (n/1e3).toFixed(1) + 'k';
513
+ return String(n);
514
+ }
515
+ function fmtDur(ms) {
516
+ if (ms == null) return '-';
517
+ if (ms < 1000) return ms.toFixed(0) + 'ms';
518
+ if (ms < 60000) return (ms/1000).toFixed(1) + 's';
519
+ const m = Math.floor(ms/60000);
520
+ const s = Math.round((ms % 60000)/1000);
521
+ return m + 'm ' + s + 's';
522
+ }
523
+ function fmtAgo(iso) {
524
+ if (!iso) return '-';
525
+ const d = new Date(iso);
526
+ const sec = (Date.now() - d.getTime()) / 1000;
527
+ if (sec < 60) return Math.floor(sec) + 's ago';
528
+ if (sec < 3600) return Math.floor(sec/60) + 'm ago';
529
+ if (sec < 86400) return Math.floor(sec/3600) + 'h ago';
530
+ return Math.floor(sec/86400) + 'd ago';
531
+ }
532
+ function spanKindClass(name) {
533
+ const n = (name || '').toLowerCase();
534
+ if (n.includes('agent') || n === 'invoke_agent') return 'agent';
535
+ if (n.includes('llm') || n.includes('invoke_llm') || n.includes('gen_ai.llm')) return 'llm';
536
+ if (n.includes('tool') || n.includes('invoke_tool') || n.includes('gen_ai.tool')) return 'tool';
537
+ return 'other';
538
+ }
539
+ function friendlySpanName(name) {
540
+ const map = {
541
+ 'gen_ai.llm.call': 'LLM Call',
542
+ 'gen_ai.tool.call': 'Tool Call',
543
+ 'invoke_agent': 'Agent Run',
544
+ 'invoke_llm': 'LLM Call',
545
+ 'invoke_tool': 'Tool Call',
546
+ 'openclaw.agent.turn': 'Agent Turn',
547
+ 'openclaw.request': 'Request',
548
+ 'openclaw.model.usage': 'Model Usage',
549
+ };
550
+ if (map[name]) return map[name];
551
+ if (name && name.startsWith('tool.')) return 'Tool: ' + name.slice(5);
552
+ if (!name) return '-';
553
+ // sentence case fallback
554
+ return name.charAt(0).toUpperCase() + name.slice(1).replace(/[._]/g, ' ');
555
+ }
556
+ function friendlyAlertType(type) {
557
+ const map = {
558
+ 'sensitive_action': 'Sensitive Action',
559
+ 'cost_budget_daily': 'Daily Budget',
560
+ 'cost_budget_session': 'Session Budget',
561
+ 'drift_detected': 'Drift Detected',
562
+ 'retry_loop': 'Retry Loop',
563
+ 'token_anomaly': 'Token Anomaly',
564
+ 'schema_violation': 'Schema Violation',
565
+ 'failure_rate': 'Failure Rate',
566
+ };
567
+ return map[type] || type;
568
+ }
569
+
570
+ // --- Router ---
571
+ function getView() {
572
+ const h = location.hash.replace('#/', '') || 'status';
573
+ const parts = h.split('/');
574
+ return { view: parts[0], param: parts[1] || null };
575
+ }
576
+
577
+ // ============================
578
+ // Status View
579
+ // ============================
580
+ function StatusView() {
581
+ const [data, setData] = useState(null);
582
+ const [alerts, setAlerts] = useState([]);
583
+
584
+ const load = useCallback(async () => {
585
+ const [s, a] = await Promise.all([
586
+ api('/status'),
587
+ api('/alerts', { since: '24h' }),
588
+ ]);
589
+ setData(s);
590
+ setAlerts((a.alerts || []).slice(0, 5));
591
+ }, []);
592
+
593
+ useEffect(() => { load(); const t = setInterval(load, 5000); return () => clearInterval(t); }, [load]);
594
+
595
+ if (!data) return html`<div class="empty">Loading...</div>`;
596
+ if (!data.agents.length) return html`<div class="empty">No agents found. Run an agent with ${'`@watch()`'} to start recording.<code>pip install tokenjuice && tj onboard</code></div>`;
597
+
598
+ return html`
599
+ <div class="page-title">Status</div>
600
+ <div style="font-size:12px;color:var(--text-dim);margin:-14px 0 16px;font-family:'IBM Plex Mono',monospace">Click on an agent tile for details</div>
601
+ <div class="cards">
602
+ ${data.agents.map(a => html`
603
+ <div class="card clickable" onClick=${() => { location.hash = '#/traces'; }}>
604
+ <div class="card-header">
605
+ <span class="status-dot ${a.status}"></span>
606
+ ${a.agent_id}
607
+ <span class="badge badge-${a.status}" style="margin-left:auto">${a.status}</span>
608
+ </div>
609
+ <div class="card-row"><span class="label">Cost today <span class="info-btn">i<span class="info-tip">Estimated from token usage. If you're on a subscription plan (e.g. Claude Max), your actual spend may differ.</span></span></span><span class="value">${fmtCost(a.cost_today)}</span></div>
610
+ <div class="card-row"><span class="label">Tokens</span><span class="value">${fmtTokens(a.input_tokens)} in / ${fmtTokens(a.output_tokens)} out</span></div>
611
+ <div class="card-row"><span class="label">Tool calls</span><span class="value">${a.tool_call_count}${a.error_count ? ' (' + a.error_count + ' failed)' : ''}</span></div>
612
+ ${a.duration_seconds != null ? html`<div class="card-row"><span class="label">Duration</span><span class="value">${fmtDur(a.duration_seconds * 1000)}</span></div>` : null}
613
+ ${a.last_span_time ? html`<div class="card-row"><span class="label">Last seen</span><span class="value">${fmtAgo(a.last_span_time)}</span></div>` : null}
614
+ ${a.active_alerts > 0 ? html`<div class="card-row"><span class="label">Alerts</span><span class="value" style="color:var(--error)">${a.active_alerts} active</span></div>` : null}
615
+ </div>
616
+ `)}
617
+ </div>
618
+ ${alerts.length > 0 ? html`
619
+ <div class="page-title" style="font-size:14px;margin-top:8px">Recent Alerts</div>
620
+ <div class="table-wrap"><table>
621
+ <thead><tr><th>Time</th><th>Severity</th><th>Type</th><th>Title</th><th>Agent</th></tr></thead>
622
+ <tbody>
623
+ ${alerts.map(a => html`
624
+ <tr><td>${fmtAgo(a.fired_at)}</td>
625
+ <td><span class="badge badge-${a.severity}">${a.severity}</span></td>
626
+ <td>${friendlyAlertType(a.type)}</td>
627
+ <td>${a.title}</td>
628
+ <td class="mono">${a.agent_id || '-'}</td></tr>
629
+ `)}
630
+ </tbody>
631
+ </table></div>
632
+ ` : null}
633
+ `;
634
+ }
635
+
636
+ // ============================
637
+ // Traces View
638
+ // ============================
639
+ function TracesListView() {
640
+ const [traces, setTraces] = useState([]);
641
+ const [agentStatus, setAgentStatus] = useState({});
642
+ const [since, setSince] = useState('24h');
643
+ const [statusF, setStatusF] = useState('');
644
+
645
+ const load = useCallback(async () => {
646
+ const [td, sd] = await Promise.all([
647
+ api('/traces', { since, status: statusF || undefined, limit: 100 }),
648
+ api('/status'),
649
+ ]);
650
+ setTraces(td.traces || []);
651
+ const map = {};
652
+ for (const a of (sd.agents || [])) map[a.agent_id] = a.status;
653
+ setAgentStatus(map);
654
+ }, [since, statusF]);
655
+
656
+ useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t); }, [load]);
657
+
658
+ return html`
659
+ <div class="page-title">Traces</div>
660
+ <div style="font-size:12px;color:var(--text-dim);margin:-14px 0 16px;font-family:'IBM Plex Mono',monospace">Click on a trace row for details</div>
661
+ <div class="filters">
662
+ <select value=${since} onChange=${e => setSince(e.target.value)}>
663
+ <option value="1h">Last 1h</option>
664
+ <option value="6h">Last 6h</option>
665
+ <option value="24h">Last 24h</option>
666
+ <option value="7d">Last 7d</option>
667
+ </select>
668
+ <select value=${statusF} onChange=${e => setStatusF(e.target.value)}>
669
+ <option value="">All results</option>
670
+ <option value="ok">ok</option>
671
+ <option value="error">error</option>
672
+ </select>
673
+ <button class="btn" onClick=${load}>Refresh</button>
674
+ </div>
675
+ ${traces.length === 0 ? html`<div class="empty">No traces yet. Run an agent with ${'`@watch()`'} to start recording.</div>` : html`
676
+ <div class="table-wrap"><table>
677
+ <thead><tr><th>Agent</th><th>Type</th><th>Time</th><th>Duration</th><th>Cost <span class="info-btn">i<span class="info-tip">Estimated from token usage. If you're on a subscription plan (e.g. Claude Max), your actual spend may differ.</span></span></th><th>Result <span class="info-btn">i<span class="info-tip">Shows "error" if even a single span in this trace failed</span></span></th><th>Status <span class="info-btn">i<span class="info-tip">Agent liveness: active (seen in last 5 min), stale (no recent spans), completed or idle</span></span></th><th>Spans</th><th></th></tr></thead>
678
+ <tbody>
679
+ ${dedup(traces).map(t => {
680
+ const st = agentStatus[t.agent_id];
681
+ return html`
682
+ <tr class="clickable" onClick=${() => location.hash = '#/traces/' + t.trace_id} title=${'Trace ' + t.trace_id}>
683
+ <td class="mono">${t.agent_id || '-'}</td>
684
+ <td>${friendlySpanName(t.name)}</td>
685
+ <td>${fmtAgo(t.start_time)}</td>
686
+ <td>${fmtDur(t.duration_ms)}</td>
687
+ <td>${fmtCost(t.cost_usd)}</td>
688
+ <td><span class="badge badge-${t.status_code}">${t.status_code}</span></td>
689
+ <td>${st ? html`<span class="status-dot ${st}" style="margin-right:5px"></span><span class="badge badge-${st}">${st}</span>` : html`<span style="color:var(--text-dim)">-</span>`}</td>
690
+ <td>${t.span_count}</td>
691
+ <td class="chevron">\u2192</td>
692
+ </tr>
693
+ `})}
694
+ </tbody>
695
+ </table></div>
696
+ `}
697
+ `;
698
+ }
699
+
700
+ function dedup(traces) {
701
+ const seen = new Set();
702
+ return traces.filter(t => {
703
+ if (seen.has(t.trace_id)) return false;
704
+ seen.add(t.trace_id);
705
+ return true;
706
+ });
707
+ }
708
+
709
+ // ============================
710
+ // Trace Detail + Waterfall
711
+ // ============================
712
+ function TraceDetailView({ traceId }) {
713
+ const [spans, setSpans] = useState([]);
714
+ const [selected, setSelected] = useState(null);
715
+
716
+ useEffect(() => {
717
+ api('/traces/' + traceId).then(d => setSpans(d.spans || []));
718
+ }, [traceId]);
719
+
720
+ if (!spans.length) return html`<div class="empty">Loading trace...</div>`;
721
+
722
+ // Build tree
723
+ const byId = {};
724
+ spans.forEach(s => byId[s.span_id] = s);
725
+ const children = {};
726
+ spans.forEach(s => {
727
+ const pid = s.parent_span_id || '__root__';
728
+ (children[pid] = children[pid] || []).push(s);
729
+ });
730
+ const spanIds = new Set(spans.map(s => s.span_id));
731
+ const roots = spans.filter(s => !s.parent_span_id || !spanIds.has(s.parent_span_id));
732
+
733
+ // Timeline bounds
734
+ const times = spans.map(s => new Date(s.start_time).getTime()).filter(t => !isNaN(t));
735
+ const endTimes = spans.map(s => s.end_time ? new Date(s.end_time).getTime() : new Date(s.start_time).getTime() + (s.duration_ms || 0)).filter(t => !isNaN(t));
736
+ const traceStart = Math.min(...times);
737
+ const traceEnd = Math.max(...endTimes);
738
+ const traceDur = traceEnd - traceStart || 1;
739
+
740
+ // Flatten tree for rendering
741
+ function flatten(nodeList, depth) {
742
+ let result = [];
743
+ for (const s of (nodeList || [])) {
744
+ result.push({ span: s, depth });
745
+ result = result.concat(flatten(children[s.span_id], depth + 1));
746
+ }
747
+ return result;
748
+ }
749
+ const rows = flatten(roots, 0);
750
+ const labelW = 100;
751
+
752
+ const sel = selected ? spans.find(s => s.span_id === selected) : null;
753
+
754
+ // Determine agent name from root span
755
+ const rootSpan = roots.length ? roots[0] : spans[0];
756
+ const agentName = rootSpan ? (rootSpan.agent_id || traceId.slice(0, 12)) : traceId.slice(0, 12);
757
+
758
+ return html`
759
+ <button class="btn btn-back" onClick=${() => location.hash = '#/traces'}>\u2190 Back to traces</button>
760
+ <div class="page-title">Trace details for ${agentName}</div>
761
+ <div style="font-size:12px;color:var(--text-dim);margin:-14px 0 16px;font-family:'IBM Plex Mono',monospace">Click a span for details</div>
762
+ <div class="waterfall">
763
+ ${rows.map(({ span: s, depth }) => {
764
+ const st = new Date(s.start_time).getTime() - traceStart;
765
+ const dur = s.duration_ms || 0;
766
+ const left = (st / traceDur * 100).toFixed(2);
767
+ const width = Math.max(dur / traceDur * 100, 3).toFixed(2);
768
+ const kind = spanKindClass(s.name);
769
+ const friendly = friendlySpanName(s.name);
770
+ const isAgent = kind === 'agent';
771
+ const detail = s.model || s.tool_name || '';
772
+ const costStr = s.cost_usd != null && s.cost_usd > 0 ? ' \u2014 ' + fmtCost(s.cost_usd) : '';
773
+ const barLabel = isAgent
774
+ ? fmtDur(s.duration_ms) + costStr
775
+ : (detail ? detail + ' \u2014 ' : '') + fmtDur(s.duration_ms) + costStr;
776
+ const tipLines = [friendly];
777
+ tipLines.push('Duration: ' + fmtDur(s.duration_ms));
778
+ if (s.provider) tipLines.push('Provider: ' + s.provider);
779
+ if (s.model) tipLines.push('Model: ' + s.model);
780
+ if (s.tool_name) tipLines.push('Tool: ' + s.tool_name);
781
+ if (s.input_tokens != null) tipLines.push('Input tokens: ' + fmtTokens(s.input_tokens));
782
+ if (s.output_tokens != null) tipLines.push('Output tokens: ' + fmtTokens(s.output_tokens));
783
+ if (s.cost_usd != null) tipLines.push('Cost: ' + fmtCost(s.cost_usd));
784
+ const tooltip = tipLines.join('\n');
785
+ return html`
786
+ <div class="wf-row ${selected === s.span_id ? 'selected' : ''}" onClick=${() => setSelected(s.span_id)}>
787
+ <div class="wf-label" style="width:${labelW}px;padding-left:${depth * 16 + 8}px">${friendly}</div>
788
+ <div class="wf-track">
789
+ <div class="wf-bar-wrap${left > 50 ? ' tip-flip' : ''}" style="left:${left}%;width:${width}%">
790
+ <div class="wf-bar ${kind}" style="width:100%">${barLabel}</div>
791
+ <div class="wf-tip">${tooltip}</div>
792
+ </div>
793
+ </div>
794
+ </div>
795
+ `;
796
+ })}
797
+ </div>
798
+ ${sel ? html`
799
+ <div class="span-detail">
800
+ <h3>${friendlySpanName(sel.name)}</h3>
801
+ <div style="color:var(--text-dim);font-size:11px;margin:-8px 0 12px;font-family:'IBM Plex Mono',monospace">${sel.name}</div>
802
+ <div class="detail-grid">
803
+ <span class="dk">Duration</span><span class="dv">${fmtDur(sel.duration_ms)}</span>
804
+ <span class="dk">Status</span><span class="dv">${sel.status_code}</span>
805
+ <span class="dk">Span ID</span><span class="dv">${sel.span_id}</span>
806
+ ${sel.provider ? html`<span class="dk">Provider</span><span class="dv">${sel.provider}</span>` : null}
807
+ ${sel.model ? html`<span class="dk">Model</span><span class="dv">${sel.model}</span>` : null}
808
+ ${sel.tool_name ? html`<span class="dk">Tool</span><span class="dv">${sel.tool_name}</span>` : null}
809
+ ${sel.input_tokens != null ? html`<span class="dk">Input tokens</span><span class="dv">${fmtTokens(sel.input_tokens)}</span>` : null}
810
+ ${sel.output_tokens != null ? html`<span class="dk">Output tokens</span><span class="dv">${fmtTokens(sel.output_tokens)}</span>` : null}
811
+ ${sel.cost_usd != null ? html`<span class="dk">Cost</span><span class="dv">${fmtCost(sel.cost_usd)}</span>` : null}
812
+ ${sel.agent_id ? html`<span class="dk">Agent</span><span class="dv">${sel.agent_id}</span>` : null}
813
+ ${sel.conversation_id ? html`<span class="dk">Conversation</span><span class="dv">${sel.conversation_id}</span>` : null}
814
+ ${sel.session_id ? html`<span class="dk">Session</span><span class="dv">${sel.session_id}</span>` : null}
815
+ <span class="dk">Trace ID</span><span class="dv">${sel.trace_id}</span>
816
+ ${sel.attributes && Object.keys(sel.attributes).length > 0 ? html`
817
+ <span class="dk">Attributes</span><span class="dv"><pre style="margin:0;white-space:pre-wrap;font-size:11px">${JSON.stringify(sel.attributes, null, 2)}</pre></span>
818
+ ` : null}
819
+ </div>
820
+ </div>
821
+ ` : null}
822
+ `;
823
+ }
824
+
825
+ // ============================
826
+ // Cost View
827
+ // ============================
828
+ function CostView() {
829
+ const [rows, setRows] = useState([]);
830
+ const [total, setTotal] = useState(0);
831
+ const [since, setSince] = useState('7d');
832
+ const [groupBy, setGroupBy] = useState('day');
833
+
834
+ const load = useCallback(async () => {
835
+ const d = await api('/cost', { since, group_by: groupBy });
836
+ setRows(d.rows || []);
837
+ setTotal(d.total_cost_usd || 0);
838
+ }, [since, groupBy]);
839
+
840
+ useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t); }, [load]);
841
+
842
+ const totalIn = rows.reduce((s, r) => s + (r.input_tokens || 0), 0);
843
+ const totalOut = rows.reduce((s, r) => s + (r.output_tokens || 0), 0);
844
+
845
+ return html`
846
+ <div class="page-title">Cost</div>
847
+ <div class="filters">
848
+ <select value=${since} onChange=${e => setSince(e.target.value)}>
849
+ <option value="1h">Last 1h</option>
850
+ <option value="6h">Last 6h</option>
851
+ <option value="24h">Last 24h</option>
852
+ <option value="7d">Last 7d</option>
853
+ <option value="30d">Last 30d</option>
854
+ </select>
855
+ <select value=${groupBy} onChange=${e => setGroupBy(e.target.value)}>
856
+ <option value="day">By day</option>
857
+ <option value="agent">By agent</option>
858
+ <option value="model">By model</option>
859
+ <option value="tool">By tool</option>
860
+ </select>
861
+ <button class="btn" onClick=${load}>Refresh</button>
862
+ </div>
863
+ <div class="summary">
864
+ <div class="summary-item"><div class="summary-label">Total cost <span class="info-btn">i<span class="info-tip">Estimated from token usage. If you're on a subscription plan (e.g. Claude Max), your actual spend may differ.</span></span></div><div class="summary-value" style="color:var(--accent)">${fmtCost(total)}</div></div>
865
+ <div class="summary-item"><div class="summary-label">Input tokens</div><div class="summary-value">${fmtTokens(totalIn)}</div></div>
866
+ <div class="summary-item"><div class="summary-label">Output tokens</div><div class="summary-value">${fmtTokens(totalOut)}</div></div>
867
+ </div>
868
+ ${rows.length === 0 ? html`<div class="empty">No cost data for the selected period.</div>` : html`
869
+ <div class="table-wrap"><table>
870
+ <thead><tr>
871
+ <th>${groupBy === 'day' ? 'Date' : groupBy === 'agent' ? 'Agent' : groupBy === 'model' ? 'Model' : 'Tool'}</th>
872
+ ${groupBy !== 'agent' ? html`<th>Agent</th>` : null}
873
+ ${groupBy !== 'model' ? html`<th>Model</th>` : null}
874
+ <th>Input</th><th>Output</th><th>Cost <span class="info-btn">i<span class="info-tip">Estimated from token usage. If you're on a subscription plan (e.g. Claude Max), your actual spend may differ.</span></span></th>
875
+ </tr></thead>
876
+ <tbody>
877
+ ${rows.map(r => html`
878
+ <tr>
879
+ <td class="mono">${r.group || '-'}</td>
880
+ ${groupBy !== 'agent' ? html`<td class="mono">${r.agent_id || '-'}</td>` : null}
881
+ ${groupBy !== 'model' ? html`<td class="mono">${r.model || '-'}</td>` : null}
882
+ <td>${fmtTokens(r.input_tokens)}</td>
883
+ <td>${fmtTokens(r.output_tokens)}</td>
884
+ <td>${fmtCost(r.cost_usd)}</td>
885
+ </tr>
886
+ `)}
887
+ <tr style="font-weight:600;border-top:2px solid var(--border)">
888
+ <td colspan=${1 + (groupBy !== 'agent' ? 1 : 0) + (groupBy !== 'model' ? 1 : 0)}></td>
889
+ <td>${fmtTokens(totalIn)}</td>
890
+ <td>${fmtTokens(totalOut)}</td>
891
+ <td>${fmtCost(total)}</td>
892
+ </tr>
893
+ </tbody>
894
+ </table></div>
895
+ `}
896
+ `;
897
+ }
898
+
899
+ // ============================
900
+ // Alerts View
901
+ // ============================
902
+ function AlertsView() {
903
+ const [alerts, setAlerts] = useState([]);
904
+ const [severity, setSeverity] = useState('');
905
+ const [expanded, setExpanded] = useState(null);
906
+
907
+ const load = useCallback(async () => {
908
+ const d = await api('/alerts', { severity: severity || undefined });
909
+ setAlerts(d.alerts || []);
910
+ }, [severity]);
911
+
912
+ useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t); }, [load]);
913
+
914
+ return html`
915
+ <div class="page-title">Alerts</div>
916
+ <div class="filters">
917
+ <select value=${severity} onChange=${e => setSeverity(e.target.value)}>
918
+ <option value="">All severities</option>
919
+ <option value="critical">Critical</option>
920
+ <option value="warning">Warning</option>
921
+ <option value="info">Info</option>
922
+ </select>
923
+ <button class="btn" onClick=${load}>Refresh</button>
924
+ </div>
925
+ ${alerts.length === 0 ? html`<div class="empty">No alerts fired. Configure sensitive actions in ${'`.ocw/config.toml`'}.</div>` : html`
926
+ <div class="table-wrap"><table>
927
+ <thead><tr><th></th><th>Time</th><th>Severity</th><th>Type</th><th>Title</th><th>Agent</th></tr></thead>
928
+ <tbody>
929
+ ${alerts.map(a => html`
930
+ <tr class="clickable" onClick=${() => setExpanded(expanded === a.alert_id ? null : a.alert_id)}>
931
+ <td><span class="expand-toggle ${expanded === a.alert_id ? 'open' : ''}">\u25B8</span></td>
932
+ <td>${fmtAgo(a.fired_at)}</td>
933
+ <td><span class="badge badge-${a.severity}">${a.severity}</span></td>
934
+ <td>${friendlyAlertType(a.type)}</td>
935
+ <td>${a.title}</td>
936
+ <td class="mono">${a.agent_id || '-'}</td>
937
+ </tr>
938
+ ${expanded === a.alert_id ? html`<tr><td colspan="6"><div class="alert-detail">${JSON.stringify(a.detail, null, 2)}</div></td></tr>` : null}
939
+ `)}
940
+ </tbody>
941
+ </table></div>
942
+ `}
943
+ `;
944
+ }
945
+
946
+ // ============================
947
+ // Drift View
948
+ // ============================
949
+ function DriftView() {
950
+ const [data, setData] = useState(null);
951
+
952
+ useEffect(() => {
953
+ api('/drift').then(d => setData(d));
954
+ const t = setInterval(() => api('/drift').then(d => setData(d)), 10000);
955
+ return () => clearInterval(t);
956
+ }, []);
957
+
958
+ if (!data) return html`<div class="empty">Loading...</div>`;
959
+
960
+ const agents = data.agents || [];
961
+ if (!agents.length) return html`<div class="empty">No baselines built yet. Run at least 10 sessions to establish a behavioral baseline.</div>`;
962
+
963
+ const metrics = [
964
+ ['Input tokens', 'avg_input_tokens', 'stddev_input_tokens', 'input_tokens'],
965
+ ['Output tokens', 'avg_output_tokens', 'stddev_output_tokens', 'output_tokens'],
966
+ ['Session duration', 'avg_session_duration_s', 'stddev_session_duration', 'duration_seconds'],
967
+ ['Tool calls', 'avg_tool_call_count', 'stddev_tool_call_count', 'tool_call_count'],
968
+ ];
969
+
970
+ return html`
971
+ <div class="page-title">Drift Detection</div>
972
+ ${agents.map(a => html`
973
+ <div class="drift-section">
974
+ <h3>${a.agent_id}</h3>
975
+ ${!a.baseline ? html`<div style="color:var(--text-dim);font-size:13px">No baseline yet.</div>` : html`
976
+ <div style="font-size:12px;color:var(--text-dim);margin-bottom:12px;font-family:'IBM Plex Mono',monospace">
977
+ Baseline: ${a.baseline.sessions_sampled} sessions sampled${a.baseline.computed_at ? ', computed ' + fmtAgo(a.baseline.computed_at) : ''}
978
+ </div>
979
+ <table>
980
+ <thead><tr><th>Metric</th><th>Baseline</th><th>Latest</th><th>Z-score (threshold: 2.0\u03C3)</th><th></th></tr></thead>
981
+ <tbody>
982
+ ${metrics.map(([label, avgKey, stdKey, latestKey]) => {
983
+ const avg = a.baseline[avgKey];
984
+ const std = a.baseline[stdKey];
985
+ const latest = a.latest_session ? a.latest_session[latestKey] : null;
986
+ let z = null;
987
+ let zDisplay = '-';
988
+ if (avg != null && latest != null) {
989
+ if (std != null && std > 0) {
990
+ z = (latest - avg) / std;
991
+ zDisplay = z.toFixed(2);
992
+ } else if (std === 0 || std == null) {
993
+ // Match drift.py z_score: inf when stddev=0 and value != mean
994
+ if (latest !== avg) {
995
+ z = Infinity;
996
+ zDisplay = 'inf';
997
+ } else {
998
+ z = 0;
999
+ zDisplay = '0.00';
1000
+ }
1001
+ }
1002
+ }
1003
+ const pass = z == null || (Number.isFinite(z) && Math.abs(z) < 2);
1004
+ return html`
1005
+ <tr>
1006
+ <td>${label}</td>
1007
+ <td class="mono">${avg != null ? avg.toFixed(1) : '-'}${std != null ? ' +/- ' + std.toFixed(1) : ''}</td>
1008
+ <td class="mono">${latest != null ? (typeof latest === 'number' ? latest.toFixed(1) : latest) : '-'}</td>
1009
+ <td class="mono" style="color:${pass ? 'var(--success)' : 'var(--error)'}">${zDisplay}</td>
1010
+ <td><span class="badge ${pass ? 'badge-ok' : 'badge-critical'}">${pass ? 'pass' : 'drift'}</span></td>
1011
+ </tr>
1012
+ `;
1013
+ })}
1014
+ </tbody>
1015
+ </table>
1016
+ `}
1017
+ </div>
1018
+ `)}
1019
+ `;
1020
+ }
1021
+
1022
+ // ============================
1023
+ // Budget View
1024
+ // ============================
1025
+ function BudgetRow({ scope, label, daily, session, effectiveDaily, effectiveSession, onSave }) {
1026
+ const [dailyVal, setDailyVal] = useState(daily != null ? String(daily) : '');
1027
+ const [sessionVal, setSessionVal] = useState(session != null ? String(session) : '');
1028
+ const [saving, setSaving] = useState(false);
1029
+ const [saved, setSaved] = useState(false);
1030
+
1031
+ const save = async () => {
1032
+ setSaving(true);
1033
+ try {
1034
+ await onSave(scope, dailyVal, sessionVal);
1035
+ setSaved(true);
1036
+ setTimeout(() => setSaved(false), 2000);
1037
+ } catch (e) {
1038
+ alert('Save failed: ' + e.message);
1039
+ } finally {
1040
+ setSaving(false);
1041
+ }
1042
+ };
1043
+
1044
+ const dailyPlaceholder = (effectiveDaily != null && daily == null)
1045
+ ? `default: $${effectiveDaily}` : 'no limit';
1046
+ const sessionPlaceholder = (effectiveSession != null && session == null)
1047
+ ? `default: $${effectiveSession}` : 'no limit';
1048
+
1049
+ return html`
1050
+ <tr>
1051
+ <td class="mono">${label}</td>
1052
+ <td>
1053
+ <input type="number" min="0" step="0.01" placeholder=${dailyPlaceholder}
1054
+ value=${dailyVal} onInput=${e => setDailyVal(e.target.value)}
1055
+ style="width:120px;background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:4px 6px;color:var(--text);font-size:13px" />
1056
+ </td>
1057
+ <td>
1058
+ <input type="number" min="0" step="0.01" placeholder=${sessionPlaceholder}
1059
+ value=${sessionVal} onInput=${e => setSessionVal(e.target.value)}
1060
+ style="width:120px;background:var(--surface);border:1px solid var(--border);border-radius:4px;padding:4px 6px;color:var(--text);font-size:13px" />
1061
+ </td>
1062
+ <td>
1063
+ <button class="btn" onClick=${save} disabled=${saving}
1064
+ style="padding:4px 12px;font-size:12px">
1065
+ ${saving ? 'Saving...' : saved ? '\u2713 Saved' : 'Save'}
1066
+ </button>
1067
+ </td>
1068
+ </tr>
1069
+ `;
1070
+ }
1071
+
1072
+ function BudgetView() {
1073
+ const [data, setData] = useState(null);
1074
+ const [alerts, setAlerts] = useState([]);
1075
+ const [error, setError] = useState(null);
1076
+
1077
+ const load = () => Promise.all([
1078
+ api('/budget'),
1079
+ api('/alerts', { type: 'cost_budget_daily', since: '24h' })
1080
+ .then(d => d.alerts || [])
1081
+ .catch(() => []),
1082
+ api('/alerts', { type: 'cost_budget_session', since: '24h' })
1083
+ .then(d => d.alerts || [])
1084
+ .catch(() => []),
1085
+ ]).then(([budget, daily, session]) => {
1086
+ setData(budget);
1087
+ setAlerts([...daily, ...session].sort((a, b) => b.fired_at.localeCompare(a.fired_at)));
1088
+ }).catch(e => setError(e.message));
1089
+
1090
+ useEffect(() => { load(); const t = setInterval(load, 10000); return () => clearInterval(t); }, []);
1091
+
1092
+ const handleSave = async (scope, dailyStr, sessionStr) => {
1093
+ // Empty field means "no limit" — send 0 so the server runs validate_budget_value(0) → None.
1094
+ const daily = dailyStr !== '' ? parseFloat(dailyStr) : 0;
1095
+ const session = sessionStr !== '' ? parseFloat(sessionStr) : 0;
1096
+ const updated = await apiPost('/budget', {
1097
+ scope,
1098
+ daily_usd: daily,
1099
+ session_usd: session,
1100
+ });
1101
+ setData(updated);
1102
+ };
1103
+
1104
+ if (error) return html`<div class="empty" style="color:var(--error)">${error}</div>`;
1105
+ if (!data) return html`<div class="empty">Loading...</div>`;
1106
+
1107
+ const agentEntries = Object.entries(data.agents || {});
1108
+
1109
+ return html`
1110
+ <div class="page-title">Budget</div>
1111
+
1112
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px">Budget limits</div>
1113
+ <div style="color:var(--text-dim);font-size:12px;margin-bottom:12px">
1114
+ Clear a field or enter 0 to remove a limit. Changes are written to your config file immediately.
1115
+ </div>
1116
+ <div class="table-wrap"><table>
1117
+ <thead><tr><th>Scope</th><th>Daily (USD)</th><th>Per-session (USD)</th><th></th></tr></thead>
1118
+ <tbody>
1119
+ <${BudgetRow}
1120
+ scope="defaults"
1121
+ label="defaults"
1122
+ daily=${data.defaults.daily_usd}
1123
+ session=${data.defaults.session_usd}
1124
+ onSave=${handleSave}
1125
+ />
1126
+ ${agentEntries.map(([id, b]) => html`
1127
+ <${BudgetRow}
1128
+ key=${id}
1129
+ scope=${id}
1130
+ label=${id}
1131
+ daily=${b.configured.daily_usd}
1132
+ session=${b.configured.session_usd}
1133
+ effectiveDaily=${b.effective.daily_usd}
1134
+ effectiveSession=${b.effective.session_usd}
1135
+ onSave=${handleSave}
1136
+ />
1137
+ `)}
1138
+ ${agentEntries.length === 0 ? html`
1139
+ <tr><td colspan="4" style="color:var(--text-dim);font-size:13px;padding:12px 0">
1140
+ No agents configured yet. Run your agent once to register it.
1141
+ </td></tr>
1142
+ ` : null}
1143
+ </tbody>
1144
+ </table></div>
1145
+
1146
+ ${alerts.length === 0
1147
+ ? html`<div class="empty" style="margin-top:20px">No budget alerts in the last 24h.</div>`
1148
+ : html`
1149
+ <div style="margin-top:24px">
1150
+ <div style="font-size:13px;font-weight:600;margin-bottom:8px;color:var(--error)">
1151
+ Budget alerts (last 24h)
1152
+ </div>
1153
+ <div class="table-wrap"><table>
1154
+ <thead><tr><th>Time</th><th>Severity</th><th>Type</th><th>Agent</th><th>Detail</th></tr></thead>
1155
+ <tbody>
1156
+ ${alerts.map(a => html`
1157
+ <tr>
1158
+ <td>${fmtAgo(a.fired_at)}</td>
1159
+ <td><span class="badge badge-${a.severity}">${a.severity}</span></td>
1160
+ <td>${friendlyAlertType(a.type)}</td>
1161
+ <td class="mono">${a.agent_id || '-'}</td>
1162
+ <td style="color:var(--text-dim);font-size:12px">${a.detail.message || ''}</td>
1163
+ </tr>
1164
+ `)}
1165
+ </tbody>
1166
+ </table></div>
1167
+ </div>
1168
+ `}
1169
+ `;
1170
+ }
1171
+
1172
+ // ============================
1173
+ // App Router
1174
+ // ============================
1175
+ function App() {
1176
+ const [route, setRoute] = useState(getView());
1177
+
1178
+ useEffect(() => {
1179
+ const handler = () => setRoute(getView());
1180
+ window.addEventListener('hashchange', handler);
1181
+ return () => window.removeEventListener('hashchange', handler);
1182
+ }, []);
1183
+
1184
+ // Update active nav link
1185
+ useEffect(() => {
1186
+ document.querySelectorAll('.nav-link').forEach(el => {
1187
+ el.classList.toggle('active', el.dataset.view === route.view);
1188
+ });
1189
+ }, [route.view]);
1190
+
1191
+ // Default to status
1192
+ if (!location.hash || location.hash === '#/') {
1193
+ location.hash = '#/status';
1194
+ return null;
1195
+ }
1196
+
1197
+ switch (route.view) {
1198
+ case 'status': return html`<${StatusView} />`;
1199
+ case 'traces':
1200
+ if (route.param) return html`<${TraceDetailView} traceId=${route.param} />`;
1201
+ return html`<${TracesListView} />`;
1202
+ case 'cost': return html`<${CostView} />`;
1203
+ case 'alerts': return html`<${AlertsView} />`;
1204
+ case 'drift': return html`<${DriftView} />`;
1205
+ case 'budget': return html`<${BudgetView} />`;
1206
+ default: return html`<div class="empty">Page not found.</div>`;
1207
+ }
1208
+ }
1209
+
1210
+ render(html`<${App} />`, document.getElementById('app'));
1211
+ </script>
1212
+ </body>
1213
+ </html>