zozul-cli 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.
Files changed (139) hide show
  1. package/.env.example +44 -0
  2. package/.github/workflows/publish.yml +26 -0
  3. package/DEVELOPMENT.md +288 -0
  4. package/LICENSE +201 -0
  5. package/README.md +178 -0
  6. package/dist/cli/commands.d.ts +3 -0
  7. package/dist/cli/commands.d.ts.map +1 -0
  8. package/dist/cli/commands.js +307 -0
  9. package/dist/cli/commands.js.map +1 -0
  10. package/dist/cli/format.d.ts +5 -0
  11. package/dist/cli/format.d.ts.map +1 -0
  12. package/dist/cli/format.js +115 -0
  13. package/dist/cli/format.js.map +1 -0
  14. package/dist/context/index.d.ts +8 -0
  15. package/dist/context/index.d.ts.map +1 -0
  16. package/dist/context/index.js +37 -0
  17. package/dist/context/index.js.map +1 -0
  18. package/dist/dashboard/html.d.ts +17 -0
  19. package/dist/dashboard/html.d.ts.map +1 -0
  20. package/dist/dashboard/html.js +79 -0
  21. package/dist/dashboard/html.js.map +1 -0
  22. package/dist/dashboard/index.html +1245 -0
  23. package/dist/hooks/config.d.ts +19 -0
  24. package/dist/hooks/config.d.ts.map +1 -0
  25. package/dist/hooks/config.js +106 -0
  26. package/dist/hooks/config.js.map +1 -0
  27. package/dist/hooks/git.d.ts +6 -0
  28. package/dist/hooks/git.d.ts.map +1 -0
  29. package/dist/hooks/git.js +73 -0
  30. package/dist/hooks/git.js.map +1 -0
  31. package/dist/hooks/index.d.ts +4 -0
  32. package/dist/hooks/index.d.ts.map +1 -0
  33. package/dist/hooks/index.js +3 -0
  34. package/dist/hooks/index.js.map +1 -0
  35. package/dist/hooks/server.d.ts +16 -0
  36. package/dist/hooks/server.d.ts.map +1 -0
  37. package/dist/hooks/server.js +349 -0
  38. package/dist/hooks/server.js.map +1 -0
  39. package/dist/index.d.ts +3 -0
  40. package/dist/index.d.ts.map +1 -0
  41. package/dist/index.js +6 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/otel/config.d.ts +36 -0
  44. package/dist/otel/config.d.ts.map +1 -0
  45. package/dist/otel/config.js +109 -0
  46. package/dist/otel/config.js.map +1 -0
  47. package/dist/otel/index.d.ts +4 -0
  48. package/dist/otel/index.d.ts.map +1 -0
  49. package/dist/otel/index.js +3 -0
  50. package/dist/otel/index.js.map +1 -0
  51. package/dist/otel/receiver.d.ts +10 -0
  52. package/dist/otel/receiver.d.ts.map +1 -0
  53. package/dist/otel/receiver.js +155 -0
  54. package/dist/otel/receiver.js.map +1 -0
  55. package/dist/parser/index.d.ts +4 -0
  56. package/dist/parser/index.d.ts.map +1 -0
  57. package/dist/parser/index.js +3 -0
  58. package/dist/parser/index.js.map +1 -0
  59. package/dist/parser/ingest.d.ts +20 -0
  60. package/dist/parser/ingest.d.ts.map +1 -0
  61. package/dist/parser/ingest.js +98 -0
  62. package/dist/parser/ingest.js.map +1 -0
  63. package/dist/parser/jsonl.d.ts +14 -0
  64. package/dist/parser/jsonl.d.ts.map +1 -0
  65. package/dist/parser/jsonl.js +202 -0
  66. package/dist/parser/jsonl.js.map +1 -0
  67. package/dist/parser/types.d.ts +81 -0
  68. package/dist/parser/types.d.ts.map +1 -0
  69. package/dist/parser/types.js +9 -0
  70. package/dist/parser/types.js.map +1 -0
  71. package/dist/parser/watcher.d.ts +16 -0
  72. package/dist/parser/watcher.d.ts.map +1 -0
  73. package/dist/parser/watcher.js +103 -0
  74. package/dist/parser/watcher.js.map +1 -0
  75. package/dist/pricing/index.d.ts +2 -0
  76. package/dist/pricing/index.d.ts.map +1 -0
  77. package/dist/pricing/index.js +37 -0
  78. package/dist/pricing/index.js.map +1 -0
  79. package/dist/service/index.d.ts +31 -0
  80. package/dist/service/index.d.ts.map +1 -0
  81. package/dist/service/index.js +252 -0
  82. package/dist/service/index.js.map +1 -0
  83. package/dist/storage/db.d.ts +75 -0
  84. package/dist/storage/db.d.ts.map +1 -0
  85. package/dist/storage/db.js +117 -0
  86. package/dist/storage/db.js.map +1 -0
  87. package/dist/storage/index.d.ts +4 -0
  88. package/dist/storage/index.d.ts.map +1 -0
  89. package/dist/storage/index.js +3 -0
  90. package/dist/storage/index.js.map +1 -0
  91. package/dist/storage/repo.d.ts +162 -0
  92. package/dist/storage/repo.d.ts.map +1 -0
  93. package/dist/storage/repo.js +472 -0
  94. package/dist/storage/repo.js.map +1 -0
  95. package/dist/sync/client.d.ts +24 -0
  96. package/dist/sync/client.d.ts.map +1 -0
  97. package/dist/sync/client.js +41 -0
  98. package/dist/sync/client.js.map +1 -0
  99. package/dist/sync/index.d.ts +18 -0
  100. package/dist/sync/index.d.ts.map +1 -0
  101. package/dist/sync/index.js +135 -0
  102. package/dist/sync/index.js.map +1 -0
  103. package/dist/sync/sync.test.d.ts +2 -0
  104. package/dist/sync/sync.test.d.ts.map +1 -0
  105. package/dist/sync/sync.test.js +412 -0
  106. package/dist/sync/sync.test.js.map +1 -0
  107. package/dist/sync/transform.d.ts +80 -0
  108. package/dist/sync/transform.d.ts.map +1 -0
  109. package/dist/sync/transform.js +90 -0
  110. package/dist/sync/transform.js.map +1 -0
  111. package/package.json +50 -0
  112. package/src/cli/commands.ts +332 -0
  113. package/src/cli/format.ts +133 -0
  114. package/src/context/index.ts +42 -0
  115. package/src/dashboard/html.ts +97 -0
  116. package/src/dashboard/index.html +1245 -0
  117. package/src/hooks/config.ts +119 -0
  118. package/src/hooks/git.ts +77 -0
  119. package/src/hooks/index.ts +7 -0
  120. package/src/hooks/server.ts +397 -0
  121. package/src/index.ts +6 -0
  122. package/src/otel/config.ts +141 -0
  123. package/src/otel/index.ts +8 -0
  124. package/src/otel/receiver.ts +183 -0
  125. package/src/parser/index.ts +3 -0
  126. package/src/parser/ingest.ts +119 -0
  127. package/src/parser/jsonl.ts +241 -0
  128. package/src/parser/types.ts +89 -0
  129. package/src/parser/watcher.ts +116 -0
  130. package/src/pricing/index.ts +51 -0
  131. package/src/service/index.ts +272 -0
  132. package/src/storage/db.ts +198 -0
  133. package/src/storage/index.ts +3 -0
  134. package/src/storage/repo.ts +601 -0
  135. package/src/sync/client.ts +63 -0
  136. package/src/sync/index.ts +207 -0
  137. package/src/sync/sync.test.ts +447 -0
  138. package/src/sync/transform.ts +184 -0
  139. package/tsconfig.json +19 -0
