zozul-cli 0.1.1 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/publish.yml +2 -3
- package/DEVELOPMENT.md +123 -12
- package/README.md +10 -7
- package/dist/cli/commands.d.ts.map +1 -1
- package/dist/cli/commands.js +10 -1
- package/dist/cli/commands.js.map +1 -1
- package/dist/dashboard/html.d.ts +5 -6
- package/dist/dashboard/html.d.ts.map +1 -1
- package/dist/dashboard/html.js +7 -50
- package/dist/dashboard/html.js.map +1 -1
- package/dist/dashboard/index.html +1016 -739
- package/dist/hooks/server.d.ts +2 -0
- package/dist/hooks/server.d.ts.map +1 -1
- package/dist/hooks/server.js +19 -4
- package/dist/hooks/server.js.map +1 -1
- package/dist/storage/repo.d.ts +8 -2
- package/dist/storage/repo.d.ts.map +1 -1
- package/dist/storage/repo.js +46 -27
- package/dist/storage/repo.js.map +1 -1
- package/dist/sync/index.d.ts +5 -0
- package/dist/sync/index.d.ts.map +1 -1
- package/dist/sync/index.js +21 -0
- package/dist/sync/index.js.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands.ts +12 -1
- package/src/dashboard/html.ts +7 -61
- package/src/dashboard/index.html +1016 -739
- package/src/hooks/server.ts +24 -3
- package/src/storage/repo.ts +51 -31
- package/src/sync/index.ts +24 -0
package/src/dashboard/index.html
CHANGED
|
@@ -46,6 +46,16 @@
|
|
|
46
46
|
.header h1 { font-size: 18px; font-weight: 600; }
|
|
47
47
|
.header .subtitle { color: var(--text-dim); font-size: 13px; }
|
|
48
48
|
.header-right { margin-left: auto; display: flex; align-items: center; gap: 10px; }
|
|
49
|
+
.source-badge {
|
|
50
|
+
font-size: 11px;
|
|
51
|
+
font-family: var(--mono);
|
|
52
|
+
padding: 3px 8px;
|
|
53
|
+
border-radius: 4px;
|
|
54
|
+
border: 1px solid var(--border);
|
|
55
|
+
color: var(--text-dim);
|
|
56
|
+
}
|
|
57
|
+
.source-badge.remote { border-color: var(--green); color: var(--green); }
|
|
58
|
+
.source-badge.local { border-color: var(--blue); color: var(--blue); }
|
|
49
59
|
.auto-btn {
|
|
50
60
|
background: none;
|
|
51
61
|
border: 1px solid var(--border);
|
|
@@ -73,38 +83,66 @@
|
|
|
73
83
|
width: 9px; height: 9px;
|
|
74
84
|
}
|
|
75
85
|
|
|
86
|
+
/* Navigation tabs */
|
|
87
|
+
.nav-tabs {
|
|
88
|
+
display: flex;
|
|
89
|
+
gap: 0;
|
|
90
|
+
border-bottom: 1px solid var(--border);
|
|
91
|
+
padding: 0 24px;
|
|
92
|
+
background: var(--bg);
|
|
93
|
+
}
|
|
94
|
+
.nav-tab {
|
|
95
|
+
padding: 10px 20px;
|
|
96
|
+
font-size: 13px;
|
|
97
|
+
font-weight: 500;
|
|
98
|
+
color: var(--text-dim);
|
|
99
|
+
cursor: pointer;
|
|
100
|
+
border: none;
|
|
101
|
+
background: none;
|
|
102
|
+
border-bottom: 2px solid transparent;
|
|
103
|
+
transition: all 0.15s;
|
|
104
|
+
}
|
|
105
|
+
.nav-tab:hover { color: var(--text); }
|
|
106
|
+
.nav-tab.active { color: var(--accent-light); border-bottom-color: var(--accent); }
|
|
107
|
+
|
|
76
108
|
.container { max-width: 1280px; margin: 0 auto; padding: 24px; }
|
|
77
109
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
gap:
|
|
82
|
-
margin-bottom:
|
|
110
|
+
/* Time window selector */
|
|
111
|
+
.time-selector {
|
|
112
|
+
display: flex;
|
|
113
|
+
gap: 4px;
|
|
114
|
+
margin-bottom: 24px;
|
|
115
|
+
align-items: center;
|
|
83
116
|
}
|
|
84
|
-
.
|
|
117
|
+
.time-btn {
|
|
85
118
|
background: var(--surface);
|
|
86
119
|
border: 1px solid var(--border);
|
|
87
|
-
|
|
88
|
-
padding: 14px
|
|
120
|
+
color: var(--text-dim);
|
|
121
|
+
padding: 5px 14px;
|
|
122
|
+
border-radius: 5px;
|
|
123
|
+
cursor: pointer;
|
|
124
|
+
font-size: 12px;
|
|
125
|
+
font-family: var(--mono);
|
|
126
|
+
transition: all 0.15s;
|
|
89
127
|
}
|
|
90
|
-
.
|
|
91
|
-
.
|
|
128
|
+
.time-btn:hover { border-color: var(--accent); color: var(--text); }
|
|
129
|
+
.time-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
|
|
92
130
|
|
|
93
|
-
.
|
|
131
|
+
.stats-grid {
|
|
94
132
|
display: grid;
|
|
95
|
-
grid-template-columns:
|
|
133
|
+
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
|
96
134
|
gap: 16px;
|
|
97
135
|
margin-bottom: 28px;
|
|
98
136
|
}
|
|
99
|
-
.
|
|
100
|
-
.chart-card {
|
|
137
|
+
.stat-card {
|
|
101
138
|
background: var(--surface);
|
|
102
139
|
border: 1px solid var(--border);
|
|
103
|
-
border-radius:
|
|
104
|
-
padding:
|
|
140
|
+
border-radius: 10px;
|
|
141
|
+
padding: 20px 24px;
|
|
105
142
|
}
|
|
106
|
-
.
|
|
107
|
-
.
|
|
143
|
+
.stat-card .label { font-size: 11px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; }
|
|
144
|
+
.stat-card .value { font-size: 28px; font-weight: 600; margin-top: 6px; font-variant-numeric: tabular-nums; font-family: var(--mono); }
|
|
145
|
+
.stat-card.hero .value { font-size: 36px; }
|
|
108
146
|
|
|
109
147
|
.panel {
|
|
110
148
|
background: var(--surface);
|
|
@@ -156,6 +194,51 @@
|
|
|
156
194
|
tr:last-child td { border-bottom: none; }
|
|
157
195
|
tr.clickable:hover td { background: rgba(124, 110, 240, 0.05); cursor: pointer; }
|
|
158
196
|
|
|
197
|
+
.tag-pill {
|
|
198
|
+
display: inline-block;
|
|
199
|
+
padding: 2px 8px;
|
|
200
|
+
border-radius: 4px;
|
|
201
|
+
font-size: 11px;
|
|
202
|
+
font-weight: 500;
|
|
203
|
+
font-family: var(--mono);
|
|
204
|
+
background: rgba(124, 110, 240, 0.12);
|
|
205
|
+
color: var(--accent-light);
|
|
206
|
+
margin-right: 4px;
|
|
207
|
+
margin-bottom: 2px;
|
|
208
|
+
}
|
|
209
|
+
.tag-pill.untagged {
|
|
210
|
+
background: rgba(139, 143, 163, 0.12);
|
|
211
|
+
color: var(--text-dim);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.badge {
|
|
215
|
+
display: inline-block;
|
|
216
|
+
padding: 2px 7px;
|
|
217
|
+
border-radius: 4px;
|
|
218
|
+
font-size: 11px;
|
|
219
|
+
font-weight: 500;
|
|
220
|
+
font-family: var(--mono);
|
|
221
|
+
}
|
|
222
|
+
.badge-cost { background: rgba(255, 152, 0, 0.12); color: var(--orange); }
|
|
223
|
+
.badge-tokens { background: rgba(124, 110, 240, 0.12); color: var(--accent-light); }
|
|
224
|
+
|
|
225
|
+
.empty { padding: 40px; text-align: center; color: var(--text-dim); font-size: 13px; }
|
|
226
|
+
|
|
227
|
+
.back-btn {
|
|
228
|
+
background: var(--surface);
|
|
229
|
+
border: 1px solid var(--border);
|
|
230
|
+
color: var(--text-dim);
|
|
231
|
+
padding: 5px 12px;
|
|
232
|
+
border-radius: 6px;
|
|
233
|
+
cursor: pointer;
|
|
234
|
+
font-size: 12px;
|
|
235
|
+
margin-bottom: 16px;
|
|
236
|
+
display: inline-flex;
|
|
237
|
+
align-items: center;
|
|
238
|
+
gap: 6px;
|
|
239
|
+
}
|
|
240
|
+
.back-btn:hover { border-color: var(--accent); color: var(--text); }
|
|
241
|
+
|
|
159
242
|
.session-id {
|
|
160
243
|
font-family: var(--mono);
|
|
161
244
|
font-size: 12px;
|
|
@@ -168,6 +251,7 @@
|
|
|
168
251
|
.session-id:hover { color: var(--accent-light); }
|
|
169
252
|
.copy-icon { opacity: 0; font-size: 10px; transition: opacity 0.15s; }
|
|
170
253
|
.session-id:hover .copy-icon { opacity: 1; }
|
|
254
|
+
|
|
171
255
|
.copied-toast {
|
|
172
256
|
position: fixed;
|
|
173
257
|
bottom: 24px;
|
|
@@ -186,22 +270,8 @@
|
|
|
186
270
|
}
|
|
187
271
|
.copied-toast.show { opacity: 1; transform: translateY(0); }
|
|
188
272
|
|
|
189
|
-
.
|
|
190
|
-
.
|
|
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); }
|
|
273
|
+
.view { display: none; }
|
|
274
|
+
.view.active { display: block; }
|
|
205
275
|
|
|
206
276
|
.turn { border-bottom: 1px solid var(--border); }
|
|
207
277
|
.turn:last-child { border-bottom: none; }
|
|
@@ -271,107 +341,83 @@
|
|
|
271
341
|
line-height: 1.5;
|
|
272
342
|
}
|
|
273
343
|
|
|
274
|
-
.
|
|
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 {
|
|
344
|
+
.pagination {
|
|
287
345
|
display: flex;
|
|
288
|
-
gap:
|
|
289
|
-
margin-bottom: 8px;
|
|
290
|
-
flex-wrap: wrap;
|
|
346
|
+
gap: 6px;
|
|
291
347
|
align-items: center;
|
|
348
|
+
justify-content: center;
|
|
349
|
+
padding: 12px 16px;
|
|
350
|
+
border-top: 1px solid var(--border);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
th.sortable { cursor: pointer; user-select: none; }
|
|
354
|
+
th.sortable:hover { color: var(--text); }
|
|
355
|
+
th.sortable::after {
|
|
356
|
+
content: '\2195';
|
|
357
|
+
margin-left: 4px;
|
|
358
|
+
font-size: 10px;
|
|
359
|
+
opacity: 0.3;
|
|
292
360
|
}
|
|
293
|
-
.
|
|
361
|
+
th.sortable.asc::after { content: '\2191'; opacity: 1; }
|
|
362
|
+
th.sortable.desc::after { content: '\2193'; opacity: 1; }
|
|
363
|
+
|
|
364
|
+
.chart-card {
|
|
294
365
|
background: var(--surface);
|
|
295
366
|
border: 1px solid var(--border);
|
|
296
|
-
|
|
297
|
-
padding:
|
|
298
|
-
|
|
299
|
-
cursor: pointer;
|
|
300
|
-
font-size: 12px;
|
|
301
|
-
font-family: var(--mono);
|
|
302
|
-
transition: all 0.15s;
|
|
367
|
+
border-radius: 8px;
|
|
368
|
+
padding: 16px;
|
|
369
|
+
margin-bottom: 20px;
|
|
303
370
|
}
|
|
304
|
-
.
|
|
305
|
-
.
|
|
306
|
-
.range-divider { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
|
|
371
|
+
.chart-card h3 { font-size: 12px; color: var(--text-dim); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 12px; }
|
|
372
|
+
.chart-card canvas { width: 100% !important; }
|
|
307
373
|
|
|
308
|
-
.
|
|
309
|
-
|
|
374
|
+
.project-list { list-style: none; }
|
|
375
|
+
.project-item {
|
|
376
|
+
display: flex;
|
|
310
377
|
align-items: center;
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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;
|
|
378
|
+
justify-content: space-between;
|
|
379
|
+
padding: 10px 16px;
|
|
380
|
+
border-bottom: 1px solid var(--border);
|
|
321
381
|
}
|
|
322
|
-
.
|
|
323
|
-
.
|
|
324
|
-
background: var(--bg);
|
|
325
|
-
border: 1px solid var(--border);
|
|
326
|
-
border-radius: 5px;
|
|
327
|
-
color: var(--text);
|
|
328
|
-
font-size: 12px;
|
|
382
|
+
.project-item:last-child { border-bottom: none; }
|
|
383
|
+
.project-name {
|
|
329
384
|
font-family: var(--mono);
|
|
330
|
-
|
|
331
|
-
|
|
385
|
+
font-size: 13px;
|
|
386
|
+
overflow: hidden;
|
|
387
|
+
text-overflow: ellipsis;
|
|
388
|
+
white-space: nowrap;
|
|
389
|
+
flex: 1;
|
|
390
|
+
margin-right: 16px;
|
|
391
|
+
}
|
|
392
|
+
.project-bar-wrap {
|
|
393
|
+
width: 120px;
|
|
394
|
+
height: 6px;
|
|
395
|
+
background: var(--surface2);
|
|
396
|
+
border-radius: 3px;
|
|
397
|
+
margin-right: 12px;
|
|
398
|
+
flex-shrink: 0;
|
|
332
399
|
}
|
|
333
|
-
.
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
.apply-btn {
|
|
400
|
+
.project-bar {
|
|
401
|
+
height: 100%;
|
|
402
|
+
border-radius: 3px;
|
|
337
403
|
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
404
|
}
|
|
348
|
-
.
|
|
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;
|
|
405
|
+
.project-cost {
|
|
359
406
|
font-family: var(--mono);
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
407
|
+
font-size: 12px;
|
|
408
|
+
color: var(--orange);
|
|
409
|
+
white-space: nowrap;
|
|
410
|
+
min-width: 70px;
|
|
411
|
+
text-align: right;
|
|
364
412
|
}
|
|
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
413
|
|
|
369
414
|
:not(button)[title] { cursor: help; }
|
|
370
415
|
|
|
371
416
|
@media (max-width: 800px) {
|
|
372
|
-
.
|
|
373
|
-
.stats-grid { grid-template-columns: repeat(2, 1fr); }
|
|
417
|
+
.stats-grid { grid-template-columns: 1fr 1fr; }
|
|
374
418
|
.panel-filter { width: 150px; }
|
|
419
|
+
.stat-card .value { font-size: 22px; }
|
|
420
|
+
.stat-card.hero .value { font-size: 28px; }
|
|
375
421
|
}
|
|
376
422
|
</style>
|
|
377
423
|
</head>
|
|
@@ -381,121 +427,153 @@
|
|
|
381
427
|
<h1>zozul</h1>
|
|
382
428
|
<span class="subtitle">Agent Observability</span>
|
|
383
429
|
<div class="header-right">
|
|
384
|
-
<
|
|
430
|
+
<span class="source-badge" id="source-badge" style="display:none">Local</span>
|
|
431
|
+
<button class="auto-btn" onclick="manualRefresh()" title="Auto-refresh every 10s">
|
|
385
432
|
<span class="live-dot"></span>
|
|
386
433
|
Auto
|
|
387
434
|
</button>
|
|
388
435
|
</div>
|
|
389
436
|
</div>
|
|
390
437
|
|
|
438
|
+
<div class="nav-tabs">
|
|
439
|
+
<button class="nav-tab active" data-view="summary">Summary</button>
|
|
440
|
+
<button class="nav-tab" data-view="tasks">Tasks</button>
|
|
441
|
+
<button class="nav-tab" data-view="tags">Tags</button>
|
|
442
|
+
<button class="nav-tab" data-view="sessions">Sessions</button>
|
|
443
|
+
</div>
|
|
444
|
+
|
|
391
445
|
<div class="container">
|
|
392
|
-
<!--
|
|
393
|
-
<div id="
|
|
394
|
-
<div class="stats-grid" id="stats
|
|
395
|
-
<div class="
|
|
396
|
-
|
|
397
|
-
<
|
|
398
|
-
<
|
|
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>
|
|
446
|
+
<!-- Summary View -->
|
|
447
|
+
<div id="view-summary" class="view active">
|
|
448
|
+
<div class="stats-grid" id="summary-stats"></div>
|
|
449
|
+
<div class="chart-card"><h3>Daily Cost (30d)</h3><canvas id="chart-daily-cost"></canvas></div>
|
|
450
|
+
<div class="panel">
|
|
451
|
+
<div class="panel-header"><span>Cost by Project</span></div>
|
|
452
|
+
<ul class="project-list" id="project-breakdown"></ul>
|
|
404
453
|
</div>
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
<
|
|
411
|
-
|
|
412
|
-
<
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
<
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
454
|
+
</div>
|
|
455
|
+
|
|
456
|
+
<!-- Sessions View -->
|
|
457
|
+
<div id="view-sessions" class="view">
|
|
458
|
+
<div class="panel">
|
|
459
|
+
<div class="panel-header">
|
|
460
|
+
<span>Sessions <span id="sessions-count" style="font-weight:400"></span></span>
|
|
461
|
+
<input class="panel-filter" id="session-filter" placeholder="filter by id or project..." oninput="filterSessions(this.value)">
|
|
462
|
+
</div>
|
|
463
|
+
<table>
|
|
464
|
+
<thead><tr>
|
|
465
|
+
<th class="sortable" data-sort="sessions:id">Session ID</th>
|
|
466
|
+
<th class="sortable" data-sort="sessions:project_path">Project</th>
|
|
467
|
+
<th class="sortable" data-sort="sessions:started_at">Started</th>
|
|
468
|
+
<th class="sortable" data-sort="sessions:total_duration_ms">Duration</th>
|
|
469
|
+
<th class="sortable" data-sort="sessions:total_turns">Turns</th>
|
|
470
|
+
<th class="sortable desc" data-sort="sessions:total_cost_usd">Cost</th>
|
|
471
|
+
<th class="sortable" data-sort="sessions:model">Model</th>
|
|
472
|
+
</tr></thead>
|
|
473
|
+
<tbody id="sessions-table"></tbody>
|
|
474
|
+
</table>
|
|
475
|
+
<div id="load-more-row" style="display:none;padding:12px 16px;text-align:center;border-top:1px solid var(--border)">
|
|
476
|
+
<button class="time-btn" onclick="loadMoreSessions()">Load more</button>
|
|
477
|
+
<span id="sessions-shown" style="font-size:12px;color:var(--text-dim);margin-left:12px"></span>
|
|
478
|
+
</div>
|
|
421
479
|
</div>
|
|
422
|
-
|
|
423
|
-
|
|
480
|
+
</div>
|
|
481
|
+
|
|
482
|
+
<!-- Task View -->
|
|
483
|
+
<div id="view-tasks" class="view">
|
|
484
|
+
<div class="panel">
|
|
485
|
+
<div class="panel-header">
|
|
486
|
+
<span>Tasks</span>
|
|
487
|
+
<input class="panel-filter" id="task-filter" placeholder="filter tasks..." oninput="filterTaskTable(this.value)">
|
|
488
|
+
</div>
|
|
489
|
+
<table>
|
|
490
|
+
<thead><tr>
|
|
491
|
+
<th class="sortable" data-sort="tasks:tags">Task</th>
|
|
492
|
+
<th class="sortable" data-sort="tasks:total_duration_ms">Process Time</th>
|
|
493
|
+
<th class="sortable desc" data-sort="tasks:total_cost_usd">Cost</th>
|
|
494
|
+
<th class="sortable" data-sort="tasks:human_interventions">Human Interventions</th>
|
|
495
|
+
<th class="sortable" data-sort="tasks:last_seen">Last Active</th>
|
|
496
|
+
</tr></thead>
|
|
497
|
+
<tbody id="task-table"></tbody>
|
|
498
|
+
</table>
|
|
424
499
|
</div>
|
|
425
|
-
|
|
500
|
+
</div>
|
|
501
|
+
|
|
502
|
+
<!-- Tag View -->
|
|
503
|
+
<div id="view-tags" class="view">
|
|
504
|
+
<div class="panel">
|
|
426
505
|
<div class="panel-header">
|
|
427
|
-
<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>
|
|
506
|
+
<span>Tags</span>
|
|
439
507
|
</div>
|
|
440
|
-
<
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
<
|
|
445
|
-
<
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
508
|
+
<table>
|
|
509
|
+
<thead><tr>
|
|
510
|
+
<th class="sortable" data-sort="tags:tag">Tag</th>
|
|
511
|
+
<th class="sortable" data-sort="tags:total_duration_ms">Total Time</th>
|
|
512
|
+
<th class="sortable desc" data-sort="tags:total_cost_usd">Total Cost</th>
|
|
513
|
+
<th class="sortable" data-sort="tags:human_interventions">Human Interventions</th>
|
|
514
|
+
<th class="sortable" data-sort="tags:last_seen">Last Active</th>
|
|
515
|
+
</tr></thead>
|
|
516
|
+
<tbody id="tag-table"></tbody>
|
|
517
|
+
</table>
|
|
518
|
+
</div>
|
|
519
|
+
</div>
|
|
520
|
+
|
|
521
|
+
<!-- Tag Detail View (drill-down from Tag View) -->
|
|
522
|
+
<div id="view-tag-detail" class="view">
|
|
523
|
+
<button class="back-btn" onclick="showView('tags')">← Tags <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
|
|
524
|
+
<div class="stats-grid" id="tag-detail-stats"></div>
|
|
525
|
+
<div class="panel">
|
|
526
|
+
<div class="panel-header">
|
|
527
|
+
<span>Turns <span id="tag-turns-count" style="font-weight:400;font-size:11px;color:var(--text-dim)"></span></span>
|
|
528
|
+
<div id="tag-turns-pagination" style="display:flex;gap:6px;align-items:center"></div>
|
|
458
529
|
</div>
|
|
530
|
+
<table>
|
|
531
|
+
<thead><tr>
|
|
532
|
+
<th>Time</th>
|
|
533
|
+
<th>Prompt</th>
|
|
534
|
+
<th>Session</th>
|
|
535
|
+
<th>Cost</th>
|
|
536
|
+
</tr></thead>
|
|
537
|
+
<tbody id="tag-turns-table"></tbody>
|
|
538
|
+
</table>
|
|
459
539
|
</div>
|
|
540
|
+
</div>
|
|
541
|
+
|
|
542
|
+
<!-- Task Group Detail View (drill-down from Task View) -->
|
|
543
|
+
<div id="view-task-detail" class="view">
|
|
544
|
+
<button class="back-btn" onclick="showView('tasks')">← Tasks <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
|
|
545
|
+
<div class="stats-grid" id="task-detail-stats"></div>
|
|
460
546
|
<div class="panel">
|
|
461
547
|
<div class="panel-header">
|
|
462
|
-
<span>
|
|
463
|
-
<
|
|
548
|
+
<span>Turns <span id="task-turns-count" style="font-weight:400;font-size:11px;color:var(--text-dim)"></span></span>
|
|
549
|
+
<div id="task-turns-pagination" style="display:flex;gap:6px;align-items:center"></div>
|
|
464
550
|
</div>
|
|
465
551
|
<table>
|
|
466
552
|
<thead><tr>
|
|
467
|
-
<th>
|
|
468
|
-
<th>
|
|
469
|
-
<th>Started</th>
|
|
553
|
+
<th>Time</th>
|
|
554
|
+
<th>Prompt</th>
|
|
470
555
|
<th>Duration</th>
|
|
471
|
-
<th>
|
|
472
|
-
<th>In Tokens</th>
|
|
473
|
-
<th>Out Tokens</th>
|
|
556
|
+
<th>Tokens</th>
|
|
474
557
|
<th>Cost</th>
|
|
475
|
-
<th>Model</th>
|
|
476
558
|
</tr></thead>
|
|
477
|
-
<tbody id="
|
|
559
|
+
<tbody id="task-turns-table"></tbody>
|
|
478
560
|
</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
561
|
</div>
|
|
484
562
|
</div>
|
|
485
563
|
|
|
486
|
-
<!-- Session
|
|
487
|
-
<div id="detail
|
|
488
|
-
<button class="back-btn" onclick="
|
|
489
|
-
<div class="stats-grid" id="detail-stats"></div>
|
|
564
|
+
<!-- Session Detail View -->
|
|
565
|
+
<div id="view-session-detail" class="view">
|
|
566
|
+
<button class="back-btn" id="session-back-btn" onclick="showView('tasks')">← Back <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
|
|
567
|
+
<div class="stats-grid" id="session-detail-stats"></div>
|
|
490
568
|
<div class="panel">
|
|
491
569
|
<div class="panel-header">Conversation</div>
|
|
492
|
-
<div id="detail-turns"></div>
|
|
570
|
+
<div id="session-detail-turns"></div>
|
|
493
571
|
</div>
|
|
494
572
|
</div>
|
|
495
573
|
|
|
496
|
-
<!-- Turn
|
|
497
|
-
<div id="turn-detail
|
|
498
|
-
<button class="back-btn" onclick="
|
|
574
|
+
<!-- Turn Block Detail View -->
|
|
575
|
+
<div id="view-turn-detail" class="view">
|
|
576
|
+
<button class="back-btn" id="turn-back-btn" onclick="goBackFromTurn()">← Back <span style="font-size:10px;margin-left:4px;color:var(--text-dim)">[Esc]</span></button>
|
|
499
577
|
<div class="stats-grid" id="turn-detail-stats"></div>
|
|
500
578
|
<div class="panel">
|
|
501
579
|
<div class="panel-header">Conversation</div>
|
|
@@ -507,45 +585,132 @@
|
|
|
507
585
|
<div class="copied-toast" id="copied-toast">Copied!</div>
|
|
508
586
|
|
|
509
587
|
<script>
|
|
510
|
-
|
|
588
|
+
// ── State ──
|
|
589
|
+
|
|
590
|
+
let dataSource = 'local';
|
|
591
|
+
let autoRefreshTimer = null;
|
|
592
|
+
let currentView = 'summary';
|
|
593
|
+
let currentTimeWindow = '7d';
|
|
594
|
+
let previousView = 'tasks';
|
|
595
|
+
let allTaskGroups = [];
|
|
596
|
+
let allTagStats = [];
|
|
597
|
+
let sortState = {
|
|
598
|
+
tasks: { key: 'total_cost_usd', dir: 'desc' },
|
|
599
|
+
tags: { key: 'total_cost_usd', dir: 'desc' },
|
|
600
|
+
sessions: { key: 'started_at', dir: 'desc' },
|
|
601
|
+
};
|
|
511
602
|
let allSessions = [];
|
|
512
603
|
let sessionsTotal = 0;
|
|
513
604
|
let sessionsOffset = 0;
|
|
514
605
|
const SESSIONS_PAGE = 50;
|
|
515
|
-
let
|
|
516
|
-
let
|
|
517
|
-
let
|
|
518
|
-
let
|
|
519
|
-
let
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
606
|
+
let chartInstances = {};
|
|
607
|
+
let tagDetailName = '';
|
|
608
|
+
let tagDetailOffset = 0;
|
|
609
|
+
let taskDetailTags = [];
|
|
610
|
+
let taskDetailOffset = 0;
|
|
611
|
+
const PAGE_SIZE = 20;
|
|
612
|
+
const AUTO_REFRESH_INTERVAL = 10_000;
|
|
613
|
+
|
|
614
|
+
// ── Data source auto-detection ──
|
|
615
|
+
|
|
616
|
+
async function detectDataSource() {
|
|
617
|
+
const badge = document.getElementById('source-badge');
|
|
618
|
+
if (typeof ZOZUL_CONFIG === 'undefined') {
|
|
619
|
+
dataSource = 'local';
|
|
620
|
+
badge.textContent = 'Local';
|
|
621
|
+
badge.className = 'source-badge local';
|
|
622
|
+
badge.style.display = '';
|
|
623
|
+
return;
|
|
525
624
|
}
|
|
526
|
-
return 'range=' + currentRange;
|
|
527
|
-
}
|
|
528
625
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
const
|
|
533
|
-
const
|
|
534
|
-
|
|
626
|
+
try {
|
|
627
|
+
const controller = new AbortController();
|
|
628
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
629
|
+
const healthUrl = ZOZUL_CONFIG.remote.baseUrl + '/health';
|
|
630
|
+
const res = await fetch(healthUrl, {
|
|
631
|
+
signal: controller.signal,
|
|
632
|
+
headers: { 'X-API-Key': ZOZUL_CONFIG.remote.apiKey },
|
|
633
|
+
});
|
|
634
|
+
clearTimeout(timeout);
|
|
635
|
+
if (res.ok) {
|
|
636
|
+
dataSource = 'remote';
|
|
637
|
+
badge.textContent = 'Remote';
|
|
638
|
+
badge.className = 'source-badge remote';
|
|
639
|
+
} else {
|
|
640
|
+
dataSource = 'local';
|
|
641
|
+
badge.textContent = 'Local';
|
|
642
|
+
badge.className = 'source-badge local';
|
|
643
|
+
}
|
|
644
|
+
} catch {
|
|
645
|
+
dataSource = 'local';
|
|
646
|
+
badge.textContent = 'Local';
|
|
647
|
+
badge.className = 'source-badge local';
|
|
535
648
|
}
|
|
536
|
-
|
|
649
|
+
badge.style.display = '';
|
|
537
650
|
}
|
|
538
651
|
|
|
539
652
|
async function fetchJson(path) {
|
|
653
|
+
if (dataSource === 'remote' && typeof ZOZUL_CONFIG !== 'undefined') {
|
|
654
|
+
try {
|
|
655
|
+
const url = ZOZUL_CONFIG.remote.baseUrl + path.replace('/api/', '/');
|
|
656
|
+
const res = await fetch(url, { headers: { 'X-API-Key': ZOZUL_CONFIG.remote.apiKey } });
|
|
657
|
+
if (res.ok) return res.json();
|
|
658
|
+
} catch {}
|
|
659
|
+
// Remote failed — fall back to local for this request
|
|
660
|
+
}
|
|
540
661
|
const res = await fetch(path);
|
|
541
662
|
if (!res.ok) throw new Error(res.status + ' ' + path);
|
|
542
663
|
return res.json();
|
|
543
664
|
}
|
|
544
665
|
|
|
666
|
+
// ── Client-side proportional cost ──
|
|
667
|
+
|
|
668
|
+
const sessionCache = {};
|
|
669
|
+
|
|
670
|
+
async function fetchSessionCached(sessionId) {
|
|
671
|
+
if (sessionCache[sessionId]) return sessionCache[sessionId];
|
|
672
|
+
const session = await fetchJson('/api/sessions/' + sessionId);
|
|
673
|
+
sessionCache[sessionId] = session;
|
|
674
|
+
return session;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
function proportionalCost(session, turn) {
|
|
678
|
+
const hasCache = turn.cache_read !== undefined && turn.cache_read !== null;
|
|
679
|
+
const sessionTotal = (session.total_input_tokens || 0) + (session.total_output_tokens || 0)
|
|
680
|
+
+ (hasCache ? (session.total_cache_read_tokens || 0) + (session.total_cache_creation_tokens || 0) : 0);
|
|
681
|
+
if (sessionTotal === 0 || !session.total_cost_usd) return 0;
|
|
682
|
+
const turnTotal = (turn.input || 0) + (turn.output || 0)
|
|
683
|
+
+ (hasCache ? (turn.cache_read || 0) + (turn.cache_creation || 0) : 0);
|
|
684
|
+
return session.total_cost_usd * turnTotal / sessionTotal;
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
async function enrichTurnsWithCost(turns) {
|
|
688
|
+
const ids = [...new Set(turns.map(t => t.session_id).filter(Boolean))];
|
|
689
|
+
await Promise.all(ids.map(fetchSessionCached));
|
|
690
|
+
for (const t of turns) {
|
|
691
|
+
const s = sessionCache[t.session_id];
|
|
692
|
+
if (!s) continue;
|
|
693
|
+
t.estimated_cost_usd = proportionalCost(s, {
|
|
694
|
+
input: t.input_tokens, output: t.output_tokens,
|
|
695
|
+
cache_read: t.cache_read_tokens, cache_creation: t.cache_creation_tokens,
|
|
696
|
+
});
|
|
697
|
+
if (t.block_input_tokens !== undefined) {
|
|
698
|
+
t.block_cost_usd = proportionalCost(s, {
|
|
699
|
+
input: t.block_input_tokens, output: t.block_output_tokens,
|
|
700
|
+
cache_read: t.block_cache_read_tokens, cache_creation: t.block_cache_creation_tokens,
|
|
701
|
+
});
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
return turns;
|
|
705
|
+
}
|
|
706
|
+
|
|
545
707
|
// ── Formatting ──
|
|
546
708
|
|
|
547
709
|
function fmtNum(n) { return (n ?? 0).toLocaleString(); }
|
|
548
|
-
function fmtCost(n) {
|
|
710
|
+
function fmtCost(n) {
|
|
711
|
+
const v = n ?? 0;
|
|
712
|
+
return v >= 1 ? '$' + v.toFixed(2) : '$' + v.toFixed(4);
|
|
713
|
+
}
|
|
549
714
|
function fmtDuration(ms) {
|
|
550
715
|
if (!ms || ms <= 0) return '\u2014';
|
|
551
716
|
const s = Math.floor(ms / 1000);
|
|
@@ -570,6 +735,18 @@ function fmtAbsolute(s) {
|
|
|
570
735
|
if (!s) return '';
|
|
571
736
|
try { return new Date(s).toLocaleString(); } catch { return s; }
|
|
572
737
|
}
|
|
738
|
+
// SQLite datetime() returns "YYYY-MM-DD HH:MM:SS" with no timezone — append Z to parse as UTC
|
|
739
|
+
function sqliteUtcToLocal(s) {
|
|
740
|
+
return new Date(s.replace(' ', 'T') + 'Z');
|
|
741
|
+
}
|
|
742
|
+
function fmtChartTs(s, span) {
|
|
743
|
+
const d = sqliteUtcToLocal(s);
|
|
744
|
+
if (span && span <= 24 * 3600000) {
|
|
745
|
+
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
746
|
+
}
|
|
747
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +
|
|
748
|
+
d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
749
|
+
}
|
|
573
750
|
function shortId(s) { return s ? s.slice(0, 8) : '\u2014'; }
|
|
574
751
|
function basename(p) {
|
|
575
752
|
if (!p) return '\u2014';
|
|
@@ -581,8 +758,6 @@ function escHtml(s) {
|
|
|
581
758
|
return d.innerHTML;
|
|
582
759
|
}
|
|
583
760
|
|
|
584
|
-
// ── Copy to clipboard ──
|
|
585
|
-
|
|
586
761
|
function copyText(text) {
|
|
587
762
|
navigator.clipboard.writeText(text).then(() => {
|
|
588
763
|
const toast = document.getElementById('copied-toast');
|
|
@@ -591,280 +766,521 @@ function copyText(text) {
|
|
|
591
766
|
});
|
|
592
767
|
}
|
|
593
768
|
|
|
594
|
-
// ──
|
|
769
|
+
// ── Navigation ──
|
|
595
770
|
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
const
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
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');
|
|
771
|
+
function showView(name) {
|
|
772
|
+
currentView = name;
|
|
773
|
+
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
774
|
+
const el = document.getElementById('view-' + name);
|
|
775
|
+
if (el) el.classList.add('active');
|
|
776
|
+
|
|
777
|
+
// Update tab highlight for main views
|
|
778
|
+
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
|
779
|
+
const mainView = ['summary', 'sessions', 'tasks', 'tags'].includes(name) ? name : null;
|
|
780
|
+
if (mainView) {
|
|
781
|
+
const tab = document.querySelector('.nav-tab[data-view="' + mainView + '"]');
|
|
782
|
+
if (tab) tab.classList.add('active');
|
|
625
783
|
}
|
|
626
784
|
}
|
|
627
785
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
786
|
+
document.querySelector('.nav-tabs').addEventListener('click', e => {
|
|
787
|
+
const tab = e.target.closest('.nav-tab');
|
|
788
|
+
if (!tab) return;
|
|
789
|
+
const view = tab.dataset.view;
|
|
790
|
+
showView(view);
|
|
791
|
+
loadViewData(view);
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
document.addEventListener('keydown', e => {
|
|
795
|
+
if (e.key === 'Escape') {
|
|
796
|
+
if (['tag-detail', 'task-detail'].includes(currentView)) {
|
|
797
|
+
showView(previousView || 'tasks');
|
|
798
|
+
} else if (['session-detail', 'turn-detail'].includes(currentView)) {
|
|
799
|
+
goBackFromTurn();
|
|
800
|
+
}
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
document.addEventListener('click', e => {
|
|
805
|
+
const th = e.target.closest('th.sortable');
|
|
806
|
+
if (th) {
|
|
807
|
+
const [table, key] = th.dataset.sort.split(':');
|
|
808
|
+
const state = sortState[table];
|
|
809
|
+
if (state.key === key) {
|
|
810
|
+
state.dir = state.dir === 'asc' ? 'desc' : 'asc';
|
|
811
|
+
} else {
|
|
812
|
+
state.key = key;
|
|
813
|
+
state.dir = key === 'tags' || key === 'tag' ? 'asc' : 'desc';
|
|
814
|
+
}
|
|
815
|
+
// Update header indicators
|
|
816
|
+
th.closest('thead').querySelectorAll('th.sortable').forEach(h => {
|
|
817
|
+
h.classList.remove('asc', 'desc');
|
|
818
|
+
const [t, k] = h.dataset.sort.split(':');
|
|
819
|
+
if (k === state.key) h.classList.add(state.dir);
|
|
820
|
+
});
|
|
821
|
+
if (table === 'tasks') renderTaskTable(sortData(allTaskGroups, state));
|
|
822
|
+
else if (table === 'tags') renderTagTable(sortData(allTagStats, state));
|
|
823
|
+
else if (table === 'sessions') renderSessionsTable(sortData(allSessions, state));
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
});
|
|
827
|
+
|
|
828
|
+
function sortData(arr, state) {
|
|
829
|
+
const { key, dir } = state;
|
|
830
|
+
return [...arr].sort((a, b) => {
|
|
831
|
+
let va = a[key], vb = b[key];
|
|
832
|
+
if (typeof va === 'string' && typeof vb === 'string') {
|
|
833
|
+
va = va.toLowerCase(); vb = vb.toLowerCase();
|
|
834
|
+
return dir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va);
|
|
835
|
+
}
|
|
836
|
+
va = va ?? 0; vb = vb ?? 0;
|
|
837
|
+
return dir === 'asc' ? va - vb : vb - va;
|
|
838
|
+
});
|
|
631
839
|
}
|
|
632
840
|
|
|
633
|
-
|
|
841
|
+
// ── Summary View ──
|
|
842
|
+
|
|
843
|
+
async function loadSummary() {
|
|
634
844
|
try {
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
845
|
+
const [stats, tasks, costSeries, sessionsResp] = await Promise.all([
|
|
846
|
+
fetchJson('/api/stats'),
|
|
847
|
+
fetchJson('/api/tasks'),
|
|
848
|
+
fetchJson('/api/metrics/cost?range=' + (currentTimeWindow === 'all' ? '90d' : currentTimeWindow) + '&step=1d'),
|
|
849
|
+
fetchJson('/api/sessions?limit=500&offset=0'),
|
|
850
|
+
]);
|
|
851
|
+
renderSummaryStats({
|
|
852
|
+
totalCost: stats.total_cost_usd ?? 0,
|
|
853
|
+
totalSessions: stats.total_sessions ?? 0,
|
|
854
|
+
totalTasks: tasks.length ?? 0,
|
|
855
|
+
});
|
|
856
|
+
renderDailyCostChart(costSeries);
|
|
857
|
+
const sessions = Array.isArray(sessionsResp) ? sessionsResp : sessionsResp.sessions;
|
|
858
|
+
renderProjectBreakdown(sessions);
|
|
643
859
|
} catch (e) {
|
|
644
|
-
console.error('load
|
|
860
|
+
console.error('summary load failed', e);
|
|
645
861
|
}
|
|
646
862
|
}
|
|
647
863
|
|
|
648
|
-
function
|
|
649
|
-
|
|
650
|
-
const
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
shown.textContent = sessionsOffset + ' of ' + sessionsTotal + ' shown';
|
|
656
|
-
} else {
|
|
657
|
-
row.style.display = 'none';
|
|
658
|
-
}
|
|
659
|
-
}
|
|
864
|
+
function renderDailyCostChart(costData) {
|
|
865
|
+
if (!costData || !costData.length) return;
|
|
866
|
+
const labels = costData.map(d => {
|
|
867
|
+
const dt = new Date(d.timestamp);
|
|
868
|
+
return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
869
|
+
});
|
|
870
|
+
const data = costData.map(d => d.cost);
|
|
660
871
|
|
|
661
|
-
const
|
|
872
|
+
const existing = chartInstances['chart-daily-cost'];
|
|
873
|
+
if (existing) {
|
|
874
|
+
existing.data.labels = labels;
|
|
875
|
+
existing.data.datasets[0].data = data;
|
|
876
|
+
existing.update('none');
|
|
877
|
+
return;
|
|
878
|
+
}
|
|
662
879
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
880
|
+
const ctx = document.getElementById('chart-daily-cost');
|
|
881
|
+
if (!ctx) return;
|
|
882
|
+
const chart = new Chart(ctx, {
|
|
883
|
+
type: 'bar',
|
|
884
|
+
data: {
|
|
885
|
+
labels,
|
|
886
|
+
datasets: [{
|
|
887
|
+
label: 'Cost (USD)',
|
|
888
|
+
data,
|
|
889
|
+
backgroundColor: 'rgba(255,152,0,0.3)',
|
|
890
|
+
borderColor: '#ff9800',
|
|
891
|
+
borderWidth: 1,
|
|
892
|
+
borderRadius: 3,
|
|
893
|
+
}]
|
|
894
|
+
},
|
|
895
|
+
options: {
|
|
896
|
+
responsive: true,
|
|
897
|
+
plugins: {
|
|
898
|
+
legend: { display: false },
|
|
899
|
+
tooltip: { callbacks: { label: ctx => '$' + ctx.parsed.y.toFixed(4) } },
|
|
900
|
+
},
|
|
901
|
+
scales: {
|
|
902
|
+
x: { ticks: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 } } },
|
|
903
|
+
y: { ticks: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 }, callback: v => '$' + v.toFixed(2) } },
|
|
904
|
+
}
|
|
905
|
+
}
|
|
906
|
+
});
|
|
907
|
+
chartInstances['chart-daily-cost'] = chart;
|
|
668
908
|
}
|
|
669
909
|
|
|
670
|
-
|
|
910
|
+
function renderProjectBreakdown(sessions) {
|
|
911
|
+
const el = document.getElementById('project-breakdown');
|
|
912
|
+
if (!sessions || !sessions.length) {
|
|
913
|
+
el.innerHTML = '<li class="empty">No sessions.</li>';
|
|
914
|
+
return;
|
|
915
|
+
}
|
|
916
|
+
// Group by project
|
|
917
|
+
const byProject = {};
|
|
918
|
+
for (const s of sessions) {
|
|
919
|
+
const proj = s.project_path || 'Unknown';
|
|
920
|
+
if (!byProject[proj]) byProject[proj] = 0;
|
|
921
|
+
byProject[proj] += s.total_cost_usd || 0;
|
|
922
|
+
}
|
|
923
|
+
const sorted = Object.entries(byProject).sort((a, b) => b[1] - a[1]);
|
|
924
|
+
const maxCost = sorted[0]?.[1] || 1;
|
|
925
|
+
|
|
926
|
+
el.innerHTML = sorted.slice(0, 10).map(([proj, cost]) => {
|
|
927
|
+
const pct = Math.round(cost / maxCost * 100);
|
|
928
|
+
return '<li class="project-item">' +
|
|
929
|
+
'<span class="project-name" title="' + escHtml(proj) + '">' + escHtml(basename(proj)) + '</span>' +
|
|
930
|
+
'<div class="project-bar-wrap"><div class="project-bar" style="width:' + pct + '%"></div></div>' +
|
|
931
|
+
'<span class="project-cost">' + fmtCost(cost) + '</span>' +
|
|
932
|
+
'</li>';
|
|
933
|
+
}).join('');
|
|
934
|
+
}
|
|
671
935
|
|
|
672
|
-
function
|
|
673
|
-
const grid = document.getElementById('stats
|
|
936
|
+
function renderSummaryStats(s) {
|
|
937
|
+
const grid = document.getElementById('summary-stats');
|
|
674
938
|
grid.innerHTML = [
|
|
675
|
-
{ label: '
|
|
676
|
-
{ label: '
|
|
677
|
-
{ label: '
|
|
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) },
|
|
939
|
+
{ label: 'Total Cost', value: fmtCost(s.totalCost), hero: true },
|
|
940
|
+
{ label: 'Total Sessions', value: fmtNum(s.totalSessions), hero: true },
|
|
941
|
+
{ label: 'Total Tasks', value: fmtNum(s.totalTasks), hero: true },
|
|
682
942
|
].map(c =>
|
|
683
|
-
'<div class="stat-card
|
|
943
|
+
'<div class="stat-card' + (c.hero ? ' hero' : '') + '">' +
|
|
684
944
|
'<div class="label">' + c.label + '</div>' +
|
|
685
945
|
'<div class="value">' + c.value + '</div>' +
|
|
686
946
|
'</div>'
|
|
687
947
|
).join('');
|
|
688
948
|
}
|
|
689
949
|
|
|
690
|
-
// ──
|
|
691
|
-
|
|
692
|
-
let allTasks = [];
|
|
693
|
-
let selectedTags = new Set();
|
|
694
|
-
let taskTurnsOffset = 0;
|
|
695
|
-
const TURNS_PAGE = 10;
|
|
950
|
+
// ── Task View ──
|
|
696
951
|
|
|
697
|
-
function
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
}
|
|
952
|
+
async function loadTasks() {
|
|
953
|
+
try {
|
|
954
|
+
const [stats, tasks] = await Promise.all([
|
|
955
|
+
fetchJson('/api/stats'),
|
|
956
|
+
fetchJson('/api/tasks'),
|
|
957
|
+
]);
|
|
958
|
+
if (!tasks.length) { allTaskGroups = []; renderTaskTable([]); return; }
|
|
704
959
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
960
|
+
// Discover tag combinations by sampling one turn per tag
|
|
961
|
+
const samples = await Promise.all(
|
|
962
|
+
tasks.map(t => fetchJson('/api/tasks/turns?tags=' + encodeURIComponent(t.task) + '&mode=any&limit=1')
|
|
963
|
+
.then(turns => turns[0]?.tags || t.task)
|
|
964
|
+
.catch(() => t.task)
|
|
965
|
+
)
|
|
966
|
+
);
|
|
967
|
+
// Deduplicate: normalize each combo to sorted pipe-separated
|
|
968
|
+
const combos = [...new Set(samples.map(s => s.split(', ').sort().join('|')))];
|
|
969
|
+
|
|
970
|
+
// Fetch stats for each unique combo
|
|
971
|
+
const comboStats = await Promise.all(
|
|
972
|
+
combos.map(combo => {
|
|
973
|
+
const tags = combo.split('|');
|
|
974
|
+
const qs = 'tags=' + tags.map(encodeURIComponent).join(',') + '&mode=all';
|
|
975
|
+
return fetchJson('/api/tasks/stats?' + qs)
|
|
976
|
+
.then(s => ({ ...s, tags: combo }))
|
|
977
|
+
.catch(() => ({ tags: combo, total_turns: 0, user_turns: 0, total_duration_ms: 0, total_cost_usd: 0 }));
|
|
978
|
+
})
|
|
979
|
+
);
|
|
980
|
+
|
|
981
|
+
let taskGroups = comboStats.map(s => ({
|
|
982
|
+
tags: s.tags,
|
|
983
|
+
turn_count: s.total_turns ?? 0,
|
|
984
|
+
human_interventions: s.user_turns ?? 0,
|
|
985
|
+
total_duration_ms: s.total_duration_ms ?? 0,
|
|
986
|
+
total_cost_usd: s.total_cost_usd ?? 0,
|
|
987
|
+
last_seen: s.last_seen ?? null,
|
|
988
|
+
}));
|
|
989
|
+
|
|
990
|
+
// Add cost gap to the Untagged row so task costs sum to total
|
|
991
|
+
const attributedCost = taskGroups.reduce((s, g) => s + (g.total_cost_usd || 0), 0);
|
|
992
|
+
const totalCost = stats.total_cost_usd || 0;
|
|
993
|
+
const gap = totalCost - attributedCost;
|
|
994
|
+
if (gap > 0.01) {
|
|
995
|
+
const untagged = taskGroups.find(g => g.tags === 'Untagged');
|
|
996
|
+
if (untagged) {
|
|
997
|
+
untagged.total_cost_usd = (untagged.total_cost_usd || 0) + gap;
|
|
998
|
+
} else {
|
|
999
|
+
taskGroups.push({
|
|
1000
|
+
tags: 'Untagged',
|
|
1001
|
+
turn_count: 0,
|
|
1002
|
+
human_interventions: 0,
|
|
1003
|
+
total_duration_ms: 0,
|
|
1004
|
+
total_cost_usd: gap,
|
|
1005
|
+
last_seen: null,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
708
1009
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
).join('');
|
|
714
|
-
} else {
|
|
715
|
-
ctxEl.textContent = '';
|
|
1010
|
+
allTaskGroups = taskGroups;
|
|
1011
|
+
renderTaskTable(sortData(taskGroups, sortState.tasks));
|
|
1012
|
+
} catch (e) {
|
|
1013
|
+
console.error('tasks load failed', e);
|
|
716
1014
|
}
|
|
1015
|
+
}
|
|
717
1016
|
|
|
718
|
-
|
|
719
|
-
|
|
1017
|
+
function renderTaskTable(groups) {
|
|
1018
|
+
const tbody = document.getElementById('task-table');
|
|
1019
|
+
if (!groups.length) {
|
|
1020
|
+
tbody.innerHTML = '<tr><td colspan="5" class="empty">No tasks yet. Tag turns with <code>zozul tag</code> to see them here.</td></tr>';
|
|
720
1021
|
return;
|
|
721
1022
|
}
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
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>';
|
|
1023
|
+
tbody.innerHTML = groups.map(g => {
|
|
1024
|
+
const tags = g.tags.split('|');
|
|
1025
|
+
const pills = tags.map(t =>
|
|
1026
|
+
'<span class="tag-pill' + (t === 'Untagged' ? ' untagged' : '') + '">' + escHtml(t) + '</span>'
|
|
1027
|
+
).join('');
|
|
1028
|
+
return '<tr class="clickable" onclick="showTaskDetail(\'' + escHtml(g.tags).replace(/'/g, "\\'") + '\')">' +
|
|
1029
|
+
'<td>' + pills + '</td>' +
|
|
1030
|
+
'<td>' + fmtDuration(g.total_duration_ms) + '</td>' +
|
|
1031
|
+
'<td><span class="badge badge-cost">' + fmtCost(g.total_cost_usd) + '</span></td>' +
|
|
1032
|
+
'<td>' + fmtNum(g.human_interventions) + '</td>' +
|
|
1033
|
+
'<td title="' + fmtAbsolute(g.last_seen) + '">' + fmtRelative(g.last_seen) + '</td>' +
|
|
1034
|
+
'</tr>';
|
|
737
1035
|
}).join('');
|
|
738
1036
|
}
|
|
739
1037
|
|
|
740
|
-
function
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
el.classList.remove('selected');
|
|
747
|
-
} else {
|
|
748
|
-
selectedTags.add(tag);
|
|
749
|
-
el.classList.add('selected');
|
|
750
|
-
}
|
|
751
|
-
loadTaskData();
|
|
1038
|
+
function filterTaskTable(q) {
|
|
1039
|
+
q = q.toLowerCase();
|
|
1040
|
+
const filtered = q
|
|
1041
|
+
? allTaskGroups.filter(g => g.tags.toLowerCase().includes(q))
|
|
1042
|
+
: allTaskGroups;
|
|
1043
|
+
renderTaskTable(sortData(filtered, sortState.tasks));
|
|
752
1044
|
}
|
|
753
1045
|
|
|
754
|
-
|
|
1046
|
+
// ── Task Group Detail ──
|
|
755
1047
|
|
|
756
|
-
function
|
|
757
|
-
|
|
758
|
-
|
|
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
|
-
}
|
|
1048
|
+
async function showTaskDetail(tagSet) {
|
|
1049
|
+
previousView = 'tasks';
|
|
1050
|
+
showView('task-detail');
|
|
765
1051
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (tags.length === 0) return;
|
|
1052
|
+
const tags = tagSet.split('|');
|
|
1053
|
+
taskDetailTags = tags;
|
|
1054
|
+
taskDetailOffset = 0;
|
|
770
1055
|
|
|
771
|
-
const
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
}
|
|
777
|
-
const [stats, turns] = await Promise.all(fetches);
|
|
1056
|
+
const statsEl = document.getElementById('task-detail-stats');
|
|
1057
|
+
const pills = tags.map(t =>
|
|
1058
|
+
'<span class="tag-pill' + (t === 'Untagged' ? ' untagged' : '') + '">' + escHtml(t) + '</span>'
|
|
1059
|
+
).join(' ');
|
|
1060
|
+
statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
|
|
778
1061
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
document.getElementById('turns-section').style.display = 'none';
|
|
1062
|
+
if (tags.length === 1 && tags[0] === 'Untagged') {
|
|
1063
|
+
statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Task</div><div class="value" style="font-size:16px">' + pills + '</div></div>';
|
|
1064
|
+
document.getElementById('task-turns-table').innerHTML = '<tr><td colspan="5" class="empty">Untagged turns cannot be drilled into.</td></tr>';
|
|
1065
|
+
document.getElementById('task-turns-pagination').innerHTML = '';
|
|
1066
|
+
return;
|
|
785
1067
|
}
|
|
786
|
-
}
|
|
787
1068
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
'
|
|
802
|
-
|
|
803
|
-
|
|
1069
|
+
const qs = 'tags=' + tags.map(encodeURIComponent).join(',') + '&mode=all';
|
|
1070
|
+
|
|
1071
|
+
try {
|
|
1072
|
+
const [stats, rawTurns] = await Promise.all([
|
|
1073
|
+
fetchJson('/api/tasks/stats?' + qs),
|
|
1074
|
+
fetchJson('/api/tasks/turns?' + qs + '&limit=' + PAGE_SIZE + '&offset=0'),
|
|
1075
|
+
]);
|
|
1076
|
+
const turns = await enrichTurnsWithCost(rawTurns);
|
|
1077
|
+
|
|
1078
|
+
statsEl.innerHTML = [
|
|
1079
|
+
{ label: 'Task', value: '<span style="font-size:14px">' + pills + '</span>' },
|
|
1080
|
+
{ label: 'Total Turns', value: fmtNum(stats.total_turns) },
|
|
1081
|
+
{ label: 'Human Interventions', value: fmtNum(stats.user_turns) },
|
|
1082
|
+
{ label: 'Duration', value: fmtDuration(stats.total_duration_ms) },
|
|
1083
|
+
{ label: 'Cost', value: fmtCost(stats.total_cost_usd) },
|
|
1084
|
+
].map(c =>
|
|
1085
|
+
'<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:16px">' + c.value + '</div></div>'
|
|
1086
|
+
).join('');
|
|
1087
|
+
|
|
1088
|
+
renderTaskDetailTurns(turns);
|
|
1089
|
+
taskDetailOffset = turns.length;
|
|
1090
|
+
} catch (e) {
|
|
1091
|
+
console.error('task detail load failed', e);
|
|
1092
|
+
document.getElementById('task-detail-stats').innerHTML =
|
|
1093
|
+
'<div class="stat-card" style="grid-column:1/-1"><div class="label" style="color:var(--red)">Error: ' + escHtml(String(e)) + '</div></div>';
|
|
1094
|
+
}
|
|
804
1095
|
}
|
|
805
1096
|
|
|
806
|
-
function
|
|
807
|
-
const
|
|
808
|
-
const
|
|
809
|
-
const countEl = document.getElementById('turns-count');
|
|
810
|
-
const paginationEl = document.getElementById('turns-pagination');
|
|
1097
|
+
function renderTaskDetailTurns(turns) {
|
|
1098
|
+
const tbody = document.getElementById('task-turns-table');
|
|
1099
|
+
const paginationEl = document.getElementById('task-turns-pagination');
|
|
811
1100
|
|
|
812
|
-
if (!turns.length &&
|
|
813
|
-
|
|
1101
|
+
if (!turns.length && taskDetailOffset === 0) {
|
|
1102
|
+
tbody.innerHTML = '<tr><td colspan="5" class="empty">No turns found.</td></tr>';
|
|
1103
|
+
paginationEl.innerHTML = '';
|
|
814
1104
|
return;
|
|
815
1105
|
}
|
|
816
1106
|
|
|
817
|
-
|
|
818
|
-
const
|
|
819
|
-
const hasMore = turns.length >= TURNS_PAGE;
|
|
820
|
-
countEl.textContent = '';
|
|
1107
|
+
const page = Math.floor(taskDetailOffset / PAGE_SIZE);
|
|
1108
|
+
const hasMore = turns.length >= PAGE_SIZE;
|
|
821
1109
|
paginationEl.innerHTML =
|
|
822
|
-
(page > 0 ? '<button class="
|
|
1110
|
+
(page > 0 ? '<button class="time-btn" onclick="taskDetailPrev()">Prev</button>' : '') +
|
|
823
1111
|
'<span style="font-size:11px;color:var(--text-dim);font-family:var(--mono)">Page ' + (page + 1) + '</span>' +
|
|
824
|
-
(hasMore ? '<button class="
|
|
1112
|
+
(hasMore ? '<button class="time-btn" onclick="taskDetailNext()">Next</button>' : '');
|
|
825
1113
|
|
|
826
|
-
|
|
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(' ') : '';
|
|
1114
|
+
tbody.innerHTML = turns.map(t => {
|
|
830
1115
|
const preview = t.content_text ? escHtml(t.content_text.slice(0, 80)) + (t.content_text.length > 80 ? '\u2026' : '') : '\u2014';
|
|
831
1116
|
const tokens = fmtNum((t.block_input_tokens || 0) + (t.block_output_tokens || 0));
|
|
832
|
-
|
|
833
|
-
return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ')">' +
|
|
1117
|
+
return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ', \'task-detail\')">' +
|
|
834
1118
|
'<td title="' + fmtAbsolute(t.timestamp) + '" style="white-space:nowrap">' + fmtRelative(t.timestamp) + '</td>' +
|
|
835
1119
|
'<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
1120
|
'<td>' + fmtDuration(t.duration_ms) + '</td>' +
|
|
838
1121
|
'<td><span class="badge badge-tokens">' + tokens + '</span></td>' +
|
|
839
1122
|
'<td><span class="badge badge-cost">' + fmtCost(t.block_cost_usd) + '</span></td>' +
|
|
840
1123
|
'</tr>';
|
|
841
1124
|
}).join('');
|
|
1125
|
+
}
|
|
842
1126
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1127
|
+
async function taskDetailNext() {
|
|
1128
|
+
taskDetailOffset += PAGE_SIZE;
|
|
1129
|
+
const qs = 'tags=' + taskDetailTags.map(encodeURIComponent).join(',') + '&mode=all&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset;
|
|
1130
|
+
const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + qs));
|
|
1131
|
+
renderTaskDetailTurns(turns);
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
async function taskDetailPrev() {
|
|
1135
|
+
taskDetailOffset = Math.max(0, taskDetailOffset - PAGE_SIZE);
|
|
1136
|
+
const qs = 'tags=' + taskDetailTags.map(encodeURIComponent).join(',') + '&mode=all&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset;
|
|
1137
|
+
const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + qs));
|
|
1138
|
+
renderTaskDetailTurns(turns);
|
|
1139
|
+
}
|
|
1140
|
+
|
|
1141
|
+
// ── Tag View ──
|
|
1142
|
+
|
|
1143
|
+
async function loadTags() {
|
|
1144
|
+
try {
|
|
1145
|
+
const tasks = await fetchJson('/api/tasks');
|
|
1146
|
+
if (!tasks.length) { allTagStats = []; renderTagTable([]); return; }
|
|
1147
|
+
const statsList = await Promise.all(
|
|
1148
|
+
tasks.map(t => fetchJson('/api/tasks/stats?tags=' + encodeURIComponent(t.task) + '&mode=any')
|
|
1149
|
+
.then(s => ({ ...s, tag: t.task, last_seen: t.last_tagged }))
|
|
1150
|
+
.catch(() => ({ tag: t.task, turn_count: t.turn_count, human_interventions: 0, total_duration_ms: 0, total_cost_usd: 0, last_seen: t.last_tagged }))
|
|
1151
|
+
)
|
|
1152
|
+
);
|
|
1153
|
+
allTagStats = statsList.map(s => ({
|
|
1154
|
+
tag: s.tag,
|
|
1155
|
+
turn_count: s.total_turns ?? s.turn_count ?? 0,
|
|
1156
|
+
human_interventions: s.user_turns ?? s.human_interventions ?? 0,
|
|
1157
|
+
total_duration_ms: s.total_duration_ms ?? 0,
|
|
1158
|
+
total_cost_usd: s.total_cost_usd ?? 0,
|
|
1159
|
+
last_seen: s.last_seen,
|
|
1160
|
+
}));
|
|
1161
|
+
renderTagTable(sortData(allTagStats, sortState.tags));
|
|
1162
|
+
} catch (e) {
|
|
1163
|
+
console.error('tags load failed', e);
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function renderTagTable(stats) {
|
|
1168
|
+
const tbody = document.getElementById('tag-table');
|
|
1169
|
+
if (!stats.length) {
|
|
1170
|
+
tbody.innerHTML = '<tr><td colspan="5" class="empty">No tags yet. Tag turns with <code>zozul tag</code> to see them here.</td></tr>';
|
|
1171
|
+
return;
|
|
847
1172
|
}
|
|
1173
|
+
tbody.innerHTML = stats.map(t =>
|
|
1174
|
+
'<tr class="clickable" onclick="showTagDetail(\'' + escHtml(t.tag).replace(/'/g, "\\'") + '\')">' +
|
|
1175
|
+
'<td><span class="tag-pill">' + escHtml(t.tag) + '</span></td>' +
|
|
1176
|
+
'<td>' + fmtDuration(t.total_duration_ms) + '</td>' +
|
|
1177
|
+
'<td><span class="badge badge-cost">' + fmtCost(t.total_cost_usd) + '</span></td>' +
|
|
1178
|
+
'<td>' + fmtNum(t.human_interventions) + '</td>' +
|
|
1179
|
+
'<td title="' + fmtAbsolute(t.last_seen) + '">' + fmtRelative(t.last_seen) + '</td>' +
|
|
1180
|
+
'</tr>'
|
|
1181
|
+
).join('');
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// ── Tag Detail ──
|
|
1185
|
+
|
|
1186
|
+
async function showTagDetail(tagName) {
|
|
1187
|
+
previousView = 'tags';
|
|
1188
|
+
showView('tag-detail');
|
|
1189
|
+
tagDetailName = tagName;
|
|
1190
|
+
tagDetailOffset = 0;
|
|
1191
|
+
|
|
1192
|
+
const statsEl = document.getElementById('tag-detail-stats');
|
|
1193
|
+
statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
|
|
1194
|
+
|
|
1195
|
+
try {
|
|
1196
|
+
const tagQs = 'tags=' + encodeURIComponent(tagName) + '&mode=any';
|
|
1197
|
+
const [stats, rawTurns] = await Promise.all([
|
|
1198
|
+
fetchJson('/api/tasks/stats?' + tagQs),
|
|
1199
|
+
fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=0'),
|
|
1200
|
+
]);
|
|
1201
|
+
const turns = await enrichTurnsWithCost(rawTurns);
|
|
1202
|
+
|
|
1203
|
+
statsEl.innerHTML = [
|
|
1204
|
+
{ label: 'Tag', value: '<span class="tag-pill" style="font-size:14px;padding:4px 12px">' + escHtml(tagName) + '</span>' },
|
|
1205
|
+
{ label: 'Total Turns', value: fmtNum(stats.total_turns) },
|
|
1206
|
+
{ label: 'Human Interventions', value: fmtNum(stats.user_turns) },
|
|
1207
|
+
{ label: 'Duration', value: fmtDuration(stats.total_duration_ms) },
|
|
1208
|
+
{ label: 'Cost', value: fmtCost(stats.total_cost_usd) },
|
|
1209
|
+
].map(c =>
|
|
1210
|
+
'<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:16px">' + c.value + '</div></div>'
|
|
1211
|
+
).join('');
|
|
1212
|
+
|
|
1213
|
+
renderTagDetailTurns(turns);
|
|
1214
|
+
tagDetailOffset = turns.length;
|
|
1215
|
+
} catch (e) {
|
|
1216
|
+
console.error('tag detail load failed', e);
|
|
1217
|
+
document.getElementById('tag-detail-stats').innerHTML =
|
|
1218
|
+
'<div class="stat-card" style="grid-column:1/-1"><div class="label" style="color:var(--red)">Error: ' + escHtml(String(e)) + '</div></div>';
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function renderTagDetailTurns(turns) {
|
|
1223
|
+
const tbody = document.getElementById('tag-turns-table');
|
|
1224
|
+
const paginationEl = document.getElementById('tag-turns-pagination');
|
|
1225
|
+
|
|
1226
|
+
if (!turns.length && tagDetailOffset === 0) {
|
|
1227
|
+
tbody.innerHTML = '<tr><td colspan="4" class="empty">No turns found.</td></tr>';
|
|
1228
|
+
paginationEl.innerHTML = '';
|
|
1229
|
+
return;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const page = Math.floor(tagDetailOffset / PAGE_SIZE);
|
|
1233
|
+
const hasMore = turns.length >= PAGE_SIZE;
|
|
1234
|
+
paginationEl.innerHTML =
|
|
1235
|
+
(page > 0 ? '<button class="time-btn" onclick="tagDetailPrev()">Prev</button>' : '') +
|
|
1236
|
+
'<span style="font-size:11px;color:var(--text-dim);font-family:var(--mono)">Page ' + (page + 1) + '</span>' +
|
|
1237
|
+
(hasMore ? '<button class="time-btn" onclick="tagDetailNext()">Next</button>' : '');
|
|
1238
|
+
|
|
1239
|
+
tbody.innerHTML = turns.map(t => {
|
|
1240
|
+
const preview = t.content_text ? escHtml(t.content_text.slice(0, 100)) + (t.content_text.length > 100 ? '\u2026' : '') : '\u2014';
|
|
1241
|
+
return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ', \'tag-detail\')">' +
|
|
1242
|
+
'<td title="' + fmtAbsolute(t.timestamp) + '" style="white-space:nowrap">' + fmtRelative(t.timestamp) + '</td>' +
|
|
1243
|
+
'<td style="font-family:var(--mono);font-size:12px;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + preview + '</td>' +
|
|
1244
|
+
'<td><span class="session-id" onclick="event.stopPropagation();copyText(\'' + t.session_id + '\')" title="Click to copy">' +
|
|
1245
|
+
shortId(t.session_id) + '<span class="copy-icon">⎘</span></span></td>' +
|
|
1246
|
+
'<td><span class="badge badge-cost">' + fmtCost(t.block_cost_usd ?? t.estimated_cost_usd ?? 0) + '</span></td>' +
|
|
1247
|
+
'</tr>';
|
|
1248
|
+
}).join('');
|
|
848
1249
|
}
|
|
849
1250
|
|
|
850
|
-
async function
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
const
|
|
854
|
-
|
|
1251
|
+
async function tagDetailNext() {
|
|
1252
|
+
tagDetailOffset += PAGE_SIZE;
|
|
1253
|
+
const tagQs = 'tags=' + encodeURIComponent(tagDetailName) + '&mode=any';
|
|
1254
|
+
const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=' + tagDetailOffset));
|
|
1255
|
+
renderTagDetailTurns(turns);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
async function tagDetailPrev() {
|
|
1259
|
+
tagDetailOffset = Math.max(0, tagDetailOffset - PAGE_SIZE);
|
|
1260
|
+
const tagQs = 'tags=' + encodeURIComponent(tagDetailName) + '&mode=any';
|
|
1261
|
+
const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=' + tagDetailOffset));
|
|
1262
|
+
renderTagDetailTurns(turns);
|
|
1263
|
+
}
|
|
1264
|
+
|
|
1265
|
+
// ── Turn Block Detail ──
|
|
1266
|
+
|
|
1267
|
+
let turnDetailReturnView = 'tasks';
|
|
1268
|
+
|
|
1269
|
+
async function showTurnBlock(turnId, returnTo) {
|
|
1270
|
+
turnDetailReturnView = returnTo || 'tasks';
|
|
1271
|
+
showView('turn-detail');
|
|
855
1272
|
|
|
856
1273
|
const statsEl = document.getElementById('turn-detail-stats');
|
|
857
1274
|
const content = document.getElementById('turn-detail-content');
|
|
858
|
-
statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading
|
|
1275
|
+
statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
|
|
859
1276
|
content.innerHTML = '';
|
|
860
1277
|
|
|
861
|
-
const block = await fetchJson('/api/turns/' + turnId + '/block');
|
|
1278
|
+
const block = await enrichTurnsWithCost(await fetchJson('/api/turns/' + turnId + '/block'));
|
|
862
1279
|
if (!block.length) { content.innerHTML = '<div class="empty">No data</div>'; return; }
|
|
863
1280
|
|
|
864
|
-
// Aggregate block stats
|
|
865
1281
|
const totalIn = block.reduce((s, t) => s + (t.input_tokens || 0), 0);
|
|
866
1282
|
const totalOut = block.reduce((s, t) => s + (t.output_tokens || 0), 0);
|
|
867
|
-
const totalCost = block.reduce((s, t) => s + (t.
|
|
1283
|
+
const totalCost = block.reduce((s, t) => s + (t.estimated_cost_usd || 0), 0);
|
|
868
1284
|
const duration = block[0].duration_ms || 0;
|
|
869
1285
|
const prompt = block[0].content_text ? block[0].content_text.slice(0, 100) : '\u2014';
|
|
870
1286
|
|
|
@@ -879,367 +1295,228 @@ async function showTurnBlock(turnId) {
|
|
|
879
1295
|
'<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:14px">' + c.value + '</div></div>'
|
|
880
1296
|
).join('');
|
|
881
1297
|
|
|
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">►</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">⎘</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');
|
|
1298
|
+
content.innerHTML = block.map((t, i) => renderTurnHtml(t, 'tb-' + turnId + '-' + i)).join('');
|
|
984
1299
|
}
|
|
985
1300
|
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
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;
|
|
1301
|
+
function goBackFromTurn() {
|
|
1302
|
+
if (currentView === 'turn-detail') {
|
|
1303
|
+
showView(turnDetailReturnView);
|
|
1304
|
+
} else if (currentView === 'session-detail') {
|
|
1305
|
+
showView(previousView || 'tasks');
|
|
997
1306
|
}
|
|
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
1307
|
}
|
|
1004
1308
|
|
|
1005
|
-
|
|
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
|
-
}
|
|
1309
|
+
// ── Session Detail ──
|
|
1055
1310
|
|
|
1056
|
-
|
|
1311
|
+
async function showSession(id, returnTo) {
|
|
1312
|
+
previousView = returnTo || 'tasks';
|
|
1313
|
+
showView('session-detail');
|
|
1057
1314
|
|
|
1058
|
-
|
|
1059
|
-
document.getElementById('
|
|
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 = '';
|
|
1315
|
+
document.getElementById('session-detail-stats').innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
|
|
1316
|
+
document.getElementById('session-detail-turns').innerHTML = '';
|
|
1064
1317
|
|
|
1065
1318
|
const [session, turns] = await Promise.all([
|
|
1066
1319
|
fetchJson('/api/sessions/' + id),
|
|
1067
1320
|
fetchJson('/api/sessions/' + id + '/turns'),
|
|
1068
1321
|
]);
|
|
1069
1322
|
|
|
1070
|
-
document.getElementById('detail-stats').innerHTML = [
|
|
1071
|
-
{ label: 'Session ID',
|
|
1072
|
-
{ label: 'Project',
|
|
1073
|
-
{ label: 'Model',
|
|
1074
|
-
{ label: '
|
|
1075
|
-
{ label: '
|
|
1076
|
-
{ label: '
|
|
1077
|
-
{ label: '
|
|
1078
|
-
{ label: '
|
|
1079
|
-
{ label: 'Cost', value: fmtCost(session.total_cost_usd) },
|
|
1323
|
+
document.getElementById('session-detail-stats').innerHTML = [
|
|
1324
|
+
{ label: 'Session ID', value: '<span class="session-id" onclick="copyText(\'' + session.id + '\')" title="Click to copy">' + shortId(session.id) + ' <span class="copy-icon">⎘</span></span>' },
|
|
1325
|
+
{ label: 'Project', value: '<span title="' + escHtml(session.project_path ?? '') + '">' + escHtml(basename(session.project_path)) + '</span>' },
|
|
1326
|
+
{ label: 'Model', value: '<span style="font-family:var(--mono);font-size:13px">' + escHtml(session.model ?? '\u2014') + '</span>' },
|
|
1327
|
+
{ label: 'Duration', value: fmtDuration(session.total_duration_ms) },
|
|
1328
|
+
{ label: 'Turns', value: session.total_turns },
|
|
1329
|
+
{ label: 'Input Tokens', value: fmtNum(session.total_input_tokens) },
|
|
1330
|
+
{ label: 'Output Tokens', value: fmtNum(session.total_output_tokens) },
|
|
1331
|
+
{ label: 'Cost', value: fmtCost(session.total_cost_usd) },
|
|
1080
1332
|
].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>'
|
|
1333
|
+
'<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:14px">' + c.value + '</div></div>'
|
|
1085
1334
|
).join('');
|
|
1086
1335
|
|
|
1087
|
-
const turnsDiv = document.getElementById('detail-turns');
|
|
1336
|
+
const turnsDiv = document.getElementById('session-detail-turns');
|
|
1088
1337
|
if (!turns.length) {
|
|
1089
|
-
turnsDiv.innerHTML = '<div class="empty">No turns recorded
|
|
1338
|
+
turnsDiv.innerHTML = '<div class="empty">No turns recorded.</div>';
|
|
1090
1339
|
return;
|
|
1091
1340
|
}
|
|
1341
|
+
turnsDiv.innerHTML = turns.map((t, i) => renderTurnHtml(t, 'tc-' + i)).join('');
|
|
1342
|
+
}
|
|
1092
1343
|
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
'
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
'</div>'
|
|
1128
|
-
|
|
1129
|
-
}
|
|
1130
|
-
}
|
|
1344
|
+
// ── Shared turn rendering ──
|
|
1345
|
+
|
|
1346
|
+
function renderTurnHtml(t, idPrefix) {
|
|
1347
|
+
const roleClass = t.role === 'assistant' ? 'assistant' : 'user';
|
|
1348
|
+
const roleLabel = t.role === 'assistant' ? 'Assistant' : 'User';
|
|
1349
|
+
const meta = [];
|
|
1350
|
+
if (t.input_tokens) meta.push(fmtNum(t.input_tokens) + ' in');
|
|
1351
|
+
if (t.output_tokens) meta.push(fmtNum(t.output_tokens) + ' out');
|
|
1352
|
+
if (t.estimated_cost_usd || t.cost_usd) meta.push(fmtCost(t.estimated_cost_usd || t.cost_usd));
|
|
1353
|
+
if (t.duration_ms) meta.push(fmtDuration(t.duration_ms));
|
|
1354
|
+
|
|
1355
|
+
const text = t.content_text
|
|
1356
|
+
? '<div class="turn-content">' + escHtml(t.content_text) + '</div>'
|
|
1357
|
+
: '';
|
|
1358
|
+
|
|
1359
|
+
let toolsHtml = '';
|
|
1360
|
+
if (t.tool_calls) {
|
|
1361
|
+
try {
|
|
1362
|
+
const calls = JSON.parse(t.tool_calls);
|
|
1363
|
+
toolsHtml = '<div class="tool-calls">' + calls.map((c, ci) => {
|
|
1364
|
+
const uid = idPrefix + '-' + ci;
|
|
1365
|
+
const inputJson = JSON.stringify(c.toolInput, null, 2);
|
|
1366
|
+
const resultHtml = c.toolResult
|
|
1367
|
+
? '<div class="tool-section-label">Result</div><div class="tool-json">' + escHtml(c.toolResult) + '</div>'
|
|
1368
|
+
: '';
|
|
1369
|
+
return '<div class="tool-call" id="' + uid + '">' +
|
|
1370
|
+
'<div class="tool-call-header" onclick="toggleTool(\'' + uid + '\')">' +
|
|
1371
|
+
'<span class="tool-name">' + escHtml(c.toolName) + '</span>' +
|
|
1372
|
+
'<span class="tool-chevron">►</span>' +
|
|
1373
|
+
'</div>' +
|
|
1374
|
+
'<div class="tool-body">' +
|
|
1375
|
+
'<div class="tool-section-label">Input</div>' +
|
|
1376
|
+
'<div class="tool-json">' + escHtml(inputJson) + '</div>' +
|
|
1377
|
+
resultHtml +
|
|
1378
|
+
'</div>' +
|
|
1379
|
+
'</div>';
|
|
1380
|
+
}).join('') + '</div>';
|
|
1381
|
+
} catch {}
|
|
1382
|
+
}
|
|
1131
1383
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}).join('');
|
|
1384
|
+
return '<div class="turn">' +
|
|
1385
|
+
'<div class="turn-header">' +
|
|
1386
|
+
'<span class="turn-role ' + roleClass + '">' + roleLabel + '</span>' +
|
|
1387
|
+
(meta.length ? '<span class="turn-meta">' + meta.join(' \u00b7 ') + '</span>' : '') +
|
|
1388
|
+
'<span class="turn-ts" title="' + fmtAbsolute(t.timestamp) + '">' + fmtRelative(t.timestamp) + '</span>' +
|
|
1389
|
+
'</div>' +
|
|
1390
|
+
text +
|
|
1391
|
+
toolsHtml +
|
|
1392
|
+
'</div>';
|
|
1142
1393
|
}
|
|
1143
1394
|
|
|
1144
1395
|
function toggleTool(uid) {
|
|
1145
1396
|
document.getElementById(uid).classList.toggle('open');
|
|
1146
1397
|
}
|
|
1147
1398
|
|
|
1148
|
-
|
|
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
|
-
}
|
|
1399
|
+
// ── Sessions View ──
|
|
1153
1400
|
|
|
1154
|
-
|
|
1401
|
+
async function loadSessions() {
|
|
1402
|
+
try {
|
|
1403
|
+
const resp = await fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=0');
|
|
1404
|
+
const sessions = Array.isArray(resp) ? resp : resp.sessions;
|
|
1405
|
+
allSessions = sessions;
|
|
1406
|
+
sessionsTotal = Array.isArray(resp) ? sessions.length : resp.total;
|
|
1407
|
+
sessionsOffset = sessions.length;
|
|
1408
|
+
renderSessionsTable(sortData(sessions, sortState.sessions));
|
|
1409
|
+
updateLoadMore();
|
|
1410
|
+
} catch (e) {
|
|
1411
|
+
console.error('sessions load failed', e);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1155
1414
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1415
|
+
async function loadMoreSessions() {
|
|
1416
|
+
try {
|
|
1417
|
+
const resp = await fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=' + sessionsOffset);
|
|
1418
|
+
const page = Array.isArray(resp) ? resp : resp.sessions;
|
|
1419
|
+
allSessions = allSessions.concat(page);
|
|
1420
|
+
sessionsTotal = Array.isArray(resp) ? allSessions.length : resp.total;
|
|
1421
|
+
sessionsOffset = allSessions.length;
|
|
1422
|
+
const q = document.getElementById('session-filter').value;
|
|
1423
|
+
const display = q ? allSessions.filter(s => sessionMatches(s, q)) : allSessions;
|
|
1424
|
+
renderSessionsTable(sortData(display, sortState.sessions));
|
|
1425
|
+
updateLoadMore();
|
|
1426
|
+
} catch (e) {
|
|
1427
|
+
console.error('load more failed', e);
|
|
1162
1428
|
}
|
|
1163
|
-
}
|
|
1429
|
+
}
|
|
1164
1430
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
loadCharts();
|
|
1431
|
+
function updateLoadMore() {
|
|
1432
|
+
const row = document.getElementById('load-more-row');
|
|
1433
|
+
const shown = document.getElementById('sessions-shown');
|
|
1434
|
+
const count = document.getElementById('sessions-count');
|
|
1435
|
+
count.textContent = '(' + sessionsTotal + ')';
|
|
1436
|
+
if (sessionsTotal > sessionsOffset) {
|
|
1437
|
+
row.style.display = '';
|
|
1438
|
+
shown.textContent = sessionsOffset + ' of ' + sessionsTotal + ' shown';
|
|
1439
|
+
} else {
|
|
1440
|
+
row.style.display = 'none';
|
|
1441
|
+
}
|
|
1177
1442
|
}
|
|
1178
1443
|
|
|
1179
|
-
function
|
|
1180
|
-
|
|
1444
|
+
function sessionMatches(s, q) {
|
|
1445
|
+
q = q.toLowerCase();
|
|
1446
|
+
return (s.id && s.id.toLowerCase().includes(q)) ||
|
|
1447
|
+
(s.project_path && s.project_path.toLowerCase().includes(q)) ||
|
|
1448
|
+
(s.model && s.model.toLowerCase().includes(q));
|
|
1181
1449
|
}
|
|
1182
1450
|
|
|
1183
|
-
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
}
|
|
1451
|
+
function filterSessions(q) {
|
|
1452
|
+
const filtered = q ? allSessions.filter(s => sessionMatches(s, q)) : allSessions;
|
|
1453
|
+
renderSessionsTable(sortData(filtered, sortState.sessions));
|
|
1454
|
+
document.getElementById('load-more-row').style.display = q ? 'none' : (sessionsTotal > sessionsOffset ? '' : 'none');
|
|
1455
|
+
}
|
|
1188
1456
|
|
|
1189
|
-
|
|
1457
|
+
function renderSessionsTable(sessions) {
|
|
1458
|
+
const tbody = document.getElementById('sessions-table');
|
|
1459
|
+
if (!sessions.length) {
|
|
1460
|
+
tbody.innerHTML = '<tr><td colspan="7" class="empty">No sessions yet.</td></tr>';
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
tbody.innerHTML = sessions.map(s => {
|
|
1464
|
+
const proj = basename(s.project_path);
|
|
1465
|
+
const projAttr = s.project_path ? ' title="' + escHtml(s.project_path) + '"' : '';
|
|
1466
|
+
return '<tr class="clickable" onclick="showSession(\'' + s.id + '\', \'sessions\')">' +
|
|
1467
|
+
'<td><span class="session-id" onclick="event.stopPropagation();copyText(\'' + s.id + '\')" title="Click to copy full ID">' +
|
|
1468
|
+
shortId(s.id) + '<span class="copy-icon">⎘</span></span></td>' +
|
|
1469
|
+
'<td' + projAttr + '>' + escHtml(proj) + '</td>' +
|
|
1470
|
+
'<td title="' + fmtAbsolute(s.started_at) + '">' + fmtRelative(s.started_at) + '</td>' +
|
|
1471
|
+
'<td>' + fmtDuration(s.total_duration_ms) + '</td>' +
|
|
1472
|
+
'<td>' + (s.total_turns ?? 0) + '</td>' +
|
|
1473
|
+
'<td><span class="badge badge-cost">' + fmtCost(s.total_cost_usd) + '</span></td>' +
|
|
1474
|
+
'<td style="font-family:var(--mono);font-size:11px;color:var(--text-dim)">' + escHtml(s.model ?? '\u2014') + '</td>' +
|
|
1475
|
+
'</tr>';
|
|
1476
|
+
}).join('');
|
|
1477
|
+
}
|
|
1190
1478
|
|
|
1191
|
-
|
|
1192
|
-
const customToggle = document.getElementById('custom-range-toggle');
|
|
1479
|
+
// ── Loading orchestration ──
|
|
1193
1480
|
|
|
1194
|
-
function
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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);
|
|
1481
|
+
async function loadViewData(view) {
|
|
1482
|
+
if (view === 'summary') await loadSummary();
|
|
1483
|
+
else if (view === 'sessions') await loadSessions();
|
|
1484
|
+
else if (view === 'tasks') await loadTasks();
|
|
1485
|
+
else if (view === 'tags') await loadTags();
|
|
1203
1486
|
}
|
|
1204
1487
|
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
}
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
document.querySelectorAll('.range-btn').forEach(b => b.classList.remove('active'));
|
|
1217
|
-
customToggle.classList.add('active');
|
|
1488
|
+
async function loadDashboard() {
|
|
1489
|
+
const btn = document.querySelector('.auto-btn');
|
|
1490
|
+
btn?.classList.add('loading');
|
|
1491
|
+
const minSpinner = new Promise(r => setTimeout(r, 400));
|
|
1492
|
+
try {
|
|
1493
|
+
await loadViewData(currentView);
|
|
1494
|
+
} catch (e) {
|
|
1495
|
+
console.error('load failed', e);
|
|
1496
|
+
} finally {
|
|
1497
|
+
await minSpinner;
|
|
1498
|
+
btn?.classList.remove('loading');
|
|
1218
1499
|
}
|
|
1219
|
-
}
|
|
1500
|
+
}
|
|
1220
1501
|
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
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
|
-
});
|
|
1502
|
+
function manualRefresh() {
|
|
1503
|
+
if (autoRefreshTimer) { clearTimeout(autoRefreshTimer); autoRefreshTimer = null; }
|
|
1504
|
+
loadDashboard().then(scheduleAutoRefresh);
|
|
1505
|
+
}
|
|
1232
1506
|
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1507
|
+
function scheduleAutoRefresh() {
|
|
1508
|
+
autoRefreshTimer = setTimeout(async () => {
|
|
1509
|
+
// Only auto-refresh main views, not detail views
|
|
1510
|
+
if (['summary', 'sessions', 'tasks', 'tags'].includes(currentView)) {
|
|
1511
|
+
await loadDashboard();
|
|
1512
|
+
}
|
|
1513
|
+
scheduleAutoRefresh();
|
|
1514
|
+
}, AUTO_REFRESH_INTERVAL);
|
|
1240
1515
|
}
|
|
1241
1516
|
|
|
1242
|
-
|
|
1517
|
+
// ── Init ──
|
|
1518
|
+
|
|
1519
|
+
detectDataSource().then(() => loadDashboard()).then(scheduleAutoRefresh);
|
|
1243
1520
|
</script>
|
|
1244
1521
|
</body>
|
|
1245
1522
|
</html>
|