zozul-cli 0.1.0 → 0.2.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.
- package/.github/workflows/publish.yml +1 -1
- package/DEVELOPMENT.md +123 -12
- package/README.md +18 -14
- 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 +1015 -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 +1015 -739
- package/src/hooks/server.ts +24 -3
- package/src/storage/repo.ts +51 -31
- package/src/sync/index.ts +24 -0
|
@@ -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,131 @@
|
|
|
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 previousView = 'tasks';
|
|
594
|
+
let allTaskGroups = [];
|
|
595
|
+
let allTagStats = [];
|
|
596
|
+
let sortState = {
|
|
597
|
+
tasks: { key: 'total_cost_usd', dir: 'desc' },
|
|
598
|
+
tags: { key: 'total_cost_usd', dir: 'desc' },
|
|
599
|
+
sessions: { key: 'started_at', dir: 'desc' },
|
|
600
|
+
};
|
|
511
601
|
let allSessions = [];
|
|
512
602
|
let sessionsTotal = 0;
|
|
513
603
|
let sessionsOffset = 0;
|
|
514
604
|
const SESSIONS_PAGE = 50;
|
|
515
|
-
let
|
|
516
|
-
let
|
|
517
|
-
let
|
|
518
|
-
let
|
|
519
|
-
let
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
605
|
+
let chartInstances = {};
|
|
606
|
+
let tagDetailName = '';
|
|
607
|
+
let tagDetailOffset = 0;
|
|
608
|
+
let taskDetailTags = [];
|
|
609
|
+
let taskDetailOffset = 0;
|
|
610
|
+
const PAGE_SIZE = 20;
|
|
611
|
+
const AUTO_REFRESH_INTERVAL = 10_000;
|
|
612
|
+
|
|
613
|
+
// ── Data source auto-detection ──
|
|
614
|
+
|
|
615
|
+
async function detectDataSource() {
|
|
616
|
+
const badge = document.getElementById('source-badge');
|
|
617
|
+
if (typeof ZOZUL_CONFIG === 'undefined') {
|
|
618
|
+
dataSource = 'local';
|
|
619
|
+
badge.textContent = 'Local';
|
|
620
|
+
badge.className = 'source-badge local';
|
|
621
|
+
badge.style.display = '';
|
|
622
|
+
return;
|
|
525
623
|
}
|
|
526
|
-
return 'range=' + currentRange;
|
|
527
|
-
}
|
|
528
624
|
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
const
|
|
532
|
-
const
|
|
533
|
-
const
|
|
534
|
-
|
|
625
|
+
try {
|
|
626
|
+
const controller = new AbortController();
|
|
627
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
628
|
+
const healthUrl = ZOZUL_CONFIG.remote.baseUrl + '/health';
|
|
629
|
+
const res = await fetch(healthUrl, {
|
|
630
|
+
signal: controller.signal,
|
|
631
|
+
headers: { 'X-API-Key': ZOZUL_CONFIG.remote.apiKey },
|
|
632
|
+
});
|
|
633
|
+
clearTimeout(timeout);
|
|
634
|
+
if (res.ok) {
|
|
635
|
+
dataSource = 'remote';
|
|
636
|
+
badge.textContent = 'Remote';
|
|
637
|
+
badge.className = 'source-badge remote';
|
|
638
|
+
} else {
|
|
639
|
+
dataSource = 'local';
|
|
640
|
+
badge.textContent = 'Local';
|
|
641
|
+
badge.className = 'source-badge local';
|
|
642
|
+
}
|
|
643
|
+
} catch {
|
|
644
|
+
dataSource = 'local';
|
|
645
|
+
badge.textContent = 'Local';
|
|
646
|
+
badge.className = 'source-badge local';
|
|
535
647
|
}
|
|
536
|
-
|
|
648
|
+
badge.style.display = '';
|
|
537
649
|
}
|
|
538
650
|
|
|
539
651
|
async function fetchJson(path) {
|
|
652
|
+
if (dataSource === 'remote' && typeof ZOZUL_CONFIG !== 'undefined') {
|
|
653
|
+
try {
|
|
654
|
+
const url = ZOZUL_CONFIG.remote.baseUrl + path.replace('/api/', '/');
|
|
655
|
+
const res = await fetch(url, { headers: { 'X-API-Key': ZOZUL_CONFIG.remote.apiKey } });
|
|
656
|
+
if (res.ok) return res.json();
|
|
657
|
+
} catch {}
|
|
658
|
+
// Remote failed — fall back to local for this request
|
|
659
|
+
}
|
|
540
660
|
const res = await fetch(path);
|
|
541
661
|
if (!res.ok) throw new Error(res.status + ' ' + path);
|
|
542
662
|
return res.json();
|
|
543
663
|
}
|
|
544
664
|
|
|
665
|
+
// ── Client-side proportional cost ──
|
|
666
|
+
|
|
667
|
+
const sessionCache = {};
|
|
668
|
+
|
|
669
|
+
async function fetchSessionCached(sessionId) {
|
|
670
|
+
if (sessionCache[sessionId]) return sessionCache[sessionId];
|
|
671
|
+
const session = await fetchJson('/api/sessions/' + sessionId);
|
|
672
|
+
sessionCache[sessionId] = session;
|
|
673
|
+
return session;
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
function proportionalCost(session, turn) {
|
|
677
|
+
const hasCache = turn.cache_read !== undefined && turn.cache_read !== null;
|
|
678
|
+
const sessionTotal = (session.total_input_tokens || 0) + (session.total_output_tokens || 0)
|
|
679
|
+
+ (hasCache ? (session.total_cache_read_tokens || 0) + (session.total_cache_creation_tokens || 0) : 0);
|
|
680
|
+
if (sessionTotal === 0 || !session.total_cost_usd) return 0;
|
|
681
|
+
const turnTotal = (turn.input || 0) + (turn.output || 0)
|
|
682
|
+
+ (hasCache ? (turn.cache_read || 0) + (turn.cache_creation || 0) : 0);
|
|
683
|
+
return session.total_cost_usd * turnTotal / sessionTotal;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
async function enrichTurnsWithCost(turns) {
|
|
687
|
+
const ids = [...new Set(turns.map(t => t.session_id).filter(Boolean))];
|
|
688
|
+
await Promise.all(ids.map(fetchSessionCached));
|
|
689
|
+
for (const t of turns) {
|
|
690
|
+
const s = sessionCache[t.session_id];
|
|
691
|
+
if (!s) continue;
|
|
692
|
+
t.estimated_cost_usd = proportionalCost(s, {
|
|
693
|
+
input: t.input_tokens, output: t.output_tokens,
|
|
694
|
+
cache_read: t.cache_read_tokens, cache_creation: t.cache_creation_tokens,
|
|
695
|
+
});
|
|
696
|
+
if (t.block_input_tokens !== undefined) {
|
|
697
|
+
t.block_cost_usd = proportionalCost(s, {
|
|
698
|
+
input: t.block_input_tokens, output: t.block_output_tokens,
|
|
699
|
+
cache_read: t.block_cache_read_tokens, cache_creation: t.block_cache_creation_tokens,
|
|
700
|
+
});
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
return turns;
|
|
704
|
+
}
|
|
705
|
+
|
|
545
706
|
// ── Formatting ──
|
|
546
707
|
|
|
547
708
|
function fmtNum(n) { return (n ?? 0).toLocaleString(); }
|
|
548
|
-
function fmtCost(n) {
|
|
709
|
+
function fmtCost(n) {
|
|
710
|
+
const v = n ?? 0;
|
|
711
|
+
return v >= 1 ? '$' + v.toFixed(2) : '$' + v.toFixed(4);
|
|
712
|
+
}
|
|
549
713
|
function fmtDuration(ms) {
|
|
550
714
|
if (!ms || ms <= 0) return '\u2014';
|
|
551
715
|
const s = Math.floor(ms / 1000);
|
|
@@ -570,6 +734,18 @@ function fmtAbsolute(s) {
|
|
|
570
734
|
if (!s) return '';
|
|
571
735
|
try { return new Date(s).toLocaleString(); } catch { return s; }
|
|
572
736
|
}
|
|
737
|
+
// SQLite datetime() returns "YYYY-MM-DD HH:MM:SS" with no timezone — append Z to parse as UTC
|
|
738
|
+
function sqliteUtcToLocal(s) {
|
|
739
|
+
return new Date(s.replace(' ', 'T') + 'Z');
|
|
740
|
+
}
|
|
741
|
+
function fmtChartTs(s, span) {
|
|
742
|
+
const d = sqliteUtcToLocal(s);
|
|
743
|
+
if (span && span <= 24 * 3600000) {
|
|
744
|
+
return d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
745
|
+
}
|
|
746
|
+
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' }) + ' ' +
|
|
747
|
+
d.toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
|
|
748
|
+
}
|
|
573
749
|
function shortId(s) { return s ? s.slice(0, 8) : '\u2014'; }
|
|
574
750
|
function basename(p) {
|
|
575
751
|
if (!p) return '\u2014';
|
|
@@ -581,8 +757,6 @@ function escHtml(s) {
|
|
|
581
757
|
return d.innerHTML;
|
|
582
758
|
}
|
|
583
759
|
|
|
584
|
-
// ── Copy to clipboard ──
|
|
585
|
-
|
|
586
760
|
function copyText(text) {
|
|
587
761
|
navigator.clipboard.writeText(text).then(() => {
|
|
588
762
|
const toast = document.getElementById('copied-toast');
|
|
@@ -591,280 +765,521 @@ function copyText(text) {
|
|
|
591
765
|
});
|
|
592
766
|
}
|
|
593
767
|
|
|
594
|
-
// ──
|
|
768
|
+
// ── Navigation ──
|
|
595
769
|
|
|
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');
|
|
770
|
+
function showView(name) {
|
|
771
|
+
currentView = name;
|
|
772
|
+
document.querySelectorAll('.view').forEach(v => v.classList.remove('active'));
|
|
773
|
+
const el = document.getElementById('view-' + name);
|
|
774
|
+
if (el) el.classList.add('active');
|
|
775
|
+
|
|
776
|
+
// Update tab highlight for main views
|
|
777
|
+
document.querySelectorAll('.nav-tab').forEach(t => t.classList.remove('active'));
|
|
778
|
+
const mainView = ['summary', 'sessions', 'tasks', 'tags'].includes(name) ? name : null;
|
|
779
|
+
if (mainView) {
|
|
780
|
+
const tab = document.querySelector('.nav-tab[data-view="' + mainView + '"]');
|
|
781
|
+
if (tab) tab.classList.add('active');
|
|
625
782
|
}
|
|
626
783
|
}
|
|
627
784
|
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
785
|
+
document.querySelector('.nav-tabs').addEventListener('click', e => {
|
|
786
|
+
const tab = e.target.closest('.nav-tab');
|
|
787
|
+
if (!tab) return;
|
|
788
|
+
const view = tab.dataset.view;
|
|
789
|
+
showView(view);
|
|
790
|
+
loadViewData(view);
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
document.addEventListener('keydown', e => {
|
|
794
|
+
if (e.key === 'Escape') {
|
|
795
|
+
if (['tag-detail', 'task-detail'].includes(currentView)) {
|
|
796
|
+
showView(previousView || 'tasks');
|
|
797
|
+
} else if (['session-detail', 'turn-detail'].includes(currentView)) {
|
|
798
|
+
goBackFromTurn();
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
|
|
803
|
+
document.addEventListener('click', e => {
|
|
804
|
+
const th = e.target.closest('th.sortable');
|
|
805
|
+
if (th) {
|
|
806
|
+
const [table, key] = th.dataset.sort.split(':');
|
|
807
|
+
const state = sortState[table];
|
|
808
|
+
if (state.key === key) {
|
|
809
|
+
state.dir = state.dir === 'asc' ? 'desc' : 'asc';
|
|
810
|
+
} else {
|
|
811
|
+
state.key = key;
|
|
812
|
+
state.dir = key === 'tags' || key === 'tag' ? 'asc' : 'desc';
|
|
813
|
+
}
|
|
814
|
+
// Update header indicators
|
|
815
|
+
th.closest('thead').querySelectorAll('th.sortable').forEach(h => {
|
|
816
|
+
h.classList.remove('asc', 'desc');
|
|
817
|
+
const [t, k] = h.dataset.sort.split(':');
|
|
818
|
+
if (k === state.key) h.classList.add(state.dir);
|
|
819
|
+
});
|
|
820
|
+
if (table === 'tasks') renderTaskTable(sortData(allTaskGroups, state));
|
|
821
|
+
else if (table === 'tags') renderTagTable(sortData(allTagStats, state));
|
|
822
|
+
else if (table === 'sessions') renderSessionsTable(sortData(allSessions, state));
|
|
823
|
+
return;
|
|
824
|
+
}
|
|
825
|
+
});
|
|
826
|
+
|
|
827
|
+
function sortData(arr, state) {
|
|
828
|
+
const { key, dir } = state;
|
|
829
|
+
return [...arr].sort((a, b) => {
|
|
830
|
+
let va = a[key], vb = b[key];
|
|
831
|
+
if (typeof va === 'string' && typeof vb === 'string') {
|
|
832
|
+
va = va.toLowerCase(); vb = vb.toLowerCase();
|
|
833
|
+
return dir === 'asc' ? va.localeCompare(vb) : vb.localeCompare(va);
|
|
834
|
+
}
|
|
835
|
+
va = va ?? 0; vb = vb ?? 0;
|
|
836
|
+
return dir === 'asc' ? va - vb : vb - va;
|
|
837
|
+
});
|
|
631
838
|
}
|
|
632
839
|
|
|
633
|
-
|
|
840
|
+
// ── Summary View ──
|
|
841
|
+
|
|
842
|
+
async function loadSummary() {
|
|
634
843
|
try {
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
844
|
+
const [stats, tasks, costSeries, sessionsResp] = await Promise.all([
|
|
845
|
+
fetchJson('/api/stats'),
|
|
846
|
+
fetchJson('/api/tasks'),
|
|
847
|
+
fetchJson('/api/metrics/cost?range=30d'),
|
|
848
|
+
fetchJson('/api/sessions?limit=500&offset=0'),
|
|
849
|
+
]);
|
|
850
|
+
renderSummaryStats({
|
|
851
|
+
totalCost: stats.total_cost_usd ?? 0,
|
|
852
|
+
totalSessions: stats.total_sessions ?? 0,
|
|
853
|
+
totalTasks: tasks.length ?? 0,
|
|
854
|
+
});
|
|
855
|
+
renderDailyCostChart(costSeries);
|
|
856
|
+
const sessions = Array.isArray(sessionsResp) ? sessionsResp : sessionsResp.sessions;
|
|
857
|
+
renderProjectBreakdown(sessions);
|
|
643
858
|
} catch (e) {
|
|
644
|
-
console.error('load
|
|
859
|
+
console.error('summary load failed', e);
|
|
645
860
|
}
|
|
646
861
|
}
|
|
647
862
|
|
|
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
|
-
}
|
|
863
|
+
function renderDailyCostChart(costData) {
|
|
864
|
+
if (!costData || !costData.length) return;
|
|
865
|
+
const labels = costData.map(d => {
|
|
866
|
+
const dt = new Date(d.timestamp);
|
|
867
|
+
return dt.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
|
|
868
|
+
});
|
|
869
|
+
const data = costData.map(d => d.cost);
|
|
660
870
|
|
|
661
|
-
const
|
|
871
|
+
const existing = chartInstances['chart-daily-cost'];
|
|
872
|
+
if (existing) {
|
|
873
|
+
existing.data.labels = labels;
|
|
874
|
+
existing.data.datasets[0].data = data;
|
|
875
|
+
existing.update('none');
|
|
876
|
+
return;
|
|
877
|
+
}
|
|
662
878
|
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
879
|
+
const ctx = document.getElementById('chart-daily-cost');
|
|
880
|
+
if (!ctx) return;
|
|
881
|
+
const chart = new Chart(ctx, {
|
|
882
|
+
type: 'bar',
|
|
883
|
+
data: {
|
|
884
|
+
labels,
|
|
885
|
+
datasets: [{
|
|
886
|
+
label: 'Cost (USD)',
|
|
887
|
+
data,
|
|
888
|
+
backgroundColor: 'rgba(255,152,0,0.3)',
|
|
889
|
+
borderColor: '#ff9800',
|
|
890
|
+
borderWidth: 1,
|
|
891
|
+
borderRadius: 3,
|
|
892
|
+
}]
|
|
893
|
+
},
|
|
894
|
+
options: {
|
|
895
|
+
responsive: true,
|
|
896
|
+
plugins: {
|
|
897
|
+
legend: { display: false },
|
|
898
|
+
tooltip: { callbacks: { label: ctx => '$' + ctx.parsed.y.toFixed(4) } },
|
|
899
|
+
},
|
|
900
|
+
scales: {
|
|
901
|
+
x: { ticks: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 } } },
|
|
902
|
+
y: { ticks: { color: '#8b8fa3', font: { family: '"JetBrains Mono", monospace', size: 10 }, callback: v => '$' + v.toFixed(2) } },
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
});
|
|
906
|
+
chartInstances['chart-daily-cost'] = chart;
|
|
668
907
|
}
|
|
669
908
|
|
|
670
|
-
|
|
909
|
+
function renderProjectBreakdown(sessions) {
|
|
910
|
+
const el = document.getElementById('project-breakdown');
|
|
911
|
+
if (!sessions || !sessions.length) {
|
|
912
|
+
el.innerHTML = '<li class="empty">No sessions.</li>';
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
// Group by project
|
|
916
|
+
const byProject = {};
|
|
917
|
+
for (const s of sessions) {
|
|
918
|
+
const proj = s.project_path || 'Unknown';
|
|
919
|
+
if (!byProject[proj]) byProject[proj] = 0;
|
|
920
|
+
byProject[proj] += s.total_cost_usd || 0;
|
|
921
|
+
}
|
|
922
|
+
const sorted = Object.entries(byProject).sort((a, b) => b[1] - a[1]);
|
|
923
|
+
const maxCost = sorted[0]?.[1] || 1;
|
|
924
|
+
|
|
925
|
+
el.innerHTML = sorted.slice(0, 10).map(([proj, cost]) => {
|
|
926
|
+
const pct = Math.round(cost / maxCost * 100);
|
|
927
|
+
return '<li class="project-item">' +
|
|
928
|
+
'<span class="project-name" title="' + escHtml(proj) + '">' + escHtml(basename(proj)) + '</span>' +
|
|
929
|
+
'<div class="project-bar-wrap"><div class="project-bar" style="width:' + pct + '%"></div></div>' +
|
|
930
|
+
'<span class="project-cost">' + fmtCost(cost) + '</span>' +
|
|
931
|
+
'</li>';
|
|
932
|
+
}).join('');
|
|
933
|
+
}
|
|
671
934
|
|
|
672
|
-
function
|
|
673
|
-
const grid = document.getElementById('stats
|
|
935
|
+
function renderSummaryStats(s) {
|
|
936
|
+
const grid = document.getElementById('summary-stats');
|
|
674
937
|
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) },
|
|
938
|
+
{ label: 'Total Cost', value: fmtCost(s.totalCost), hero: true },
|
|
939
|
+
{ label: 'Total Sessions', value: fmtNum(s.totalSessions), hero: true },
|
|
940
|
+
{ label: 'Total Tasks', value: fmtNum(s.totalTasks), hero: true },
|
|
682
941
|
].map(c =>
|
|
683
|
-
'<div class="stat-card
|
|
942
|
+
'<div class="stat-card' + (c.hero ? ' hero' : '') + '">' +
|
|
684
943
|
'<div class="label">' + c.label + '</div>' +
|
|
685
944
|
'<div class="value">' + c.value + '</div>' +
|
|
686
945
|
'</div>'
|
|
687
946
|
).join('');
|
|
688
947
|
}
|
|
689
948
|
|
|
690
|
-
// ──
|
|
691
|
-
|
|
692
|
-
let allTasks = [];
|
|
693
|
-
let selectedTags = new Set();
|
|
694
|
-
let taskTurnsOffset = 0;
|
|
695
|
-
const TURNS_PAGE = 10;
|
|
949
|
+
// ── Task View ──
|
|
696
950
|
|
|
697
|
-
function
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
}
|
|
951
|
+
async function loadTasks() {
|
|
952
|
+
try {
|
|
953
|
+
const [stats, tasks] = await Promise.all([
|
|
954
|
+
fetchJson('/api/stats'),
|
|
955
|
+
fetchJson('/api/tasks'),
|
|
956
|
+
]);
|
|
957
|
+
if (!tasks.length) { allTaskGroups = []; renderTaskTable([]); return; }
|
|
704
958
|
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
959
|
+
// Discover tag combinations by sampling one turn per tag
|
|
960
|
+
const samples = await Promise.all(
|
|
961
|
+
tasks.map(t => fetchJson('/api/tasks/turns?tags=' + encodeURIComponent(t.task) + '&mode=any&limit=1')
|
|
962
|
+
.then(turns => turns[0]?.tags || t.task)
|
|
963
|
+
.catch(() => t.task)
|
|
964
|
+
)
|
|
965
|
+
);
|
|
966
|
+
// Deduplicate: normalize each combo to sorted pipe-separated
|
|
967
|
+
const combos = [...new Set(samples.map(s => s.split(', ').sort().join('|')))];
|
|
968
|
+
|
|
969
|
+
// Fetch stats for each unique combo
|
|
970
|
+
const comboStats = await Promise.all(
|
|
971
|
+
combos.map(combo => {
|
|
972
|
+
const tags = combo.split('|');
|
|
973
|
+
const qs = 'tags=' + tags.map(encodeURIComponent).join(',') + '&mode=all';
|
|
974
|
+
return fetchJson('/api/tasks/stats?' + qs)
|
|
975
|
+
.then(s => ({ ...s, tags: combo }))
|
|
976
|
+
.catch(() => ({ tags: combo, total_turns: 0, user_turns: 0, total_duration_ms: 0, total_cost_usd: 0 }));
|
|
977
|
+
})
|
|
978
|
+
);
|
|
979
|
+
|
|
980
|
+
let taskGroups = comboStats.map(s => ({
|
|
981
|
+
tags: s.tags,
|
|
982
|
+
turn_count: s.total_turns ?? 0,
|
|
983
|
+
human_interventions: s.user_turns ?? 0,
|
|
984
|
+
total_duration_ms: s.total_duration_ms ?? 0,
|
|
985
|
+
total_cost_usd: s.total_cost_usd ?? 0,
|
|
986
|
+
last_seen: s.last_seen ?? null,
|
|
987
|
+
}));
|
|
988
|
+
|
|
989
|
+
// Add cost gap to the Untagged row so task costs sum to total
|
|
990
|
+
const attributedCost = taskGroups.reduce((s, g) => s + (g.total_cost_usd || 0), 0);
|
|
991
|
+
const totalCost = stats.total_cost_usd || 0;
|
|
992
|
+
const gap = totalCost - attributedCost;
|
|
993
|
+
if (gap > 0.01) {
|
|
994
|
+
const untagged = taskGroups.find(g => g.tags === 'Untagged');
|
|
995
|
+
if (untagged) {
|
|
996
|
+
untagged.total_cost_usd = (untagged.total_cost_usd || 0) + gap;
|
|
997
|
+
} else {
|
|
998
|
+
taskGroups.push({
|
|
999
|
+
tags: 'Untagged',
|
|
1000
|
+
turn_count: 0,
|
|
1001
|
+
human_interventions: 0,
|
|
1002
|
+
total_duration_ms: 0,
|
|
1003
|
+
total_cost_usd: gap,
|
|
1004
|
+
last_seen: null,
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
708
1008
|
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
).join('');
|
|
714
|
-
} else {
|
|
715
|
-
ctxEl.textContent = '';
|
|
1009
|
+
allTaskGroups = taskGroups;
|
|
1010
|
+
renderTaskTable(sortData(taskGroups, sortState.tasks));
|
|
1011
|
+
} catch (e) {
|
|
1012
|
+
console.error('tasks load failed', e);
|
|
716
1013
|
}
|
|
1014
|
+
}
|
|
717
1015
|
|
|
718
|
-
|
|
719
|
-
|
|
1016
|
+
function renderTaskTable(groups) {
|
|
1017
|
+
const tbody = document.getElementById('task-table');
|
|
1018
|
+
if (!groups.length) {
|
|
1019
|
+
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
1020
|
return;
|
|
721
1021
|
}
|
|
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>';
|
|
1022
|
+
tbody.innerHTML = groups.map(g => {
|
|
1023
|
+
const tags = g.tags.split('|');
|
|
1024
|
+
const pills = tags.map(t =>
|
|
1025
|
+
'<span class="tag-pill' + (t === 'Untagged' ? ' untagged' : '') + '">' + escHtml(t) + '</span>'
|
|
1026
|
+
).join('');
|
|
1027
|
+
return '<tr class="clickable" onclick="showTaskDetail(\'' + escHtml(g.tags).replace(/'/g, "\\'") + '\')">' +
|
|
1028
|
+
'<td>' + pills + '</td>' +
|
|
1029
|
+
'<td>' + fmtDuration(g.total_duration_ms) + '</td>' +
|
|
1030
|
+
'<td><span class="badge badge-cost">' + fmtCost(g.total_cost_usd) + '</span></td>' +
|
|
1031
|
+
'<td>' + fmtNum(g.human_interventions) + '</td>' +
|
|
1032
|
+
'<td title="' + fmtAbsolute(g.last_seen) + '">' + fmtRelative(g.last_seen) + '</td>' +
|
|
1033
|
+
'</tr>';
|
|
737
1034
|
}).join('');
|
|
738
1035
|
}
|
|
739
1036
|
|
|
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();
|
|
1037
|
+
function filterTaskTable(q) {
|
|
1038
|
+
q = q.toLowerCase();
|
|
1039
|
+
const filtered = q
|
|
1040
|
+
? allTaskGroups.filter(g => g.tags.toLowerCase().includes(q))
|
|
1041
|
+
: allTaskGroups;
|
|
1042
|
+
renderTaskTable(sortData(filtered, sortState.tasks));
|
|
752
1043
|
}
|
|
753
1044
|
|
|
754
|
-
|
|
1045
|
+
// ── Task Group Detail ──
|
|
755
1046
|
|
|
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
|
-
}
|
|
1047
|
+
async function showTaskDetail(tagSet) {
|
|
1048
|
+
previousView = 'tasks';
|
|
1049
|
+
showView('task-detail');
|
|
765
1050
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
if (tags.length === 0) return;
|
|
1051
|
+
const tags = tagSet.split('|');
|
|
1052
|
+
taskDetailTags = tags;
|
|
1053
|
+
taskDetailOffset = 0;
|
|
770
1054
|
|
|
771
|
-
const
|
|
772
|
-
const
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
}
|
|
777
|
-
const [stats, turns] = await Promise.all(fetches);
|
|
1055
|
+
const statsEl = document.getElementById('task-detail-stats');
|
|
1056
|
+
const pills = tags.map(t =>
|
|
1057
|
+
'<span class="tag-pill' + (t === 'Untagged' ? ' untagged' : '') + '">' + escHtml(t) + '</span>'
|
|
1058
|
+
).join(' ');
|
|
1059
|
+
statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
|
|
778
1060
|
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
document.getElementById('turns-section').style.display = 'none';
|
|
1061
|
+
if (tags.length === 1 && tags[0] === 'Untagged') {
|
|
1062
|
+
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>';
|
|
1063
|
+
document.getElementById('task-turns-table').innerHTML = '<tr><td colspan="5" class="empty">Untagged turns cannot be drilled into.</td></tr>';
|
|
1064
|
+
document.getElementById('task-turns-pagination').innerHTML = '';
|
|
1065
|
+
return;
|
|
785
1066
|
}
|
|
786
|
-
}
|
|
787
1067
|
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
'
|
|
802
|
-
|
|
803
|
-
|
|
1068
|
+
const qs = 'tags=' + tags.map(encodeURIComponent).join(',') + '&mode=all';
|
|
1069
|
+
|
|
1070
|
+
try {
|
|
1071
|
+
const [stats, rawTurns] = await Promise.all([
|
|
1072
|
+
fetchJson('/api/tasks/stats?' + qs),
|
|
1073
|
+
fetchJson('/api/tasks/turns?' + qs + '&limit=' + PAGE_SIZE + '&offset=0'),
|
|
1074
|
+
]);
|
|
1075
|
+
const turns = await enrichTurnsWithCost(rawTurns);
|
|
1076
|
+
|
|
1077
|
+
statsEl.innerHTML = [
|
|
1078
|
+
{ label: 'Task', value: '<span style="font-size:14px">' + pills + '</span>' },
|
|
1079
|
+
{ label: 'Total Turns', value: fmtNum(stats.total_turns) },
|
|
1080
|
+
{ label: 'Human Interventions', value: fmtNum(stats.user_turns) },
|
|
1081
|
+
{ label: 'Duration', value: fmtDuration(stats.total_duration_ms) },
|
|
1082
|
+
{ label: 'Cost', value: fmtCost(stats.total_cost_usd) },
|
|
1083
|
+
].map(c =>
|
|
1084
|
+
'<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:16px">' + c.value + '</div></div>'
|
|
1085
|
+
).join('');
|
|
1086
|
+
|
|
1087
|
+
renderTaskDetailTurns(turns);
|
|
1088
|
+
taskDetailOffset = turns.length;
|
|
1089
|
+
} catch (e) {
|
|
1090
|
+
console.error('task detail load failed', e);
|
|
1091
|
+
document.getElementById('task-detail-stats').innerHTML =
|
|
1092
|
+
'<div class="stat-card" style="grid-column:1/-1"><div class="label" style="color:var(--red)">Error: ' + escHtml(String(e)) + '</div></div>';
|
|
1093
|
+
}
|
|
804
1094
|
}
|
|
805
1095
|
|
|
806
|
-
function
|
|
807
|
-
const
|
|
808
|
-
const
|
|
809
|
-
const countEl = document.getElementById('turns-count');
|
|
810
|
-
const paginationEl = document.getElementById('turns-pagination');
|
|
1096
|
+
function renderTaskDetailTurns(turns) {
|
|
1097
|
+
const tbody = document.getElementById('task-turns-table');
|
|
1098
|
+
const paginationEl = document.getElementById('task-turns-pagination');
|
|
811
1099
|
|
|
812
|
-
if (!turns.length &&
|
|
813
|
-
|
|
1100
|
+
if (!turns.length && taskDetailOffset === 0) {
|
|
1101
|
+
tbody.innerHTML = '<tr><td colspan="5" class="empty">No turns found.</td></tr>';
|
|
1102
|
+
paginationEl.innerHTML = '';
|
|
814
1103
|
return;
|
|
815
1104
|
}
|
|
816
1105
|
|
|
817
|
-
|
|
818
|
-
const
|
|
819
|
-
const hasMore = turns.length >= TURNS_PAGE;
|
|
820
|
-
countEl.textContent = '';
|
|
1106
|
+
const page = Math.floor(taskDetailOffset / PAGE_SIZE);
|
|
1107
|
+
const hasMore = turns.length >= PAGE_SIZE;
|
|
821
1108
|
paginationEl.innerHTML =
|
|
822
|
-
(page > 0 ? '<button class="
|
|
1109
|
+
(page > 0 ? '<button class="time-btn" onclick="taskDetailPrev()">Prev</button>' : '') +
|
|
823
1110
|
'<span style="font-size:11px;color:var(--text-dim);font-family:var(--mono)">Page ' + (page + 1) + '</span>' +
|
|
824
|
-
(hasMore ? '<button class="
|
|
1111
|
+
(hasMore ? '<button class="time-btn" onclick="taskDetailNext()">Next</button>' : '');
|
|
825
1112
|
|
|
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(' ') : '';
|
|
1113
|
+
tbody.innerHTML = turns.map(t => {
|
|
830
1114
|
const preview = t.content_text ? escHtml(t.content_text.slice(0, 80)) + (t.content_text.length > 80 ? '\u2026' : '') : '\u2014';
|
|
831
1115
|
const tokens = fmtNum((t.block_input_tokens || 0) + (t.block_output_tokens || 0));
|
|
832
|
-
|
|
833
|
-
return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ')">' +
|
|
1116
|
+
return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ', \'task-detail\')">' +
|
|
834
1117
|
'<td title="' + fmtAbsolute(t.timestamp) + '" style="white-space:nowrap">' + fmtRelative(t.timestamp) + '</td>' +
|
|
835
1118
|
'<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
1119
|
'<td>' + fmtDuration(t.duration_ms) + '</td>' +
|
|
838
1120
|
'<td><span class="badge badge-tokens">' + tokens + '</span></td>' +
|
|
839
1121
|
'<td><span class="badge badge-cost">' + fmtCost(t.block_cost_usd) + '</span></td>' +
|
|
840
1122
|
'</tr>';
|
|
841
1123
|
}).join('');
|
|
1124
|
+
}
|
|
842
1125
|
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
1126
|
+
async function taskDetailNext() {
|
|
1127
|
+
taskDetailOffset += PAGE_SIZE;
|
|
1128
|
+
const qs = 'tags=' + taskDetailTags.map(encodeURIComponent).join(',') + '&mode=all&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset;
|
|
1129
|
+
const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + qs));
|
|
1130
|
+
renderTaskDetailTurns(turns);
|
|
1131
|
+
}
|
|
1132
|
+
|
|
1133
|
+
async function taskDetailPrev() {
|
|
1134
|
+
taskDetailOffset = Math.max(0, taskDetailOffset - PAGE_SIZE);
|
|
1135
|
+
const qs = 'tags=' + taskDetailTags.map(encodeURIComponent).join(',') + '&mode=all&limit=' + PAGE_SIZE + '&offset=' + taskDetailOffset;
|
|
1136
|
+
const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + qs));
|
|
1137
|
+
renderTaskDetailTurns(turns);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// ── Tag View ──
|
|
1141
|
+
|
|
1142
|
+
async function loadTags() {
|
|
1143
|
+
try {
|
|
1144
|
+
const tasks = await fetchJson('/api/tasks');
|
|
1145
|
+
if (!tasks.length) { allTagStats = []; renderTagTable([]); return; }
|
|
1146
|
+
const statsList = await Promise.all(
|
|
1147
|
+
tasks.map(t => fetchJson('/api/tasks/stats?tags=' + encodeURIComponent(t.task) + '&mode=any')
|
|
1148
|
+
.then(s => ({ ...s, tag: t.task, last_seen: t.last_tagged }))
|
|
1149
|
+
.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 }))
|
|
1150
|
+
)
|
|
1151
|
+
);
|
|
1152
|
+
allTagStats = statsList.map(s => ({
|
|
1153
|
+
tag: s.tag,
|
|
1154
|
+
turn_count: s.total_turns ?? s.turn_count ?? 0,
|
|
1155
|
+
human_interventions: s.user_turns ?? s.human_interventions ?? 0,
|
|
1156
|
+
total_duration_ms: s.total_duration_ms ?? 0,
|
|
1157
|
+
total_cost_usd: s.total_cost_usd ?? 0,
|
|
1158
|
+
last_seen: s.last_seen,
|
|
1159
|
+
}));
|
|
1160
|
+
renderTagTable(sortData(allTagStats, sortState.tags));
|
|
1161
|
+
} catch (e) {
|
|
1162
|
+
console.error('tags load failed', e);
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
|
|
1166
|
+
function renderTagTable(stats) {
|
|
1167
|
+
const tbody = document.getElementById('tag-table');
|
|
1168
|
+
if (!stats.length) {
|
|
1169
|
+
tbody.innerHTML = '<tr><td colspan="5" class="empty">No tags yet. Tag turns with <code>zozul tag</code> to see them here.</td></tr>';
|
|
1170
|
+
return;
|
|
847
1171
|
}
|
|
1172
|
+
tbody.innerHTML = stats.map(t =>
|
|
1173
|
+
'<tr class="clickable" onclick="showTagDetail(\'' + escHtml(t.tag).replace(/'/g, "\\'") + '\')">' +
|
|
1174
|
+
'<td><span class="tag-pill">' + escHtml(t.tag) + '</span></td>' +
|
|
1175
|
+
'<td>' + fmtDuration(t.total_duration_ms) + '</td>' +
|
|
1176
|
+
'<td><span class="badge badge-cost">' + fmtCost(t.total_cost_usd) + '</span></td>' +
|
|
1177
|
+
'<td>' + fmtNum(t.human_interventions) + '</td>' +
|
|
1178
|
+
'<td title="' + fmtAbsolute(t.last_seen) + '">' + fmtRelative(t.last_seen) + '</td>' +
|
|
1179
|
+
'</tr>'
|
|
1180
|
+
).join('');
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
// ── Tag Detail ──
|
|
1184
|
+
|
|
1185
|
+
async function showTagDetail(tagName) {
|
|
1186
|
+
previousView = 'tags';
|
|
1187
|
+
showView('tag-detail');
|
|
1188
|
+
tagDetailName = tagName;
|
|
1189
|
+
tagDetailOffset = 0;
|
|
1190
|
+
|
|
1191
|
+
const statsEl = document.getElementById('tag-detail-stats');
|
|
1192
|
+
statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
|
|
1193
|
+
|
|
1194
|
+
try {
|
|
1195
|
+
const tagQs = 'tags=' + encodeURIComponent(tagName) + '&mode=any';
|
|
1196
|
+
const [stats, rawTurns] = await Promise.all([
|
|
1197
|
+
fetchJson('/api/tasks/stats?' + tagQs),
|
|
1198
|
+
fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=0'),
|
|
1199
|
+
]);
|
|
1200
|
+
const turns = await enrichTurnsWithCost(rawTurns);
|
|
1201
|
+
|
|
1202
|
+
statsEl.innerHTML = [
|
|
1203
|
+
{ label: 'Tag', value: '<span class="tag-pill" style="font-size:14px;padding:4px 12px">' + escHtml(tagName) + '</span>' },
|
|
1204
|
+
{ label: 'Total Turns', value: fmtNum(stats.total_turns) },
|
|
1205
|
+
{ label: 'Human Interventions', value: fmtNum(stats.user_turns) },
|
|
1206
|
+
{ label: 'Duration', value: fmtDuration(stats.total_duration_ms) },
|
|
1207
|
+
{ label: 'Cost', value: fmtCost(stats.total_cost_usd) },
|
|
1208
|
+
].map(c =>
|
|
1209
|
+
'<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:16px">' + c.value + '</div></div>'
|
|
1210
|
+
).join('');
|
|
1211
|
+
|
|
1212
|
+
renderTagDetailTurns(turns);
|
|
1213
|
+
tagDetailOffset = turns.length;
|
|
1214
|
+
} catch (e) {
|
|
1215
|
+
console.error('tag detail load failed', e);
|
|
1216
|
+
document.getElementById('tag-detail-stats').innerHTML =
|
|
1217
|
+
'<div class="stat-card" style="grid-column:1/-1"><div class="label" style="color:var(--red)">Error: ' + escHtml(String(e)) + '</div></div>';
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
function renderTagDetailTurns(turns) {
|
|
1222
|
+
const tbody = document.getElementById('tag-turns-table');
|
|
1223
|
+
const paginationEl = document.getElementById('tag-turns-pagination');
|
|
1224
|
+
|
|
1225
|
+
if (!turns.length && tagDetailOffset === 0) {
|
|
1226
|
+
tbody.innerHTML = '<tr><td colspan="4" class="empty">No turns found.</td></tr>';
|
|
1227
|
+
paginationEl.innerHTML = '';
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
const page = Math.floor(tagDetailOffset / PAGE_SIZE);
|
|
1232
|
+
const hasMore = turns.length >= PAGE_SIZE;
|
|
1233
|
+
paginationEl.innerHTML =
|
|
1234
|
+
(page > 0 ? '<button class="time-btn" onclick="tagDetailPrev()">Prev</button>' : '') +
|
|
1235
|
+
'<span style="font-size:11px;color:var(--text-dim);font-family:var(--mono)">Page ' + (page + 1) + '</span>' +
|
|
1236
|
+
(hasMore ? '<button class="time-btn" onclick="tagDetailNext()">Next</button>' : '');
|
|
1237
|
+
|
|
1238
|
+
tbody.innerHTML = turns.map(t => {
|
|
1239
|
+
const preview = t.content_text ? escHtml(t.content_text.slice(0, 100)) + (t.content_text.length > 100 ? '\u2026' : '') : '\u2014';
|
|
1240
|
+
return '<tr class="clickable" onclick="showTurnBlock(' + t.id + ', \'tag-detail\')">' +
|
|
1241
|
+
'<td title="' + fmtAbsolute(t.timestamp) + '" style="white-space:nowrap">' + fmtRelative(t.timestamp) + '</td>' +
|
|
1242
|
+
'<td style="font-family:var(--mono);font-size:12px;max-width:400px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + preview + '</td>' +
|
|
1243
|
+
'<td><span class="session-id" onclick="event.stopPropagation();copyText(\'' + t.session_id + '\')" title="Click to copy">' +
|
|
1244
|
+
shortId(t.session_id) + '<span class="copy-icon">⎘</span></span></td>' +
|
|
1245
|
+
'<td><span class="badge badge-cost">' + fmtCost(t.block_cost_usd ?? t.estimated_cost_usd ?? 0) + '</span></td>' +
|
|
1246
|
+
'</tr>';
|
|
1247
|
+
}).join('');
|
|
848
1248
|
}
|
|
849
1249
|
|
|
850
|
-
async function
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
const
|
|
854
|
-
|
|
1250
|
+
async function tagDetailNext() {
|
|
1251
|
+
tagDetailOffset += PAGE_SIZE;
|
|
1252
|
+
const tagQs = 'tags=' + encodeURIComponent(tagDetailName) + '&mode=any';
|
|
1253
|
+
const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=' + tagDetailOffset));
|
|
1254
|
+
renderTagDetailTurns(turns);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
async function tagDetailPrev() {
|
|
1258
|
+
tagDetailOffset = Math.max(0, tagDetailOffset - PAGE_SIZE);
|
|
1259
|
+
const tagQs = 'tags=' + encodeURIComponent(tagDetailName) + '&mode=any';
|
|
1260
|
+
const turns = await enrichTurnsWithCost(await fetchJson('/api/tasks/turns?' + tagQs + '&limit=' + PAGE_SIZE + '&offset=' + tagDetailOffset));
|
|
1261
|
+
renderTagDetailTurns(turns);
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// ── Turn Block Detail ──
|
|
1265
|
+
|
|
1266
|
+
let turnDetailReturnView = 'tasks';
|
|
1267
|
+
|
|
1268
|
+
async function showTurnBlock(turnId, returnTo) {
|
|
1269
|
+
turnDetailReturnView = returnTo || 'tasks';
|
|
1270
|
+
showView('turn-detail');
|
|
855
1271
|
|
|
856
1272
|
const statsEl = document.getElementById('turn-detail-stats');
|
|
857
1273
|
const content = document.getElementById('turn-detail-content');
|
|
858
|
-
statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading
|
|
1274
|
+
statsEl.innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
|
|
859
1275
|
content.innerHTML = '';
|
|
860
1276
|
|
|
861
|
-
const block = await fetchJson('/api/turns/' + turnId + '/block');
|
|
1277
|
+
const block = await enrichTurnsWithCost(await fetchJson('/api/turns/' + turnId + '/block'));
|
|
862
1278
|
if (!block.length) { content.innerHTML = '<div class="empty">No data</div>'; return; }
|
|
863
1279
|
|
|
864
|
-
// Aggregate block stats
|
|
865
1280
|
const totalIn = block.reduce((s, t) => s + (t.input_tokens || 0), 0);
|
|
866
1281
|
const totalOut = block.reduce((s, t) => s + (t.output_tokens || 0), 0);
|
|
867
|
-
const totalCost = block.reduce((s, t) => s + (t.
|
|
1282
|
+
const totalCost = block.reduce((s, t) => s + (t.estimated_cost_usd || 0), 0);
|
|
868
1283
|
const duration = block[0].duration_ms || 0;
|
|
869
1284
|
const prompt = block[0].content_text ? block[0].content_text.slice(0, 100) : '\u2014';
|
|
870
1285
|
|
|
@@ -879,367 +1294,228 @@ async function showTurnBlock(turnId) {
|
|
|
879
1294
|
'<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:14px">' + c.value + '</div></div>'
|
|
880
1295
|
).join('');
|
|
881
1296
|
|
|
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');
|
|
1297
|
+
content.innerHTML = block.map((t, i) => renderTurnHtml(t, 'tb-' + turnId + '-' + i)).join('');
|
|
984
1298
|
}
|
|
985
1299
|
|
|
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;
|
|
1300
|
+
function goBackFromTurn() {
|
|
1301
|
+
if (currentView === 'turn-detail') {
|
|
1302
|
+
showView(turnDetailReturnView);
|
|
1303
|
+
} else if (currentView === 'session-detail') {
|
|
1304
|
+
showView(previousView || 'tasks');
|
|
997
1305
|
}
|
|
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
1306
|
}
|
|
1004
1307
|
|
|
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
|
-
}
|
|
1308
|
+
// ── Session Detail ──
|
|
1055
1309
|
|
|
1056
|
-
|
|
1310
|
+
async function showSession(id, returnTo) {
|
|
1311
|
+
previousView = returnTo || 'tasks';
|
|
1312
|
+
showView('session-detail');
|
|
1057
1313
|
|
|
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 = '';
|
|
1314
|
+
document.getElementById('session-detail-stats').innerHTML = '<div class="stat-card" style="grid-column:1/-1"><div class="label">Loading...</div></div>';
|
|
1315
|
+
document.getElementById('session-detail-turns').innerHTML = '';
|
|
1064
1316
|
|
|
1065
1317
|
const [session, turns] = await Promise.all([
|
|
1066
1318
|
fetchJson('/api/sessions/' + id),
|
|
1067
1319
|
fetchJson('/api/sessions/' + id + '/turns'),
|
|
1068
1320
|
]);
|
|
1069
1321
|
|
|
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) },
|
|
1322
|
+
document.getElementById('session-detail-stats').innerHTML = [
|
|
1323
|
+
{ 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>' },
|
|
1324
|
+
{ label: 'Project', value: '<span title="' + escHtml(session.project_path ?? '') + '">' + escHtml(basename(session.project_path)) + '</span>' },
|
|
1325
|
+
{ label: 'Model', value: '<span style="font-family:var(--mono);font-size:13px">' + escHtml(session.model ?? '\u2014') + '</span>' },
|
|
1326
|
+
{ label: 'Duration', value: fmtDuration(session.total_duration_ms) },
|
|
1327
|
+
{ label: 'Turns', value: session.total_turns },
|
|
1328
|
+
{ label: 'Input Tokens', value: fmtNum(session.total_input_tokens) },
|
|
1329
|
+
{ label: 'Output Tokens', value: fmtNum(session.total_output_tokens) },
|
|
1330
|
+
{ label: 'Cost', value: fmtCost(session.total_cost_usd) },
|
|
1080
1331
|
].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>'
|
|
1332
|
+
'<div class="stat-card"><div class="label">' + c.label + '</div><div class="value" style="font-size:14px">' + c.value + '</div></div>'
|
|
1085
1333
|
).join('');
|
|
1086
1334
|
|
|
1087
|
-
const turnsDiv = document.getElementById('detail-turns');
|
|
1335
|
+
const turnsDiv = document.getElementById('session-detail-turns');
|
|
1088
1336
|
if (!turns.length) {
|
|
1089
|
-
turnsDiv.innerHTML = '<div class="empty">No turns recorded
|
|
1337
|
+
turnsDiv.innerHTML = '<div class="empty">No turns recorded.</div>';
|
|
1090
1338
|
return;
|
|
1091
1339
|
}
|
|
1340
|
+
turnsDiv.innerHTML = turns.map((t, i) => renderTurnHtml(t, 'tc-' + i)).join('');
|
|
1341
|
+
}
|
|
1092
1342
|
|
|
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
|
-
}
|
|
1343
|
+
// ── Shared turn rendering ──
|
|
1344
|
+
|
|
1345
|
+
function renderTurnHtml(t, idPrefix) {
|
|
1346
|
+
const roleClass = t.role === 'assistant' ? 'assistant' : 'user';
|
|
1347
|
+
const roleLabel = t.role === 'assistant' ? 'Assistant' : 'User';
|
|
1348
|
+
const meta = [];
|
|
1349
|
+
if (t.input_tokens) meta.push(fmtNum(t.input_tokens) + ' in');
|
|
1350
|
+
if (t.output_tokens) meta.push(fmtNum(t.output_tokens) + ' out');
|
|
1351
|
+
if (t.estimated_cost_usd || t.cost_usd) meta.push(fmtCost(t.estimated_cost_usd || t.cost_usd));
|
|
1352
|
+
if (t.duration_ms) meta.push(fmtDuration(t.duration_ms));
|
|
1353
|
+
|
|
1354
|
+
const text = t.content_text
|
|
1355
|
+
? '<div class="turn-content">' + escHtml(t.content_text) + '</div>'
|
|
1356
|
+
: '';
|
|
1357
|
+
|
|
1358
|
+
let toolsHtml = '';
|
|
1359
|
+
if (t.tool_calls) {
|
|
1360
|
+
try {
|
|
1361
|
+
const calls = JSON.parse(t.tool_calls);
|
|
1362
|
+
toolsHtml = '<div class="tool-calls">' + calls.map((c, ci) => {
|
|
1363
|
+
const uid = idPrefix + '-' + ci;
|
|
1364
|
+
const inputJson = JSON.stringify(c.toolInput, null, 2);
|
|
1365
|
+
const resultHtml = c.toolResult
|
|
1366
|
+
? '<div class="tool-section-label">Result</div><div class="tool-json">' + escHtml(c.toolResult) + '</div>'
|
|
1367
|
+
: '';
|
|
1368
|
+
return '<div class="tool-call" id="' + uid + '">' +
|
|
1369
|
+
'<div class="tool-call-header" onclick="toggleTool(\'' + uid + '\')">' +
|
|
1370
|
+
'<span class="tool-name">' + escHtml(c.toolName) + '</span>' +
|
|
1371
|
+
'<span class="tool-chevron">►</span>' +
|
|
1372
|
+
'</div>' +
|
|
1373
|
+
'<div class="tool-body">' +
|
|
1374
|
+
'<div class="tool-section-label">Input</div>' +
|
|
1375
|
+
'<div class="tool-json">' + escHtml(inputJson) + '</div>' +
|
|
1376
|
+
resultHtml +
|
|
1377
|
+
'</div>' +
|
|
1378
|
+
'</div>';
|
|
1379
|
+
}).join('') + '</div>';
|
|
1380
|
+
} catch {}
|
|
1381
|
+
}
|
|
1131
1382
|
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
}).join('');
|
|
1383
|
+
return '<div class="turn">' +
|
|
1384
|
+
'<div class="turn-header">' +
|
|
1385
|
+
'<span class="turn-role ' + roleClass + '">' + roleLabel + '</span>' +
|
|
1386
|
+
(meta.length ? '<span class="turn-meta">' + meta.join(' \u00b7 ') + '</span>' : '') +
|
|
1387
|
+
'<span class="turn-ts" title="' + fmtAbsolute(t.timestamp) + '">' + fmtRelative(t.timestamp) + '</span>' +
|
|
1388
|
+
'</div>' +
|
|
1389
|
+
text +
|
|
1390
|
+
toolsHtml +
|
|
1391
|
+
'</div>';
|
|
1142
1392
|
}
|
|
1143
1393
|
|
|
1144
1394
|
function toggleTool(uid) {
|
|
1145
1395
|
document.getElementById(uid).classList.toggle('open');
|
|
1146
1396
|
}
|
|
1147
1397
|
|
|
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
|
-
}
|
|
1398
|
+
// ── Sessions View ──
|
|
1153
1399
|
|
|
1154
|
-
|
|
1400
|
+
async function loadSessions() {
|
|
1401
|
+
try {
|
|
1402
|
+
const resp = await fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=0');
|
|
1403
|
+
const sessions = Array.isArray(resp) ? resp : resp.sessions;
|
|
1404
|
+
allSessions = sessions;
|
|
1405
|
+
sessionsTotal = Array.isArray(resp) ? sessions.length : resp.total;
|
|
1406
|
+
sessionsOffset = sessions.length;
|
|
1407
|
+
renderSessionsTable(sortData(sessions, sortState.sessions));
|
|
1408
|
+
updateLoadMore();
|
|
1409
|
+
} catch (e) {
|
|
1410
|
+
console.error('sessions load failed', e);
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1155
1413
|
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1414
|
+
async function loadMoreSessions() {
|
|
1415
|
+
try {
|
|
1416
|
+
const resp = await fetchJson('/api/sessions?limit=' + SESSIONS_PAGE + '&offset=' + sessionsOffset);
|
|
1417
|
+
const page = Array.isArray(resp) ? resp : resp.sessions;
|
|
1418
|
+
allSessions = allSessions.concat(page);
|
|
1419
|
+
sessionsTotal = Array.isArray(resp) ? allSessions.length : resp.total;
|
|
1420
|
+
sessionsOffset = allSessions.length;
|
|
1421
|
+
const q = document.getElementById('session-filter').value;
|
|
1422
|
+
const display = q ? allSessions.filter(s => sessionMatches(s, q)) : allSessions;
|
|
1423
|
+
renderSessionsTable(sortData(display, sortState.sessions));
|
|
1424
|
+
updateLoadMore();
|
|
1425
|
+
} catch (e) {
|
|
1426
|
+
console.error('load more failed', e);
|
|
1162
1427
|
}
|
|
1163
|
-
}
|
|
1428
|
+
}
|
|
1164
1429
|
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
loadCharts();
|
|
1430
|
+
function updateLoadMore() {
|
|
1431
|
+
const row = document.getElementById('load-more-row');
|
|
1432
|
+
const shown = document.getElementById('sessions-shown');
|
|
1433
|
+
const count = document.getElementById('sessions-count');
|
|
1434
|
+
count.textContent = '(' + sessionsTotal + ')';
|
|
1435
|
+
if (sessionsTotal > sessionsOffset) {
|
|
1436
|
+
row.style.display = '';
|
|
1437
|
+
shown.textContent = sessionsOffset + ' of ' + sessionsTotal + ' shown';
|
|
1438
|
+
} else {
|
|
1439
|
+
row.style.display = 'none';
|
|
1440
|
+
}
|
|
1177
1441
|
}
|
|
1178
1442
|
|
|
1179
|
-
function
|
|
1180
|
-
|
|
1443
|
+
function sessionMatches(s, q) {
|
|
1444
|
+
q = q.toLowerCase();
|
|
1445
|
+
return (s.id && s.id.toLowerCase().includes(q)) ||
|
|
1446
|
+
(s.project_path && s.project_path.toLowerCase().includes(q)) ||
|
|
1447
|
+
(s.model && s.model.toLowerCase().includes(q));
|
|
1181
1448
|
}
|
|
1182
1449
|
|
|
1183
|
-
|
|
1184
|
-
const
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
}
|
|
1450
|
+
function filterSessions(q) {
|
|
1451
|
+
const filtered = q ? allSessions.filter(s => sessionMatches(s, q)) : allSessions;
|
|
1452
|
+
renderSessionsTable(sortData(filtered, sortState.sessions));
|
|
1453
|
+
document.getElementById('load-more-row').style.display = q ? 'none' : (sessionsTotal > sessionsOffset ? '' : 'none');
|
|
1454
|
+
}
|
|
1188
1455
|
|
|
1189
|
-
|
|
1456
|
+
function renderSessionsTable(sessions) {
|
|
1457
|
+
const tbody = document.getElementById('sessions-table');
|
|
1458
|
+
if (!sessions.length) {
|
|
1459
|
+
tbody.innerHTML = '<tr><td colspan="7" class="empty">No sessions yet.</td></tr>';
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1462
|
+
tbody.innerHTML = sessions.map(s => {
|
|
1463
|
+
const proj = basename(s.project_path);
|
|
1464
|
+
const projAttr = s.project_path ? ' title="' + escHtml(s.project_path) + '"' : '';
|
|
1465
|
+
return '<tr class="clickable" onclick="showSession(\'' + s.id + '\', \'sessions\')">' +
|
|
1466
|
+
'<td><span class="session-id" onclick="event.stopPropagation();copyText(\'' + s.id + '\')" title="Click to copy full ID">' +
|
|
1467
|
+
shortId(s.id) + '<span class="copy-icon">⎘</span></span></td>' +
|
|
1468
|
+
'<td' + projAttr + '>' + escHtml(proj) + '</td>' +
|
|
1469
|
+
'<td title="' + fmtAbsolute(s.started_at) + '">' + fmtRelative(s.started_at) + '</td>' +
|
|
1470
|
+
'<td>' + fmtDuration(s.total_duration_ms) + '</td>' +
|
|
1471
|
+
'<td>' + (s.total_turns ?? 0) + '</td>' +
|
|
1472
|
+
'<td><span class="badge badge-cost">' + fmtCost(s.total_cost_usd) + '</span></td>' +
|
|
1473
|
+
'<td style="font-family:var(--mono);font-size:11px;color:var(--text-dim)">' + escHtml(s.model ?? '\u2014') + '</td>' +
|
|
1474
|
+
'</tr>';
|
|
1475
|
+
}).join('');
|
|
1476
|
+
}
|
|
1190
1477
|
|
|
1191
|
-
|
|
1192
|
-
const customToggle = document.getElementById('custom-range-toggle');
|
|
1478
|
+
// ── Loading orchestration ──
|
|
1193
1479
|
|
|
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);
|
|
1480
|
+
async function loadViewData(view) {
|
|
1481
|
+
if (view === 'summary') await loadSummary();
|
|
1482
|
+
else if (view === 'sessions') await loadSessions();
|
|
1483
|
+
else if (view === 'tasks') await loadTasks();
|
|
1484
|
+
else if (view === 'tags') await loadTags();
|
|
1203
1485
|
}
|
|
1204
1486
|
|
|
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');
|
|
1487
|
+
async function loadDashboard() {
|
|
1488
|
+
const btn = document.querySelector('.auto-btn');
|
|
1489
|
+
btn?.classList.add('loading');
|
|
1490
|
+
const minSpinner = new Promise(r => setTimeout(r, 400));
|
|
1491
|
+
try {
|
|
1492
|
+
await loadViewData(currentView);
|
|
1493
|
+
} catch (e) {
|
|
1494
|
+
console.error('load failed', e);
|
|
1495
|
+
} finally {
|
|
1496
|
+
await minSpinner;
|
|
1497
|
+
btn?.classList.remove('loading');
|
|
1218
1498
|
}
|
|
1219
|
-
}
|
|
1499
|
+
}
|
|
1220
1500
|
|
|
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
|
-
});
|
|
1501
|
+
function manualRefresh() {
|
|
1502
|
+
if (autoRefreshTimer) { clearTimeout(autoRefreshTimer); autoRefreshTimer = null; }
|
|
1503
|
+
loadDashboard().then(scheduleAutoRefresh);
|
|
1504
|
+
}
|
|
1232
1505
|
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1506
|
+
function scheduleAutoRefresh() {
|
|
1507
|
+
autoRefreshTimer = setTimeout(async () => {
|
|
1508
|
+
// Only auto-refresh main views, not detail views
|
|
1509
|
+
if (['summary', 'sessions', 'tasks', 'tags'].includes(currentView)) {
|
|
1510
|
+
await loadDashboard();
|
|
1511
|
+
}
|
|
1512
|
+
scheduleAutoRefresh();
|
|
1513
|
+
}, AUTO_REFRESH_INTERVAL);
|
|
1240
1514
|
}
|
|
1241
1515
|
|
|
1242
|
-
|
|
1516
|
+
// ── Init ──
|
|
1517
|
+
|
|
1518
|
+
detectDataSource().then(() => loadDashboard()).then(scheduleAutoRefresh);
|
|
1243
1519
|
</script>
|
|
1244
1520
|
</body>
|
|
1245
1521
|
</html>
|