@@ -0,0 +1,1245 @@
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
+ <title>zozul — Agent Observability</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4"></script>
8
+ <style>
9
+ :root {
10
+ --bg: #0f1117;
11
+ --surface: #1a1d27;
12
+ --surface2: #21253a;
13
+ --border: #2a2d3a;
14
+ --text: #e0e0e6;
15
+ --text-dim: #8b8fa3;
16
+ --accent: #7c6ef0;
17
+ --accent-light: #9d8ff7;
18
+ --green: #4caf50;
19
+ --orange: #ff9800;
20
+ --red: #ef5350;
21
+ --blue: #42a5f5;
22
+ --mono: "JetBrains Mono", "Fira Code", "Cascadia Code", ui-monospace, monospace;
23
+ }
24
+ * { margin: 0; padding: 0; box-sizing: border-box; }
25
+ body {
26
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
27
+ background: var(--bg);
28
+ color: var(--text);
29
+ line-height: 1.5;
30
+ }
31
+ a { color: var(--accent-light); text-decoration: none; }
32
+ a:hover { text-decoration: underline; }
33
+ code { font-family: var(--mono); background: var(--surface2); padding: 1px 5px; border-radius: 3px; font-size: 0.9em; }
34
+
35
+ .header {
36
+ border-bottom: 1px solid var(--border);
37
+ padding: 12px 24px;
38
+ display: flex;
39
+ align-items: center;
40
+ gap: 16px;
41
+ position: sticky;
42
+ top: 0;
43
+ background: var(--bg);
44
+ z-index: 10;
45
+ }
46
+ .header h1 { font-size: 18px; font-weight: 600; }
47
+ .header .subtitle { color: var(--text-dim); font-size: 13px; }
48
+ .header-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
49
+ .auto-btn {
50
+ background: none;
51
+ border: 1px solid var(--border);
52
+ color: var(--text-dim);
53
+ padding: 4px 12px;
54
+ border-radius: 6px;
55
+ cursor: pointer;
56
+ font-size: 12px;
57
+ display: flex;
58
+ align-items: center;
59
+ gap: 6px;
60
+ }
61
+ .auto-btn:hover { border-color: var(--accent); color: var(--text); }
62
+ .live-dot {
63
+ width: 7px; height: 7px; border-radius: 50%;
64
+ background: var(--green);
65
+ flex-shrink: 0;
66
+ }
67
+ @keyframes spin { to { transform: rotate(360deg); } }
68
+ .auto-btn.loading .live-dot {
69
+ background: transparent;
70
+ border: 2px solid rgba(76,175,80,0.3);
71
+ border-top-color: var(--green);
72
+ animation: spin 0.6s linear infinite;
73
+ width: 9px; height: 9px;
74
+ }
75
+
76
+ .container { max-width: 1280px; margin: 0 auto; padding: 24px; }
77
+
78
+ .stats-grid {
79
+ display: grid;
80
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
81
+ gap: 12px;
82
+ margin-bottom: 28px;
83
+ }
84
+ .stat-card {
85
+ background: var(--surface);
86
+ border: 1px solid var(--border);
87
+ border-radius: 8px;
88
+ padding: 14px 16px;
89
+ }
90
+ .stat-card .label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; }
91
+ .stat-card .value { font-size: 22px; font-weight: 600; margin-top: 4px; font-variant-numeric: tabular-nums; font-family: var(--mono); }
92
+
93
+ .charts-grid {
94
+ display: grid;
95
+ grid-template-columns: 1fr 1fr;
96
+ gap: 16px;
97
+ margin-bottom: 28px;
98
+ }
99
+ .chart-card.full-width { grid-column: 1 / -1; }
100
+ .chart-card {
101
+ background: var(--surface);
102
+ border: 1px solid var(--border);
103
+ border-radius: 8px;
104
+ padding: 16px;
105
+ }
106
+ .chart-card h3 { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 12px; }
107
+ .chart-card canvas { width: 100% !important; }
108
+
109
+ .panel {
110
+ background: var(--surface);
111
+ border: 1px solid var(--border);
112
+ border-radius: 8px;
113
+ margin-bottom: 20px;
114
+ overflow: hidden;
115
+ }
116
+ .panel-header {
117
+ padding: 10px 16px;
118
+ border-bottom: 1px solid var(--border);
119
+ font-size: 12px;
120
+ font-weight: 600;
121
+ text-transform: uppercase;
122
+ letter-spacing: 0.06em;
123
+ color: var(--text-dim);
124
+ display: flex;
125
+ align-items: center;
126
+ justify-content: space-between;
127
+ gap: 12px;
128
+ }
129
+ .panel-filter {
130
+ background: var(--bg);
131
+ border: 1px solid var(--border);
132
+ border-radius: 5px;
133
+ color: var(--text);
134
+ font-size: 12px;
135
+ padding: 4px 10px;
136
+ width: 220px;
137
+ outline: none;
138
+ font-family: var(--mono);
139
+ }
140
+ .panel-filter:focus { border-color: var(--accent); }
141
+ .panel-filter::placeholder { color: var(--text-dim); }
142
+
143
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
144
+ th {
145
+ text-align: left;
146
+ padding: 8px 14px;
147
+ color: var(--text-dim);
148
+ font-weight: 500;
149
+ font-size: 11px;
150
+ text-transform: uppercase;
151
+ letter-spacing: 0.05em;
152
+ border-bottom: 1px solid var(--border);
153
+ white-space: nowrap;
154
+ }
155
+ td { padding: 9px 14px; border-bottom: 1px solid var(--border); font-variant-numeric: tabular-nums; }
156
+ tr:last-child td { border-bottom: none; }
157
+ tr.clickable:hover td { background: rgba(124, 110, 240, 0.05); cursor: pointer; }
158
+
159
+ .session-id {
160
+ font-family: var(--mono);
161
+ font-size: 12px;
162
+ color: var(--text-dim);
163
+ cursor: pointer;
164
+ display: inline-flex;
165
+ align-items: center;
166
+ gap: 4px;
167
+ }
168
+ .session-id:hover { color: var(--accent-light); }
169
+ .copy-icon { opacity: 0; font-size: 10px; transition: opacity 0.15s; }
170
+ .session-id:hover .copy-icon { opacity: 1; }
171
+ .copied-toast {
172
+ position: fixed;
173
+ bottom: 24px;
174
+ right: 24px;
175
+ background: var(--surface2);
176
+ border: 1px solid var(--border);
177
+ color: var(--green);
178
+ padding: 8px 16px;
179
+ border-radius: 6px;
180
+ font-size: 13px;
181
+ opacity: 0;
182
+ transform: translateY(8px);
183
+ transition: all 0.2s;
184
+ pointer-events: none;
185
+ z-index: 100;
186
+ }
187
+ .copied-toast.show { opacity: 1; transform: translateY(0); }
188
+
189
+ .session-detail { display: none; }
190
+ .session-detail.active { display: block; }
191
+ .back-btn {
192
+ background: var(--surface);
193
+ border: 1px solid var(--border);
194
+ color: var(--text-dim);
195
+ padding: 5px 12px;
196
+ border-radius: 6px;
197
+ cursor: pointer;
198
+ font-size: 12px;
199
+ margin-bottom: 16px;
200
+ display: inline-flex;
201
+ align-items: center;
202
+ gap: 6px;
203
+ }
204
+ .back-btn:hover { border-color: var(--accent); color: var(--text); }
205
+
206
+ .turn { border-bottom: 1px solid var(--border); }
207
+ .turn:last-child { border-bottom: none; }
208
+ .turn-header {
209
+ padding: 10px 16px 0;
210
+ font-size: 12px;
211
+ color: var(--text-dim);
212
+ display: flex;
213
+ align-items: baseline;
214
+ gap: 10px;
215
+ }
216
+ .turn-role { font-weight: 700; font-size: 11px; text-transform: uppercase; letter-spacing: 0.08em; }
217
+ .turn-role.user { color: var(--blue); }
218
+ .turn-role.assistant { color: var(--green); }
219
+ .turn-meta { font-size: 11px; color: var(--text-dim); font-family: var(--mono); }
220
+ .turn-ts { margin-left: auto; font-size: 11px; color: var(--text-dim); white-space: nowrap; }
221
+ .turn-content {
222
+ white-space: pre-wrap;
223
+ font-size: 13px;
224
+ font-family: var(--mono);
225
+ line-height: 1.6;
226
+ max-height: 400px;
227
+ overflow-y: auto;
228
+ margin: 8px 16px 12px;
229
+ padding: 10px 12px;
230
+ background: var(--bg);
231
+ border-radius: 6px;
232
+ border: 1px solid var(--border);
233
+ color: #c9d1e0;
234
+ }
235
+ .turn-content::-webkit-scrollbar { width: 6px; }
236
+ .turn-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
237
+
238
+ .tool-calls { margin: 4px 16px 10px; display: flex; flex-direction: column; gap: 4px; }
239
+ .tool-call {
240
+ border: 1px solid var(--border);
241
+ border-radius: 5px;
242
+ overflow: hidden;
243
+ font-size: 12px;
244
+ }
245
+ .tool-call-header {
246
+ padding: 5px 10px;
247
+ background: var(--surface2);
248
+ display: flex;
249
+ align-items: center;
250
+ gap: 8px;
251
+ cursor: pointer;
252
+ user-select: none;
253
+ }
254
+ .tool-call-header:hover { background: rgba(124, 110, 240, 0.08); }
255
+ .tool-name { font-family: var(--mono); color: var(--orange); font-weight: 600; font-size: 12px; }
256
+ .tool-chevron { color: var(--text-dim); font-size: 10px; margin-left: auto; transition: transform 0.15s; }
257
+ .tool-call.open .tool-chevron { transform: rotate(90deg); }
258
+ .tool-body { display: none; }
259
+ .tool-call.open .tool-body { display: block; }
260
+ .tool-section-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim); padding: 6px 10px 2px; }
261
+ .tool-json {
262
+ font-family: var(--mono);
263
+ font-size: 11px;
264
+ white-space: pre-wrap;
265
+ padding: 4px 10px 8px;
266
+ color: #c9d1e0;
267
+ max-height: 200px;
268
+ overflow-y: auto;
269
+ border-top: 1px solid var(--border);
270
+ background: var(--bg);
271
+ line-height: 1.5;
272
+ }
273
+
274
+ .empty { padding: 40px; text-align: center; color: var(--text-dim); font-size: 13px; }
275
+ .badge {
276
+ display: inline-block;
277
+ padding: 2px 7px;
278
+ border-radius: 4px;
279
+ font-size: 11px;
280
+ font-weight: 500;
281
+ font-family: var(--mono);
282
+ }
283
+ .badge-cost { background: rgba(255, 152, 0, 0.12); color: var(--orange); }
284
+ .badge-tokens { background: rgba(124, 110, 240, 0.12); color: var(--accent-light); }
285
+
286
+ .range-picker {
287
+ display: flex;
288
+ gap: 4px;
289
+ margin-bottom: 8px;
290
+ flex-wrap: wrap;
291
+ align-items: center;
292
+ }
293
+ .range-btn {
294
+ background: var(--surface);
295
+ border: 1px solid var(--border);
296
+ color: var(--text-dim);
297
+ padding: 4px 12px;
298
+ border-radius: 5px;
299
+ cursor: pointer;
300
+ font-size: 12px;
301
+ font-family: var(--mono);
302
+ transition: all 0.15s;
303
+ }
304
+ .range-btn:hover { border-color: var(--accent); color: var(--text); }
305
+ .range-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
306
+ .range-divider { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
307
+
308
+ .custom-range {
309
+ display: none;
310
+ align-items: center;
311
+ gap: 8px;
312
+ margin-bottom: 16px;
313
+ flex-wrap: wrap;
314
+ }
315
+ .custom-range.visible { display: flex; }
316
+ .custom-range label {
317
+ font-size: 11px;
318
+ color: var(--text-dim);
319
+ text-transform: uppercase;
320
+ letter-spacing: 0.05em;
321
+ }
322
+ .custom-range input[type="datetime-local"],
323
+ .custom-range select {
324
+ background: var(--bg);
325
+ border: 1px solid var(--border);
326
+ border-radius: 5px;
327
+ color: var(--text);
328
+ font-size: 12px;
329
+ font-family: var(--mono);
330
+ padding: 4px 8px;
331
+ outline: none;
332
+ }
333
+ .custom-range input[type="datetime-local"]:focus,
334
+ .custom-range select:focus { border-color: var(--accent); }
335
+ .custom-range select { cursor: pointer; }
336
+ .apply-btn {
337
+ background: var(--accent);
338
+ border: none;
339
+ color: #fff;
340
+ padding: 5px 14px;
341
+ border-radius: 5px;
342
+ cursor: pointer;
343
+ font-size: 12px;
344
+ font-family: var(--mono);
345
+ font-weight: 600;
346
+ transition: opacity 0.15s;
347
+ }
348
+ .apply-btn:hover { opacity: 0.85; }
349
+
350
+ .tag-chip {
351
+ display: inline-flex;
352
+ align-items: center;
353
+ gap: 4px;
354
+ background: var(--surface2);
355
+ border: 1px solid var(--border);
356
+ border-radius: 5px;
357
+ padding: 3px 10px;
358
+ font-size: 11px;
359
+ font-family: var(--mono);
360
+ color: var(--text-dim);
361
+ cursor: pointer;
362
+ user-select: none;
363
+ transition: all 0.15s;
364
+ }
365
+ .tag-chip:hover { border-color: var(--accent); color: var(--text); }
366
+ .tag-chip.selected { background: rgba(124,110,240,0.15); border-color: var(--accent); color: var(--accent-light); }
367
+ .tag-chip input { display: none; }
368
+
369
+ :not(button)[title] { cursor: help; }
370
+
371
+ @media (max-width: 800px) {
372
+ .charts-grid { grid-template-columns: 1fr; }
373
+ .stats-grid { grid-template-columns: repeat(2, 1fr); }
374
+ .panel-filter { width: 150px; }
375
+ }
376
+ </style>
377
+ </head>
378
+ <body>
379
+
380
+ <div class="header">
381
+ <h1>zozul</h1>
382
+ <span class="subtitle">Agent Observability</span>
383
+ <div class="header-right">
384
+ <button class="auto-btn" onclick="manualRefresh()" title="Auto-refresh every 10s — click to refresh now">
385
+ <span class="live-dot"></span>
386
+ Auto
387
+ </button>
388
+ </div>
389
+ </div>
390
+
391
+ <div class="container">
392
+ <!-- Main view -->
393
+ <div id="main-view">
394
+ <div class="stats-grid" id="stats-grid"></div>
395
+ <div class="range-picker" id="range-picker">
396
+ <button class="range-btn" data-range="1h">1h</button>
397
+ <button class="range-btn" data-range="6h">6h</button>
398
+ <button class="range-btn" data-range="24h">24h</button>
399
+ <button class="range-btn active" data-range="7d">7d</button>
400
+ <button class="range-btn" data-range="30d">30d</button>
401
+ <button class="range-btn" data-range="90d">90d</button>
402
+ <span class="range-divider"></span>
403
+ <button class="range-btn" id="custom-range-toggle">Custom</button>
404
+ </div>
405
+ <div class="custom-range" id="custom-range">
406
+ <label for="range-from">From</label>
407
+ <input type="datetime-local" id="range-from">
408
+ <label for="range-to">To</label>
409
+ <input type="datetime-local" id="range-to">
410
+ <label for="range-step">Resolution</label>
411
+ <select id="range-step">
412
+ <option value="auto">Auto</option>
413
+ <option value="1m">1 min</option>
414
+ <option value="5m">5 min</option>
415
+ <option value="15m">15 min</option>
416
+ <option value="1h">1 hour</option>
417
+ <option value="6h">6 hours</option>
418
+ <option value="1d">1 day</option>
419
+ </select>
420
+ <button class="apply-btn" id="apply-custom">Apply</button>
421
+ </div>
422
+ <div class="charts-grid">
423
+ <div class="chart-card full-width"><h3 id="tokens-label">Tokens &amp; Cost (7d)</h3><canvas id="chart-tokens"></canvas></div>
424
+ </div>
425
+ <div class="panel" id="tasks-panel" style="display:none">
426
+ <div class="panel-header">
427
+ <span>Tasks <span id="active-context" style="font-weight:400;font-size:11px;font-family:var(--mono)"></span></span>
428
+ <div style="display:flex;gap:8px;align-items:center">
429
+ <input class="panel-filter" id="task-search" placeholder="search tags…" oninput="filterTagChips(this.value)" style="width:140px">
430
+ <select class="panel-filter" id="task-time-range" onchange="onTaskTimeRange()" style="width:100px">
431
+ <option value="">All time</option>
432
+ <option value="1h">1h</option>
433
+ <option value="6h">6h</option>
434
+ <option value="24h">24h</option>
435
+ <option value="7d">7d</option>
436
+ <option value="30d">30d</option>
437
+ </select>
438
+ </div>
439
+ </div>
440
+ <div style="padding:12px 16px;display:flex;gap:6px;flex-wrap:wrap;border-bottom:1px solid var(--border)" id="task-chips"></div>
441
+ <div id="task-stats-grid" class="stats-grid" style="padding:16px;margin-bottom:0"></div>
442
+ <div id="turns-section" style="display:none">
443
+ <div class="panel-header">
444
+ <span>Turns <span id="turns-count" style="font-weight:400;font-size:11px;color:var(--text-dim)"></span></span>
445
+ <div style="display:flex;gap:6px;align-items:center" id="turns-pagination"></div>
446
+ </div>
447
+ <table>
448
+ <thead><tr>
449
+ <th>Time</th>
450
+ <th>Prompt</th>
451
+ <th>Tags</th>
452
+ <th>Duration</th>
453
+ <th>Tokens</th>
454
+ <th>Cost</th>
455
+ </tr></thead>
456
+ <tbody id="turns-table"></tbody>
457
+ </table>
458
+ </div>
459
+ </div>
460
+ <div class="panel">
461
+ <div class="panel-header">
462
+ <span>Sessions <span id="sessions-count" style="font-weight:400"></span></span>
463
+ <input class="panel-filter" id="session-filter" placeholder="filter by id or project…" oninput="filterSessions(this.value)">
464
+ </div>
465
+ <table>
466
+ <thead><tr>
467
+ <th>Session ID</th>
468
+ <th>Project</th>
469
+ <th>Started</th>
470
+ <th>Duration</th>
471
+ <th>Turns</th>
472
+ <th>In Tokens</th>
473
+ <th>Out Tokens</th>
474
+ <th>Cost</th>
475
+ <th>Model</th>
476
+ </tr></thead>
477
+ <tbody id="sessions-table"></tbody>
478
+ </table>
479
+ <div id="load-more-row" style="display:none;padding:12px 16px;text-align:center;border-top:1px solid var(--border)">
480
+ <button class="range-btn" onclick="loadMoreSessions()">Load more</button>
481
+ <span id="sessions-shown" style="font-size:12px;color:var(--text-dim);margin-left:12px"></span>
482
+ </div>
483
+ </div>
484
+ </div>
485
+
486
+ <!-- Session detail view -->
487
+ <div id="detail-view" class="session-detail">
488
+ <button class="back-btn" onclick="showMain()">&#8592; Sessions <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
489
+ <div class="stats-grid" id="detail-stats"></div>
490
+ <div class="panel">
491
+ <div class="panel-header">Conversation</div>
492
+ <div id="detail-turns"></div>
493
+ </div>
494
+ </div>
495
+
496
+ <!-- Turn detail view -->
497
+ <div id="turn-detail-view" class="session-detail">
498
+ <button class="back-btn" onclick="showMain()">&#8592; Turns <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
499
+ <div class="stats-grid" id="turn-detail-stats"></div>
500
+ <div class="panel">
501
+ <div class="panel-header">Conversation</div>
502
+ <div id="turn-detail-content"></div>
503
+ </div>
504
+ </div>
505
+ </div>
506
+
507
+ <div class="copied-toast" id="copied-toast">Copied!</div>
508
+
509
+ <script>
510
+ let chartInstances = {};
511
+ let allSessions = [];
512
+ let sessionsTotal = 0;
513
+ let sessionsOffset = 0;
514
+ const SESSIONS_PAGE = 50;
515
+ let autoRefreshTimer = null;
516
+ let currentRange = '7d';
517
+ let customFrom = null;
518
+ let customTo = null;
519
+ let customStep = 'auto';
520
+
521
+ function chartQueryString() {
522
+ if (customFrom && customTo) {
523
+ const qs = 'from=' + encodeURIComponent(customFrom) + '&to=' + encodeURIComponent(customTo);
524
+ return customStep !== 'auto' ? qs + '&step=' + customStep : qs;
525
+ }
526
+ return 'range=' + currentRange;
527
+ }
528
+
529
+ function chartLabel() {
530
+ if (customFrom && customTo) {
531
+ const f = new Date(customFrom);
532
+ const t = new Date(customTo);
533
+ const fmt = d => d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
534
+ return fmt(f) + ' \u2013 ' + fmt(t);
535
+ }
536
+ return currentRange;
537
+ }
538
+
539
+ async function fetchJson(path) {
540
+ const res = await fetch(path);
541
+ if (!res.ok) throw new Error(res.status + ' ' + path);
542
+ return res.json();
543
+ }
544
+
545
+ // ── Formatting ──
546
+
547
+ function fmtNum(n) { return (n ?? 0).toLocaleString(); }
548
+ function fmtCost(n) { return '$' + (n ?? 0).toFixed(4); }
549
+ function fmtDuration(ms) {
550
+ if (!ms || ms <= 0) return '\u2014';
551
+ const s = Math.floor(ms / 1000);
552
+ if (s < 60) return s + 's';
553
+ const m = Math.floor(s / 60);
554
+ if (m < 60) return m + 'm ' + (s % 60) + 's';
555
+ return Math.floor(m / 60) + 'h ' + (m % 60) + 'm';
556
+ }
557
+ function fmtRelative(s) {
558
+ if (!s) return '\u2014';
559
+ const diff = Date.now() - new Date(s).getTime();
560
+ const mins = Math.floor(diff / 60000);
561
+ const hours = Math.floor(mins / 60);
562
+ const days = Math.floor(hours / 24);
563
+ if (mins < 1) return 'just now';
564
+ if (mins < 60) return mins + 'm ago';
565
+ if (hours < 24) return hours + 'h ago';
566
+ if (days < 7) return days + 'd ago';
567
+ return new Date(s).toLocaleDateString();
568
+ }
569
+ function fmtAbsolute(s) {
570
+ if (!s) return '';
571
+ try { return new Date(s).toLocaleString(); } catch { return s; }
572
+ }
573
+ function shortId(s) { return s ? s.slice(0, 8) : '\u2014'; }
574
+ function basename(p) {
575
+ if (!p) return '\u2014';
576
+ return p.replace(/\/$/, '').split('/').pop() || p;
577
+ }
578
+ function escHtml(s) {
579
+ const d = document.createElement('div');
580
+ d.textContent = s;
581
+ return d.innerHTML;
582
+ }
583
+
584
+ // ── Copy to clipboard ──
585
+
586
+ function copyText(text) {
587
+ navigator.clipboard.writeText(text).then(() => {
588
+ const toast = document.getElementById('copied-toast');
589
+ toast.classList.add('show');
590
+ setTimeout(() => toast.classList.remove('show'), 1800);
591
+ });
592
+ }
593
+
594
+ // ── Dashboard load ──
595
+
596
+ async function loadDashboard() {
597
+ const btn = document.querySelector('.auto-btn');
598
+ btn?.classList.add('loading');
599
+ const minSpinner = new Promise(r => setTimeout(r, 400));
600
+ try {
601
+ const qs = chartQueryString();
602
+ const [stats, sessionsResp, tokens, cost, tasks, activeCtx] = await Promise.all([
603
+ fetchJson('/api/stats'),
604
+ fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=0'),
605
+ fetchJson('/api/metrics/tokens?' + qs),
606
+ fetchJson('/api/metrics/cost?' + qs),
607
+ fetchJson('/api/tasks'),
608
+ fetchJson('/api/context'),
609
+ ]);
610
+ // Handle both paginated { sessions, total } and legacy plain-array responses
611
+ const sessionsArr = Array.isArray(sessionsResp) ? sessionsResp : sessionsResp.sessions;
612
+ allSessions = sessionsArr;
613
+ sessionsTotal = Array.isArray(sessionsResp) ? sessionsArr.length : sessionsResp.total;
614
+ sessionsOffset = allSessions.length;
615
+ renderStats(stats);
616
+ renderTasks(tasks, activeCtx);
617
+ renderSessions(allSessions);
618
+ updateLoadMore();
619
+ renderTokenCostChart(tokens, cost);
620
+ } catch (e) {
621
+ console.error('load failed', e);
622
+ } finally {
623
+ await minSpinner;
624
+ btn?.classList.remove('loading');
625
+ }
626
+ }
627
+
628
+ function manualRefresh() {
629
+ if (autoRefreshTimer) { clearTimeout(autoRefreshTimer); autoRefreshTimer = null; }
630
+ loadDashboard().then(scheduleAutoRefresh);
631
+ }
632
+
633
+ async function loadMoreSessions() {
634
+ try {
635
+ const resp = await fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=' + sessionsOffset);
636
+ const page = Array.isArray(resp) ? resp : resp.sessions;
637
+ allSessions = allSessions.concat(page);
638
+ sessionsTotal = Array.isArray(resp) ? allSessions.length : resp.total;
639
+ sessionsOffset = allSessions.length;
640
+ const q = document.getElementById('session-filter').value;
641
+ q ? filterSessions(q) : renderSessions(allSessions);
642
+ updateLoadMore();
643
+ } catch (e) {
644
+ console.error('load more failed', e);
645
+ }
646
+ }
647
+
648
+ function updateLoadMore() {
649
+ const row = document.getElementById('load-more-row');
650
+ const shown = document.getElementById('sessions-shown');
651
+ const count = document.getElementById('sessions-count');
652
+ count.textContent = '(' + sessionsTotal + ')';
653
+ if (sessionsTotal > sessionsOffset) {
654
+ row.style.display = '';
655
+ shown.textContent = sessionsOffset + ' of ' + sessionsTotal + ' shown';
656
+ } else {
657
+ row.style.display = 'none';
658
+ }
659
+ }
660
+
661
+ const AUTO_REFRESH_INTERVAL = 10_000; // ms
662
+
663
+ function scheduleAutoRefresh() {
664
+ autoRefreshTimer = setTimeout(async () => {
665
+ await loadDashboard();
666
+ scheduleAutoRefresh();
667
+ }, AUTO_REFRESH_INTERVAL);
668
+ }
669
+
670
+ // ── Stats ──
671
+
672
+ function renderStats(s) {
673
+ const grid = document.getElementById('stats-grid');
674
+ grid.innerHTML = [
675
+ { label: 'Sessions', value: fmtNum(s.total_sessions) },
676
+ { label: 'User Prompts', value: fmtNum(s.total_user_prompts), title: 'UserPromptSubmit hook events' },
677
+ { label: 'Interruptions', value: fmtNum(s.total_interruptions), title: 'Times user stopped Claude mid-run' },
678
+ { label: 'Input Tokens', value: fmtNum(s.total_input_tokens) },
679
+ { label: 'Output Tokens', value: fmtNum(s.total_output_tokens) },
680
+ { label: 'Cache Read', value: fmtNum(s.total_cache_read_tokens) },
681
+ { label: 'Total Cost', value: fmtCost(s.total_cost_usd) },
682
+ ].map(c =>
683
+ '<div class="stat-card"' + (c.title ? ' title="' + c.title + '"' : '') + '>' +
684
+ '<div class="label">' + c.label + '</div>' +
685
+ '<div class="value">' + c.value + '</div>' +
686
+ '</div>'
687
+ ).join('');
688
+ }
689
+
690
+ // ── Tasks ──
691
+
692
+ let allTasks = [];
693
+ let selectedTags = new Set();
694
+ let taskTurnsOffset = 0;
695
+ const TURNS_PAGE = 10;
696
+
697
+ function getTaskTimeRange() {
698
+ const val = document.getElementById('task-time-range').value;
699
+ if (!val) return {};
700
+ const now = new Date();
701
+ const ms = val.endsWith('h') ? parseInt(val) * 3600000 : parseInt(val) * 86400000;
702
+ return { from: new Date(now.getTime() - ms).toISOString(), to: now.toISOString() };
703
+ }
704
+
705
+ async function renderTasks(tasks, activeCtx) {
706
+ const panel = document.getElementById('tasks-panel');
707
+ const ctxEl = document.getElementById('active-context');
708
+
709
+ if (activeCtx && activeCtx.active) {
710
+ const tags = Array.isArray(activeCtx.active) ? activeCtx.active : [activeCtx.active];
711
+ ctxEl.innerHTML = tags.map(t =>
712
+ '<span style="display:inline-block;background:rgba(124,110,240,0.15);color:var(--accent-light);padding:1px 8px;border-radius:4px;margin-left:4px;font-size:11px">' + escHtml(t) + '</span>'
713
+ ).join('');
714
+ } else {
715
+ ctxEl.textContent = '';
716
+ }
717
+
718
+ if (!tasks.length) {
719
+ panel.style.display = 'none';
720
+ return;
721
+ }
722
+
723
+ panel.style.display = '';
724
+ allTasks = tasks;
725
+ renderTagChips('');
726
+ await loadTaskData();
727
+ }
728
+
729
+ function renderTagChips(search) {
730
+ const chipsEl = document.getElementById('task-chips');
731
+ const q = search.toLowerCase();
732
+ const filtered = q ? allTasks.filter(t => t.task.toLowerCase().includes(q)) : allTasks;
733
+ chipsEl.innerHTML = filtered.map(t => {
734
+ const sel = selectedTags.has(t.task) ? ' selected' : '';
735
+ return '<label class="tag-chip' + sel + '" data-tag="' + escHtml(t.task) + '" onclick="toggleTag(this)">' +
736
+ escHtml(t.task) + ' <span style="color:var(--text-dim);font-size:10px">(' + t.turn_count + ')</span></label>';
737
+ }).join('');
738
+ }
739
+
740
+ function filterTagChips(q) { renderTagChips(q); }
741
+
742
+ function toggleTag(el) {
743
+ const tag = el.dataset.tag;
744
+ if (selectedTags.has(tag)) {
745
+ selectedTags.delete(tag);
746
+ el.classList.remove('selected');
747
+ } else {
748
+ selectedTags.add(tag);
749
+ el.classList.add('selected');
750
+ }
751
+ loadTaskData();
752
+ }
753
+
754
+ function onTaskTimeRange() { loadTaskData(); }
755
+
756
+ function taskQueryParams() {
757
+ const selected = Array.from(selectedTags);
758
+ const tags = selected.length > 0 ? selected : allTasks.map(t => t.task);
759
+ const mode = selected.length > 0 ? 'all' : 'any';
760
+ const { from, to } = getTaskTimeRange();
761
+ let qs = 'tags=' + tags.map(encodeURIComponent).join(',') + '&mode=' + mode;
762
+ if (from && to) qs += '&from=' + encodeURIComponent(from) + '&to=' + encodeURIComponent(to);
763
+ return { qs, tags, label: selected.length === 0 ? 'All tags' : selected.join(' + ') };
764
+ }
765
+
766
+ async function loadTaskData() {
767
+ taskTurnsOffset = 0;
768
+ const { qs, tags, label } = taskQueryParams();
769
+ if (tags.length === 0) return;
770
+
771
+ const selected = Array.from(selectedTags);
772
+ const fetches = [fetchJson('/api/tasks/stats?' + qs)];
773
+ // Only fetch turns if specific tags are selected
774
+ if (selected.length > 0) {
775
+ fetches.push(fetchJson('/api/tasks/turns?' + qs + '&limit=' + TURNS_PAGE + '&offset=0'));
776
+ }
777
+ const [stats, turns] = await Promise.all(fetches);
778
+
779
+ renderTaskStats(stats, label);
780
+ if (selected.length > 0 && turns) {
781
+ renderTurnsTable(turns, false);
782
+ taskTurnsOffset = turns.length;
783
+ } else {
784
+ document.getElementById('turns-section').style.display = 'none';
785
+ }
786
+ }
787
+
788
+ function renderTaskStats(stats, label) {
789
+ const grid = document.getElementById('task-stats-grid');
790
+ grid.innerHTML = [
791
+ { label: 'Selected', value: '<span style="font-size:13px;font-family:var(--mono)">' + escHtml(label) + '</span>' },
792
+ { label: 'Total Turns', value: fmtNum(stats.total_turns) },
793
+ { label: 'User Interventions', value: fmtNum(stats.user_turns), title: 'Number of real user prompts' },
794
+ { label: 'Duration', value: fmtDuration(stats.total_duration_ms), title: 'Total Claude processing time' },
795
+ { label: 'Input Tokens', value: fmtNum(stats.total_input_tokens) },
796
+ { label: 'Output Tokens', value: fmtNum(stats.total_output_tokens) },
797
+ { label: 'Cost', value: fmtCost(stats.total_cost_usd) },
798
+ ].map(c =>
799
+ '<div class="stat-card"' + (c.title ? ' title="' + c.title + '"' : '') + '>' +
800
+ '<div class="label">' + c.label + '</div>' +
801
+ '<div class="value">' + c.value + '</div>' +
802
+ '</div>'
803
+ ).join('');
804
+ }
805
+
806
+ function renderTurnsTable(turns, append) {
807
+ const section = document.getElementById('turns-section');
808
+ const tbody = document.getElementById('turns-table');
809
+ const countEl = document.getElementById('turns-count');
810
+ const paginationEl = document.getElementById('turns-pagination');
811
+
812
+ if (!turns.length && !append) {
813
+ section.style.display = 'none';
814
+ return;
815
+ }
816
+
817
+ section.style.display = '';
818
+ const page = Math.floor(taskTurnsOffset / TURNS_PAGE);
819
+ const hasMore = turns.length >= TURNS_PAGE;
820
+ countEl.textContent = '';
821
+ paginationEl.innerHTML =
822
+ (page > 0 ? '<button class="range-btn" onclick="turnsPrevPage()">\u2190 Prev</button>' : '') +
823
+ '<span style="font-size:11px;color:var(--text-dim);font-family:var(--mono)">Page ' + (page + 1) + '</span>' +
824
+ (hasMore ? '<button class="range-btn" onclick="turnsNextPage()">Next \u2192</button>' : '');
825
+
826
+ const html = turns.map(t => {
827
+ const tags = t.tags ? t.tags.split(', ').map(tag =>
828
+ '<span style="display:inline-block;background:rgba(124,110,240,0.12);color:var(--accent-light);padding:0 6px;border-radius:3px;font-size:10px;font-family:var(--mono)">' + escHtml(tag) + '</span>'
829
+ ).join(' ') : '';
830
+ const preview = t.content_text ? escHtml(t.content_text.slice(0, 80)) + (t.content_text.length > 80 ? '\u2026' : '') : '\u2014';
831
+ const tokens = fmtNum((t.block_input_tokens || 0) + (t.block_output_tokens || 0));
832
+
833
+ return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ')">' +
834
+ '<td title="' + fmtAbsolute(t.timestamp) + '" style="white-space:nowrap">' + fmtRelative(t.timestamp) + '</td>' +
835
+ '<td style="font-family:var(--mono);font-size:12px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + preview + '</td>' +
836
+ '<td>' + tags + '</td>' +
837
+ '<td>' + fmtDuration(t.duration_ms) + '</td>' +
838
+ '<td><span class="badge badge-tokens">' + tokens + '</span></td>' +
839
+ '<td><span class="badge badge-cost">' + fmtCost(t.block_cost_usd) + '</span></td>' +
840
+ '</tr>';
841
+ }).join('');
842
+
843
+ if (append) {
844
+ tbody.innerHTML += html;
845
+ } else {
846
+ tbody.innerHTML = html;
847
+ }
848
+ }
849
+
850
+ async function showTurnBlock(turnId) {
851
+ document.getElementById('main-view').style.display = 'none';
852
+ document.getElementById('detail-view').classList.remove('active');
853
+ const view = document.getElementById('turn-detail-view');
854
+ view.classList.add('active');
855
+
856
+ const statsEl = document.getElementById('turn-detail-stats');
857
+ const content = document.getElementById('turn-detail-content');
858
+ statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading\u2026</div></div>';
859
+ content.innerHTML = '';
860
+
861
+ const block = await fetchJson('/api/turns/' + turnId + '/block');
862
+ if (!block.length) { content.innerHTML = '<div class="empty">No data</div>'; return; }
863
+
864
+ // Aggregate block stats
865
+ const totalIn = block.reduce((s, t) => s + (t.input_tokens || 0), 0);
866
+ const totalOut = block.reduce((s, t) => s + (t.output_tokens || 0), 0);
867
+ const totalCost = block.reduce((s, t) => s + (t.cost_usd || 0), 0);
868
+ const duration = block[0].duration_ms || 0;
869
+ const prompt = block[0].content_text ? block[0].content_text.slice(0, 100) : '\u2014';
870
+
871
+ statsEl.innerHTML = [
872
+ { label: 'Prompt', value: '<span style="font-size:13px;font-family:var(--mono)">' + escHtml(prompt) + (block[0].content_text && block[0].content_text.length > 100 ? '\u2026' : '') + '</span>' },
873
+ { label: 'Turns in Block', value: block.length },
874
+ { label: 'Duration', value: fmtDuration(duration) },
875
+ { label: 'Input Tokens', value: fmtNum(totalIn) },
876
+ { label: 'Output Tokens', value: fmtNum(totalOut) },
877
+ { label: 'Cost', value: fmtCost(totalCost) },
878
+ ].map(c =>
879
+ '<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:14px">' + c.value + '</div></div>'
880
+ ).join('');
881
+
882
+ content.innerHTML = block.map((t, i) => {
883
+ const roleClass = t.role === 'assistant' ? 'assistant' : 'user';
884
+ const roleLabel = t.role === 'assistant' ? 'Assistant' : 'User';
885
+ const meta = [];
886
+ if (t.input_tokens) meta.push(fmtNum(t.input_tokens) + ' in');
887
+ if (t.output_tokens) meta.push(fmtNum(t.output_tokens) + ' out');
888
+ if (t.cost_usd) meta.push(fmtCost(t.cost_usd));
889
+ if (t.duration_ms) meta.push(fmtDuration(t.duration_ms));
890
+
891
+ const text = t.content_text
892
+ ? '<div class="turn-content">' + escHtml(t.content_text) + '</div>'
893
+ : '';
894
+
895
+ let toolsHtml = '';
896
+ if (t.tool_calls) {
897
+ try {
898
+ const calls = JSON.parse(t.tool_calls);
899
+ toolsHtml = '<div class="tool-calls">' + calls.map((c, ci) => {
900
+ const uid = 'tb-' + turnId + '-' + i + '-' + ci;
901
+ const inputJson = JSON.stringify(c.toolInput, null, 2);
902
+ const resultHtml = c.toolResult
903
+ ? '<div class="tool-section-label">Result</div><div class="tool-json">' + escHtml(c.toolResult) + '</div>'
904
+ : '';
905
+ return '<div class="tool-call" id="' + uid + '">' +
906
+ '<div class="tool-call-header" onclick="toggleTool(\'' + uid + '\')">' +
907
+ '<span class="tool-name">' + escHtml(c.toolName) + '</span>' +
908
+ '<span class="tool-chevron">&#9658;</span>' +
909
+ '</div>' +
910
+ '<div class="tool-body">' +
911
+ '<div class="tool-section-label">Input</div>' +
912
+ '<div class="tool-json">' + escHtml(inputJson) + '</div>' +
913
+ resultHtml +
914
+ '</div>' +
915
+ '</div>';
916
+ }).join('') + '</div>';
917
+ } catch {}
918
+ }
919
+
920
+ return '<div class="turn">' +
921
+ '<div class="turn-header">' +
922
+ '<span class="turn-role ' + roleClass + '">' + roleLabel + '</span>' +
923
+ (meta.length ? '<span class="turn-meta">' + meta.join(' \u00b7 ') + '</span>' : '') +
924
+ '<span class="turn-ts" title="' + fmtAbsolute(t.timestamp) + '">' + fmtRelative(t.timestamp) + '</span>' +
925
+ '</div>' +
926
+ text +
927
+ toolsHtml +
928
+ '</div>';
929
+ }).join('');
930
+ }
931
+
932
+ async function turnsNextPage() {
933
+ taskTurnsOffset += TURNS_PAGE;
934
+ const { qs } = taskQueryParams();
935
+ const turns = await fetchJson('/api/tasks/turns?' + qs + '&limit=' + TURNS_PAGE + '&offset=' + taskTurnsOffset);
936
+ renderTurnsTable(turns, false);
937
+ }
938
+
939
+ async function turnsPrevPage() {
940
+ taskTurnsOffset = Math.max(0, taskTurnsOffset - TURNS_PAGE);
941
+ const { qs } = taskQueryParams();
942
+ const turns = await fetchJson('/api/tasks/turns?' + qs + '&limit=' + TURNS_PAGE + '&offset=' + taskTurnsOffset);
943
+ renderTurnsTable(turns, false);
944
+ }
945
+
946
+ // ── Sessions table ──
947
+
948
+ function renderSessions(sessions) {
949
+ const tbody = document.getElementById('sessions-table');
950
+ if (!sessions.length) {
951
+ tbody.innerHTML = '<tr><td colspan="9" class="empty">No sessions yet. Use Claude Code then run <code>zozul ingest</code>.</td></tr>';
952
+ return;
953
+ }
954
+ tbody.innerHTML = sessions.map(s => {
955
+ const proj = basename(s.project_path);
956
+ const projAttr = s.project_path ? ' title="' + escHtml(s.project_path) + '"' : '';
957
+ return '<tr class="clickable" onclick="showSession(\'' + s.id + '\')">' +
958
+ '<td><span class="session-id" onclick="event.stopPropagation();copyText(\'' + s.id + '\')" title="Click to copy full ID">' +
959
+ shortId(s.id) + '<span class="copy-icon">&#x2398;</span></span></td>' +
960
+ '<td' + projAttr + '>' + escHtml(proj) + '</td>' +
961
+ '<td title="' + fmtAbsolute(s.started_at) + '">' + fmtRelative(s.started_at) + '</td>' +
962
+ '<td>' + fmtDuration(s.total_duration_ms) + '</td>' +
963
+ '<td>' + (s.total_turns ?? 0) + '</td>' +
964
+ '<td><span class="badge badge-tokens">' + fmtNum(s.total_input_tokens) + '</span></td>' +
965
+ '<td><span class="badge badge-tokens">' + fmtNum(s.total_output_tokens) + '</span></td>' +
966
+ '<td><span class="badge badge-cost">' + fmtCost(s.total_cost_usd) + '</span></td>' +
967
+ '<td style="font-family:var(--mono);font-size:11px;color:var(--text-dim)">' + escHtml(s.model ?? '\u2014') + '</td>' +
968
+ '</tr>';
969
+ }).join('');
970
+ }
971
+
972
+ function filterSessions(q) {
973
+ q = q.toLowerCase();
974
+ const filtered = q
975
+ ? allSessions.filter(s =>
976
+ (s.id && s.id.toLowerCase().includes(q)) ||
977
+ (s.project_path && s.project_path.toLowerCase().includes(q)) ||
978
+ (s.model && s.model.toLowerCase().includes(q))
979
+ )
980
+ : allSessions;
981
+ renderSessions(filtered);
982
+ // Hide load-more while filtering (results are client-side only)
983
+ document.getElementById('load-more-row').style.display = q ? 'none' : (sessionsTotal > sessionsOffset ? '' : 'none');
984
+ }
985
+
986
+ // ── Charts ──
987
+
988
+ function makeChart(id, config) {
989
+ const existing = chartInstances[id];
990
+ if (existing) {
991
+ const newHash = JSON.stringify(config.data);
992
+ if (existing._dataHash === newHash) return;
993
+ existing._dataHash = newHash;
994
+ existing.data = config.data;
995
+ existing.update('none');
996
+ return;
997
+ }
998
+ const ctx = document.getElementById(id);
999
+ if (!ctx) return;
1000
+ const chart = new Chart(ctx, config);
1001
+ chart._dataHash = JSON.stringify(config.data);
1002
+ chartInstances[id] = chart;
1003
+ }
1004
+
1005
+ const COLORS = ['#7c6ef0','#4caf50','#42a5f5','#ff9800','#ef5350','#ab47bc','#26a69a','#8d6e63','#78909c','#d4e157'];
1006
+ const TICK = { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 } };
1007
+ const LEGEND = { labels: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 } } };
1008
+
1009
+ function renderTokenCostChart(tokens, cost) {
1010
+ const labels = tokens.map(d => d.timestamp);
1011
+ const costByTs = Object.fromEntries(cost.map(d => [d.timestamp, d.cost]));
1012
+ makeChart('chart-tokens', {
1013
+ type: 'bar',
1014
+ data: {
1015
+ labels,
1016
+ datasets: [
1017
+ { label: 'Cost (USD)', data: labels.map(ts => costByTs[ts] ?? 0),
1018
+ backgroundColor: 'rgba(255,152,0,0.25)', borderColor: '#ff9800', borderWidth: 1,
1019
+ yAxisID: 'yCost', order: 2 },
1020
+ { label: 'Input', data: tokens.map(d => d.input),
1021
+ type: 'line', borderColor: '#7c6ef0', borderWidth: 2, tension: 0.3, pointRadius: 2,
1022
+ yAxisID: 'yTokens', order: 1 },
1023
+ { label: 'Output', data: tokens.map(d => d.output),
1024
+ type: 'line', borderColor: '#4caf50', borderWidth: 2, tension: 0.3, pointRadius: 2,
1025
+ yAxisID: 'yTokens', order: 1 },
1026
+ ]
1027
+ },
1028
+ options: {
1029
+ responsive: true,
1030
+ plugins: {
1031
+ legend: LEGEND,
1032
+ tooltip: {
1033
+ callbacks: {
1034
+ afterLabel: (ctx) => {
1035
+ if (ctx.dataset.label !== 'Output') return undefined;
1036
+ const cr = tokens[ctx.dataIndex]?.cache_read ?? 0;
1037
+ const inp = tokens[ctx.dataIndex]?.input ?? 0;
1038
+ if (cr <= 0) return undefined;
1039
+ const pct = inp + cr > 0 ? Math.round(cr / (inp + cr) * 100) : 0;
1040
+ return ` Cache read: ${cr.toLocaleString()} (${pct}% of input)`;
1041
+ }
1042
+ }
1043
+ }
1044
+ },
1045
+ scales: {
1046
+ x: { ticks: TICK },
1047
+ yTokens: { position: 'left', ticks: TICK, title: { display: true, text: 'Tokens', color: '#8b8fa3', font: { size: 10 } } },
1048
+ yCost: { position: 'right', grid: { drawOnChartArea: false },
1049
+ ticks: { ...TICK, callback: v => '$' + v.toFixed(3) },
1050
+ title: { display: true, text: 'Cost (USD)', color: '#ff9800', font: { size: 10 } } },
1051
+ }
1052
+ }
1053
+ });
1054
+ }
1055
+
1056
+ // ── Session detail ──
1057
+
1058
+ async function showSession(id) {
1059
+ document.getElementById('main-view').style.display = 'none';
1060
+ const view = document.getElementById('detail-view');
1061
+ view.classList.add('active');
1062
+ document.getElementById('detail-stats').innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading\u2026</div></div>';
1063
+ document.getElementById('detail-turns').innerHTML = '';
1064
+
1065
+ const [session, turns] = await Promise.all([
1066
+ fetchJson('/api/sessions/' + id),
1067
+ fetchJson('/api/sessions/' + id + '/turns'),
1068
+ ]);
1069
+
1070
+ document.getElementById('detail-stats').innerHTML = [
1071
+ { label: 'Session ID', value: '<span class="session-id" onclick="copyText(\'' + session.id + '\')" title="Click to copy">' + shortId(session.id) + ' <span class="copy-icon">&#x2398;</span></span>' },
1072
+ { label: 'Project', value: '<span title="' + escHtml(session.project_path ?? '') + '">' + escHtml(basename(session.project_path)) + '</span>' },
1073
+ { label: 'Model', value: '<span style="font-family:var(--mono);font-size:13px">' + escHtml(session.model ?? '\u2014') + '</span>' },
1074
+ { label: 'Started', value: '<span title="' + fmtAbsolute(session.started_at) + '">' + fmtRelative(session.started_at) + '</span>' },
1075
+ { label: 'Duration', value: fmtDuration(session.total_duration_ms) },
1076
+ { label: 'Turns', value: session.total_turns },
1077
+ { label: 'Input Tokens', value: fmtNum(session.total_input_tokens) },
1078
+ { label: 'Output Tokens', value: fmtNum(session.total_output_tokens) },
1079
+ { label: 'Cost', value: fmtCost(session.total_cost_usd) },
1080
+ ].map(c =>
1081
+ '<div class="stat-card">' +
1082
+ '<div class="label">' + c.label + '</div>' +
1083
+ '<div class="value" style="font-size:14px">' + c.value + '</div>' +
1084
+ '</div>'
1085
+ ).join('');
1086
+
1087
+ const turnsDiv = document.getElementById('detail-turns');
1088
+ if (!turns.length) {
1089
+ turnsDiv.innerHTML = '<div class="empty">No turns recorded. Run <code>zozul ingest</code> to parse transcript files.</div>';
1090
+ return;
1091
+ }
1092
+
1093
+ turnsDiv.innerHTML = turns.map((t, i) => {
1094
+ const roleClass = t.role === 'assistant' ? 'assistant' : 'user';
1095
+ const roleLabel = t.role === 'assistant' ? 'Assistant' : 'User';
1096
+
1097
+ const meta = [];
1098
+ if (t.input_tokens) meta.push(fmtNum(t.input_tokens) + ' in');
1099
+ if (t.output_tokens) meta.push(fmtNum(t.output_tokens) + ' out');
1100
+ if (t.cost_usd) meta.push(fmtCost(t.cost_usd));
1101
+ if (t.duration_ms) meta.push(fmtDuration(t.duration_ms));
1102
+
1103
+ const content = t.content_text
1104
+ ? '<div class="turn-content">' + escHtml(t.content_text) + '</div>'
1105
+ : '';
1106
+
1107
+ let toolsHtml = '';
1108
+ if (t.tool_calls) {
1109
+ try {
1110
+ const calls = JSON.parse(t.tool_calls);
1111
+ toolsHtml = '<div class="tool-calls">' + calls.map((c, ci) => {
1112
+ const uid = 'tc-' + i + '-' + ci;
1113
+ const inputJson = JSON.stringify(c.toolInput, null, 2);
1114
+ const resultHtml = c.toolResult
1115
+ ? '<div class="tool-section-label">Result</div><div class="tool-json">' + escHtml(c.toolResult) + '</div>'
1116
+ : '';
1117
+ return '<div class="tool-call" id="' + uid + '">' +
1118
+ '<div class="tool-call-header" onclick="toggleTool(\'' + uid + '\')">' +
1119
+ '<span class="tool-name">' + escHtml(c.toolName) + '</span>' +
1120
+ '<span class="tool-chevron">&#9658;</span>' +
1121
+ '</div>' +
1122
+ '<div class="tool-body">' +
1123
+ '<div class="tool-section-label">Input</div>' +
1124
+ '<div class="tool-json">' + escHtml(inputJson) + '</div>' +
1125
+ resultHtml +
1126
+ '</div>' +
1127
+ '</div>';
1128
+ }).join('') + '</div>';
1129
+ } catch {}
1130
+ }
1131
+
1132
+ return '<div class="turn">' +
1133
+ '<div class="turn-header">' +
1134
+ '<span class="turn-role ' + roleClass + '">' + roleLabel + '</span>' +
1135
+ (meta.length ? '<span class="turn-meta">' + meta.join(' \u00b7 ') + '</span>' : '') +
1136
+ '<span class="turn-ts" title="' + fmtAbsolute(t.timestamp) + '">' + fmtRelative(t.timestamp) + '</span>' +
1137
+ '</div>' +
1138
+ content +
1139
+ toolsHtml +
1140
+ '</div>';
1141
+ }).join('');
1142
+ }
1143
+
1144
+ function toggleTool(uid) {
1145
+ document.getElementById(uid).classList.toggle('open');
1146
+ }
1147
+
1148
+ function showMain() {
1149
+ document.getElementById('detail-view').classList.remove('active');
1150
+ document.getElementById('turn-detail-view').classList.remove('active');
1151
+ document.getElementById('main-view').style.display = '';
1152
+ }
1153
+
1154
+ // ── Keyboard ──
1155
+
1156
+ document.addEventListener('keydown', e => {
1157
+ if (e.key === 'Escape') {
1158
+ if (document.getElementById('detail-view').classList.contains('active') ||
1159
+ document.getElementById('turn-detail-view').classList.contains('active')) {
1160
+ showMain();
1161
+ }
1162
+ }
1163
+ });
1164
+
1165
+ // ── Range picker ──
1166
+
1167
+ function setActivePreset(range) {
1168
+ currentRange = range;
1169
+ customFrom = null;
1170
+ customTo = null;
1171
+ document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
1172
+ const btn = document.querySelector('.range-btn[data-range="' + range + '"]');
1173
+ if (btn) btn.classList.add('active');
1174
+ document.getElementById('custom-range').classList.remove('visible');
1175
+ updateChartLabels();
1176
+ loadCharts();
1177
+ }
1178
+
1179
+ function updateChartLabels() {
1180
+ document.getElementById('tokens-label').textContent = 'Tokens & Cost (' + chartLabel() + ')';
1181
+ }
1182
+
1183
+ document.getElementById('range-picker').addEventListener('click', e => {
1184
+ const btn = e.target.closest('.range-btn[data-range]');
1185
+ if (!btn) return;
1186
+ setActivePreset(btn.dataset.range);
1187
+ });
1188
+
1189
+ // ── Custom range ──
1190
+
1191
+ const customRangeEl = document.getElementById('custom-range');
1192
+ const customToggle = document.getElementById('custom-range-toggle');
1193
+
1194
+ function initCustomDefaults() {
1195
+ const now = new Date();
1196
+ const yesterday = new Date(now.getTime() - 24 * 3600000);
1197
+ const toLocal = d => {
1198
+ const pad = n => String(n).padStart(2, '0');
1199
+ return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + 'T' + pad(d.getHours()) + ':' + pad(d.getMinutes());
1200
+ };
1201
+ document.getElementById('range-from').value = toLocal(yesterday);
1202
+ document.getElementById('range-to').value = toLocal(now);
1203
+ }
1204
+
1205
+ customToggle.addEventListener('click', (e) => {
1206
+ e.stopPropagation();
1207
+ const isOpen = customRangeEl.classList.contains('visible');
1208
+ if (isOpen) {
1209
+ customRangeEl.classList.remove('visible');
1210
+ customToggle.classList.remove('active');
1211
+ var last = document.querySelector('.range-btn[data-range="' + currentRange + '"]');
1212
+ if (last) last.classList.add('active');
1213
+ } else {
1214
+ initCustomDefaults();
1215
+ customRangeEl.classList.add('visible');
1216
+ document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
1217
+ customToggle.classList.add('active');
1218
+ }
1219
+ });
1220
+
1221
+ document.getElementById('apply-custom').addEventListener('click', () => {
1222
+ const fromVal = document.getElementById('range-from').value;
1223
+ const toVal = document.getElementById('range-to').value;
1224
+ if (!fromVal || !toVal) return;
1225
+ customFrom = new Date(fromVal).toISOString();
1226
+ customTo = new Date(toVal).toISOString();
1227
+ customStep = document.getElementById('range-step').value;
1228
+ document.querySelectorAll('.range-btn[data-range]').forEach(b => b.classList.remove('active'));
1229
+ updateChartLabels();
1230
+ loadCharts();
1231
+ });
1232
+
1233
+ async function loadCharts() {
1234
+ const qs = chartQueryString();
1235
+ const [tokens, cost] = await Promise.all([
1236
+ fetchJson('/api/metrics/tokens?' + qs),
1237
+ fetchJson('/api/metrics/cost?' + qs),
1238
+ ]);
1239
+ renderTokenCostChart(tokens, cost);
1240
+ }
1241
+
1242
+ loadDashboard().then(scheduleAutoRefresh);
1243
+ </script>
1244
+ </body>
1245
+ </html>