graphtrack 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -42
- package/dist/graph.d.ts +9 -0
- package/dist/graph.js +35 -30
- package/dist/graph.js.map +1 -1
- package/dist/parser.d.ts +7 -1
- package/dist/parser.js +104 -20
- package/dist/parser.js.map +1 -1
- package/dist/server.js +39 -2
- package/dist/server.js.map +1 -1
- package/dist/types.d.ts +26 -1
- package/package.json +7 -3
- package/public/images/icon.svg +1 -0
- package/public/index.html +973 -90
package/public/index.html
CHANGED
|
@@ -4,81 +4,683 @@
|
|
|
4
4
|
<meta charset="utf-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
6
|
<meta name="description" content="Graphtrack — visualize Claude Code token usage as an interactive graph." />
|
|
7
|
+
<link rel="icon" type="image/svg+xml" href="images/icon.svg" />
|
|
7
8
|
<title>Graphtrack</title>
|
|
9
|
+
<link rel="preconnect" href="https://fonts.googleapis.com">
|
|
10
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
11
|
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
|
8
12
|
<script src="https://cdnjs.cloudflare.com/ajax/libs/vis-network/9.1.9/standalone/umd/vis-network.min.js"></script>
|
|
9
13
|
<style>
|
|
10
14
|
:root {
|
|
11
|
-
--canvas
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
15
|
+
--canvas: #08080a;
|
|
16
|
+
--surface: #111114;
|
|
17
|
+
--card: #18181c;
|
|
18
|
+
--card-hover: #1e1e24;
|
|
19
|
+
--border: #25252a;
|
|
20
|
+
--border-light: #2e2e36;
|
|
21
|
+
--yellow: #faff69;
|
|
22
|
+
--yellow-dim: rgba(250, 255, 105, 0.08);
|
|
23
|
+
--yellow-glow: rgba(250, 255, 105, 0.12);
|
|
24
|
+
--text: #e8e8ec;
|
|
25
|
+
--body: #a0a0ac;
|
|
26
|
+
--muted: #65656f;
|
|
27
|
+
--accent-blue: #5b9cf5;
|
|
28
|
+
--accent-green: #4ade80;
|
|
29
|
+
--accent-red: #f87171;
|
|
30
|
+
--radius: 10px;
|
|
31
|
+
--radius-sm: 6px;
|
|
32
|
+
}
|
|
33
|
+
* { box-sizing: border-box; margin: 0; }
|
|
34
|
+
html, body { height: 100%; }
|
|
35
|
+
body {
|
|
36
|
+
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
|
37
|
+
background: var(--canvas);
|
|
38
|
+
color: var(--body);
|
|
39
|
+
display: flex;
|
|
40
|
+
flex-direction: column;
|
|
41
|
+
-webkit-font-smoothing: antialiased;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/* ─── NAVBAR ─── */
|
|
45
|
+
header {
|
|
46
|
+
padding: 10px 24px;
|
|
47
|
+
background: var(--surface);
|
|
48
|
+
border-bottom: 1px solid var(--border);
|
|
49
|
+
display: flex;
|
|
50
|
+
align-items: center;
|
|
51
|
+
gap: 10px;
|
|
52
|
+
flex: 0 0 auto;
|
|
53
|
+
flex-wrap: wrap;
|
|
54
|
+
position: relative;
|
|
55
|
+
z-index: 10;
|
|
56
|
+
}
|
|
57
|
+
header::after {
|
|
58
|
+
content: "";
|
|
59
|
+
position: absolute;
|
|
60
|
+
bottom: -1px;
|
|
61
|
+
left: 0;
|
|
62
|
+
right: 0;
|
|
63
|
+
height: 1px;
|
|
64
|
+
background: linear-gradient(90deg, transparent, var(--yellow-dim), transparent);
|
|
65
|
+
}
|
|
66
|
+
.logo {
|
|
67
|
+
display: flex;
|
|
68
|
+
align-items: center;
|
|
69
|
+
gap: 8px;
|
|
70
|
+
flex-shrink: 0;
|
|
71
|
+
}
|
|
72
|
+
.logo img {
|
|
73
|
+
width: 26px;
|
|
74
|
+
height: 26px;
|
|
75
|
+
filter: drop-shadow(0 0 6px rgba(250, 255, 105, 0.25));
|
|
76
|
+
transition: filter 0.3s;
|
|
77
|
+
}
|
|
78
|
+
.logo img:hover {
|
|
79
|
+
filter: drop-shadow(0 0 10px rgba(250, 255, 105, 0.45));
|
|
80
|
+
}
|
|
81
|
+
.logo h1 {
|
|
82
|
+
font-size: 17px;
|
|
83
|
+
color: var(--text);
|
|
84
|
+
letter-spacing: -0.4px;
|
|
85
|
+
font-weight: 700;
|
|
86
|
+
}
|
|
87
|
+
.sub {
|
|
88
|
+
font-size: 11.5px;
|
|
89
|
+
color: var(--muted);
|
|
90
|
+
margin-right: 4px;
|
|
91
|
+
white-space: nowrap;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/* ─── CUSTOM DROPDOWN ─── */
|
|
95
|
+
.dropdown {
|
|
96
|
+
position: relative;
|
|
97
|
+
}
|
|
98
|
+
.dropdown-trigger {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
gap: 8px;
|
|
102
|
+
background: var(--card);
|
|
103
|
+
color: var(--body);
|
|
104
|
+
border: 1px solid var(--border);
|
|
105
|
+
padding: 6px 12px;
|
|
106
|
+
border-radius: var(--radius-sm);
|
|
107
|
+
font-size: 12.5px;
|
|
108
|
+
font-family: inherit;
|
|
109
|
+
cursor: pointer;
|
|
110
|
+
transition: all 0.2s ease;
|
|
111
|
+
max-width: 340px;
|
|
112
|
+
white-space: nowrap;
|
|
113
|
+
overflow: hidden;
|
|
114
|
+
text-overflow: ellipsis;
|
|
115
|
+
min-width: 200px;
|
|
116
|
+
}
|
|
117
|
+
.dropdown-trigger:hover {
|
|
118
|
+
border-color: var(--border-light);
|
|
119
|
+
background: var(--card-hover);
|
|
120
|
+
}
|
|
121
|
+
.dropdown-trigger:focus {
|
|
122
|
+
outline: none;
|
|
123
|
+
border-color: var(--yellow);
|
|
124
|
+
box-shadow: 0 0 0 2px var(--yellow-dim);
|
|
125
|
+
}
|
|
126
|
+
.dropdown-trigger.disabled {
|
|
127
|
+
opacity: 0.4;
|
|
128
|
+
pointer-events: none;
|
|
129
|
+
}
|
|
130
|
+
.dropdown-trigger .dd-label {
|
|
131
|
+
flex: 1;
|
|
132
|
+
overflow: hidden;
|
|
133
|
+
text-overflow: ellipsis;
|
|
134
|
+
text-align: left;
|
|
135
|
+
}
|
|
136
|
+
.dropdown-trigger .dd-label.placeholder {
|
|
137
|
+
color: var(--muted);
|
|
138
|
+
}
|
|
139
|
+
.dropdown-trigger .dd-chevron {
|
|
140
|
+
flex-shrink: 0;
|
|
141
|
+
width: 14px;
|
|
142
|
+
height: 14px;
|
|
143
|
+
color: var(--muted);
|
|
144
|
+
transition: transform 0.2s ease, color 0.2s;
|
|
145
|
+
}
|
|
146
|
+
.dropdown-trigger:hover .dd-chevron { color: var(--body); }
|
|
147
|
+
.dropdown.open .dropdown-trigger {
|
|
148
|
+
border-color: var(--yellow);
|
|
149
|
+
box-shadow: 0 0 0 2px var(--yellow-dim);
|
|
150
|
+
}
|
|
151
|
+
.dropdown.open .dd-chevron {
|
|
152
|
+
transform: rotate(180deg);
|
|
153
|
+
color: var(--yellow);
|
|
154
|
+
}
|
|
155
|
+
.dropdown-panel {
|
|
156
|
+
position: absolute;
|
|
157
|
+
top: calc(100% + 6px);
|
|
158
|
+
left: 0;
|
|
159
|
+
min-width: 100%;
|
|
160
|
+
width: 360px;
|
|
161
|
+
background: var(--card);
|
|
162
|
+
border: 1px solid var(--border-light);
|
|
163
|
+
border-radius: var(--radius);
|
|
164
|
+
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(250, 255, 105, 0.04);
|
|
165
|
+
z-index: 100;
|
|
166
|
+
opacity: 0;
|
|
167
|
+
transform: translateY(-6px) scale(0.98);
|
|
168
|
+
pointer-events: none;
|
|
169
|
+
transition: opacity 0.18s ease, transform 0.18s ease;
|
|
170
|
+
overflow: hidden;
|
|
171
|
+
}
|
|
172
|
+
.dropdown.open .dropdown-panel {
|
|
173
|
+
opacity: 1;
|
|
174
|
+
transform: translateY(0) scale(1);
|
|
175
|
+
pointer-events: auto;
|
|
176
|
+
}
|
|
177
|
+
.dd-search {
|
|
178
|
+
padding: 10px 12px;
|
|
179
|
+
border-bottom: 1px solid var(--border);
|
|
180
|
+
}
|
|
181
|
+
.dd-search input {
|
|
182
|
+
width: 100%;
|
|
183
|
+
background: var(--surface);
|
|
184
|
+
border: 1px solid var(--border);
|
|
185
|
+
border-radius: var(--radius-sm);
|
|
186
|
+
padding: 7px 10px 7px 32px;
|
|
187
|
+
font-size: 12.5px;
|
|
188
|
+
font-family: inherit;
|
|
189
|
+
color: var(--text);
|
|
190
|
+
outline: none;
|
|
191
|
+
transition: border-color 0.2s;
|
|
192
|
+
}
|
|
193
|
+
.dd-search input:focus {
|
|
194
|
+
border-color: var(--yellow);
|
|
195
|
+
}
|
|
196
|
+
.dd-search input::placeholder {
|
|
197
|
+
color: var(--muted);
|
|
198
|
+
}
|
|
199
|
+
.dd-search-icon {
|
|
200
|
+
position: absolute;
|
|
201
|
+
left: 22px;
|
|
202
|
+
top: 50%;
|
|
203
|
+
transform: translateY(-50%);
|
|
204
|
+
width: 14px;
|
|
205
|
+
height: 14px;
|
|
206
|
+
color: var(--muted);
|
|
207
|
+
pointer-events: none;
|
|
208
|
+
}
|
|
209
|
+
.dd-search { position: relative; }
|
|
210
|
+
.dd-list {
|
|
211
|
+
max-height: 300px;
|
|
212
|
+
overflow-y: auto;
|
|
213
|
+
overscroll-behavior: contain;
|
|
214
|
+
padding: 4px;
|
|
215
|
+
}
|
|
216
|
+
.dd-group-label {
|
|
217
|
+
padding: 8px 10px 4px;
|
|
218
|
+
font-size: 10px;
|
|
219
|
+
text-transform: uppercase;
|
|
220
|
+
letter-spacing: 1px;
|
|
221
|
+
font-weight: 600;
|
|
222
|
+
color: var(--muted);
|
|
223
|
+
}
|
|
224
|
+
.dd-item {
|
|
225
|
+
display: flex;
|
|
226
|
+
align-items: center;
|
|
227
|
+
gap: 10px;
|
|
228
|
+
padding: 8px 10px;
|
|
229
|
+
border-radius: var(--radius-sm);
|
|
230
|
+
cursor: pointer;
|
|
231
|
+
transition: background 0.12s, color 0.12s;
|
|
232
|
+
font-size: 12.5px;
|
|
233
|
+
color: var(--body);
|
|
234
|
+
}
|
|
235
|
+
.dd-item:hover {
|
|
236
|
+
background: var(--yellow-dim);
|
|
237
|
+
color: var(--text);
|
|
238
|
+
}
|
|
239
|
+
.dd-item.selected {
|
|
240
|
+
background: rgba(250, 255, 105, 0.1);
|
|
241
|
+
color: var(--yellow);
|
|
242
|
+
}
|
|
243
|
+
.dd-item .dd-dot {
|
|
244
|
+
width: 6px;
|
|
245
|
+
height: 6px;
|
|
246
|
+
border-radius: 50%;
|
|
247
|
+
background: var(--border-light);
|
|
248
|
+
flex-shrink: 0;
|
|
249
|
+
transition: background 0.15s;
|
|
250
|
+
}
|
|
251
|
+
.dd-item.selected .dd-dot {
|
|
252
|
+
background: var(--yellow);
|
|
253
|
+
box-shadow: 0 0 6px rgba(250, 255, 105, 0.4);
|
|
254
|
+
}
|
|
255
|
+
.dd-item-info {
|
|
256
|
+
flex: 1;
|
|
257
|
+
min-width: 0;
|
|
258
|
+
display: flex;
|
|
259
|
+
flex-direction: column;
|
|
260
|
+
gap: 1px;
|
|
261
|
+
}
|
|
262
|
+
.dd-item-id {
|
|
263
|
+
font-family: 'JetBrains Mono', monospace;
|
|
264
|
+
font-size: 12px;
|
|
265
|
+
font-weight: 500;
|
|
266
|
+
color: var(--text);
|
|
267
|
+
overflow: hidden;
|
|
268
|
+
text-overflow: ellipsis;
|
|
269
|
+
white-space: nowrap;
|
|
270
|
+
}
|
|
271
|
+
.dd-item.selected .dd-item-id {
|
|
272
|
+
color: var(--yellow);
|
|
273
|
+
}
|
|
274
|
+
.dd-item-proj {
|
|
275
|
+
font-size: 11px;
|
|
276
|
+
color: var(--muted);
|
|
277
|
+
overflow: hidden;
|
|
278
|
+
text-overflow: ellipsis;
|
|
279
|
+
white-space: nowrap;
|
|
280
|
+
}
|
|
281
|
+
.dd-empty {
|
|
282
|
+
padding: 20px 12px;
|
|
283
|
+
text-align: center;
|
|
284
|
+
color: var(--muted);
|
|
285
|
+
font-size: 12.5px;
|
|
286
|
+
}
|
|
287
|
+
.dd-divider {
|
|
288
|
+
height: 1px;
|
|
289
|
+
background: var(--border);
|
|
290
|
+
margin: 4px 8px;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
.toggle {
|
|
294
|
+
margin-left: auto;
|
|
295
|
+
display: flex;
|
|
296
|
+
gap: 3px;
|
|
297
|
+
background: var(--surface);
|
|
298
|
+
border: 1px solid var(--border);
|
|
299
|
+
border-radius: var(--radius);
|
|
300
|
+
padding: 3px;
|
|
301
|
+
}
|
|
26
302
|
.toggle button {
|
|
27
|
-
background:
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
303
|
+
background: transparent;
|
|
304
|
+
color: var(--muted);
|
|
305
|
+
border: none;
|
|
306
|
+
padding: 6px 14px;
|
|
307
|
+
border-radius: 7px;
|
|
308
|
+
cursor: pointer;
|
|
309
|
+
font-size: 12.5px;
|
|
310
|
+
font-weight: 500;
|
|
311
|
+
font-family: inherit;
|
|
312
|
+
transition: all 0.2s ease;
|
|
313
|
+
position: relative;
|
|
314
|
+
}
|
|
315
|
+
.toggle button:hover {
|
|
316
|
+
color: var(--body);
|
|
317
|
+
background: var(--card);
|
|
318
|
+
}
|
|
319
|
+
.toggle button.active {
|
|
320
|
+
background: var(--yellow);
|
|
321
|
+
color: #0a0a0a;
|
|
322
|
+
font-weight: 600;
|
|
323
|
+
box-shadow: 0 1px 4px rgba(250, 255, 105, 0.2);
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
#btnOverview {
|
|
327
|
+
background: var(--card);
|
|
328
|
+
color: var(--body);
|
|
329
|
+
border: 1px solid var(--border);
|
|
330
|
+
padding: 6px 14px;
|
|
331
|
+
border-radius: var(--radius-sm);
|
|
332
|
+
cursor: pointer;
|
|
333
|
+
font-size: 12.5px;
|
|
334
|
+
font-weight: 500;
|
|
335
|
+
font-family: inherit;
|
|
336
|
+
transition: all 0.2s ease;
|
|
337
|
+
}
|
|
338
|
+
#btnOverview:hover {
|
|
339
|
+
border-color: var(--border-light);
|
|
340
|
+
color: var(--text);
|
|
341
|
+
background: var(--card-hover);
|
|
342
|
+
}
|
|
343
|
+
#btnOverview.active {
|
|
344
|
+
background: var(--yellow);
|
|
345
|
+
color: #0a0a0a;
|
|
346
|
+
border-color: var(--yellow);
|
|
347
|
+
font-weight: 600;
|
|
348
|
+
box-shadow: 0 1px 4px rgba(250, 255, 105, 0.2);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/* ─── STATS BAR ─── */
|
|
352
|
+
#stats {
|
|
353
|
+
flex: 0 0 auto;
|
|
354
|
+
padding: 10px 24px;
|
|
355
|
+
display: flex;
|
|
356
|
+
gap: 2px;
|
|
357
|
+
flex-wrap: wrap;
|
|
358
|
+
background: var(--surface);
|
|
359
|
+
border-bottom: 1px solid var(--border);
|
|
360
|
+
}
|
|
361
|
+
#stats .stat {
|
|
362
|
+
display: flex;
|
|
363
|
+
flex-direction: column;
|
|
364
|
+
gap: 1px;
|
|
365
|
+
padding: 6px 16px;
|
|
366
|
+
border-radius: var(--radius-sm);
|
|
367
|
+
transition: background 0.2s;
|
|
368
|
+
}
|
|
369
|
+
#stats .stat:hover {
|
|
370
|
+
background: var(--card);
|
|
371
|
+
}
|
|
372
|
+
#stats .stat:first-child {
|
|
373
|
+
padding-left: 0;
|
|
374
|
+
}
|
|
375
|
+
#stats .stat .label {
|
|
376
|
+
color: var(--muted);
|
|
377
|
+
font-size: 10px;
|
|
378
|
+
text-transform: uppercase;
|
|
379
|
+
letter-spacing: 0.8px;
|
|
380
|
+
font-weight: 600;
|
|
381
|
+
}
|
|
382
|
+
#stats .stat .value {
|
|
383
|
+
color: var(--text);
|
|
384
|
+
font-weight: 600;
|
|
385
|
+
font-size: 14px;
|
|
386
|
+
font-variant-numeric: tabular-nums;
|
|
387
|
+
transition: color 0.2s;
|
|
388
|
+
}
|
|
389
|
+
#stats .stat:hover .value {
|
|
390
|
+
color: #fff;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/* ─── GRAPH / MAIN AREA ─── */
|
|
36
394
|
#graph {
|
|
37
|
-
flex: 1 1 auto;
|
|
395
|
+
flex: 1 1 auto;
|
|
396
|
+
min-height: 0;
|
|
397
|
+
width: 100%;
|
|
38
398
|
background-color: var(--canvas);
|
|
39
399
|
background-image:
|
|
40
|
-
|
|
41
|
-
linear-gradient(
|
|
42
|
-
|
|
400
|
+
radial-gradient(circle 600px at 50% 50%, rgba(250, 255, 105, 0.015), transparent),
|
|
401
|
+
linear-gradient(rgba(255, 255, 255, 0.025) 1px, transparent 1px),
|
|
402
|
+
linear-gradient(90deg, rgba(255, 255, 255, 0.025) 1px, transparent 1px);
|
|
403
|
+
background-size: auto, 48px 48px, 48px 48px;
|
|
43
404
|
position: relative;
|
|
405
|
+
overflow: hidden;
|
|
44
406
|
}
|
|
45
407
|
#graph::after {
|
|
46
|
-
content:"";
|
|
47
|
-
position: absolute;
|
|
48
|
-
|
|
408
|
+
content: "";
|
|
409
|
+
position: absolute;
|
|
410
|
+
inset: 0;
|
|
411
|
+
pointer-events: none;
|
|
412
|
+
background: radial-gradient(ellipse at center, rgba(250, 255, 105, 0.03) 0%, transparent 60%);
|
|
49
413
|
}
|
|
50
414
|
#graph canvas { position: relative; z-index: 2; }
|
|
415
|
+
|
|
416
|
+
/* empty state */
|
|
51
417
|
#graph .empty {
|
|
52
|
-
position: absolute;
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
418
|
+
position: absolute;
|
|
419
|
+
inset: 0;
|
|
420
|
+
display: flex;
|
|
421
|
+
flex-direction: column;
|
|
422
|
+
align-items: center;
|
|
423
|
+
justify-content: center;
|
|
424
|
+
gap: 12px;
|
|
425
|
+
z-index: 2;
|
|
426
|
+
text-align: center;
|
|
427
|
+
padding: 24px;
|
|
428
|
+
}
|
|
429
|
+
#graph .empty .icon {
|
|
430
|
+
width: 56px;
|
|
431
|
+
height: 56px;
|
|
432
|
+
opacity: 0.15;
|
|
433
|
+
margin-bottom: 4px;
|
|
434
|
+
}
|
|
435
|
+
#graph .empty h2 { color: var(--text); font-size: 18px; font-weight: 600; }
|
|
436
|
+
#graph .empty p { color: var(--muted); font-size: 13px; max-width: 440px; line-height: 1.6; }
|
|
437
|
+
#graph .empty code {
|
|
438
|
+
background: var(--card);
|
|
439
|
+
border: 1px solid var(--border);
|
|
440
|
+
padding: 3px 8px;
|
|
441
|
+
border-radius: var(--radius-sm);
|
|
442
|
+
color: var(--yellow);
|
|
443
|
+
font-family: 'JetBrains Mono', monospace;
|
|
444
|
+
font-size: 12px;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/* spinner */
|
|
448
|
+
#spinner {
|
|
449
|
+
display: none;
|
|
450
|
+
position: fixed;
|
|
451
|
+
inset: 0;
|
|
452
|
+
z-index: 1000;
|
|
453
|
+
align-items: center;
|
|
454
|
+
justify-content: center;
|
|
455
|
+
background: rgba(8, 8, 10, 0.5);
|
|
456
|
+
backdrop-filter: blur(4px);
|
|
457
|
+
}
|
|
458
|
+
#spinner.on { display: flex; }
|
|
459
|
+
#spinner span {
|
|
460
|
+
color: var(--yellow);
|
|
461
|
+
border: 2.5px solid var(--border);
|
|
462
|
+
border-top-color: var(--yellow);
|
|
463
|
+
border-radius: 50%;
|
|
464
|
+
width: 30px;
|
|
465
|
+
height: 30px;
|
|
466
|
+
animation: spin 0.7s linear infinite;
|
|
467
|
+
}
|
|
61
468
|
@keyframes spin { to { transform: rotate(360deg); } }
|
|
469
|
+
|
|
470
|
+
/* ─── TABLE VIEW ─── */
|
|
471
|
+
.tables {
|
|
472
|
+
height: 100%;
|
|
473
|
+
overflow: auto;
|
|
474
|
+
padding: 24px 28px 40px;
|
|
475
|
+
}
|
|
476
|
+
.tbl-sec {
|
|
477
|
+
margin-bottom: 32px;
|
|
478
|
+
max-width: 920px;
|
|
479
|
+
}
|
|
480
|
+
.tables h3 {
|
|
481
|
+
color: var(--text);
|
|
482
|
+
margin: 0 0 12px;
|
|
483
|
+
font-size: 12px;
|
|
484
|
+
letter-spacing: 1.2px;
|
|
485
|
+
text-transform: uppercase;
|
|
486
|
+
font-weight: 600;
|
|
487
|
+
display: flex;
|
|
488
|
+
align-items: center;
|
|
489
|
+
gap: 8px;
|
|
490
|
+
}
|
|
491
|
+
.tables h3::after {
|
|
492
|
+
content: "";
|
|
493
|
+
flex: 1;
|
|
494
|
+
height: 1px;
|
|
495
|
+
background: var(--border);
|
|
496
|
+
}
|
|
497
|
+
.tables table {
|
|
498
|
+
width: 100%;
|
|
499
|
+
border-collapse: collapse;
|
|
500
|
+
font-size: 13px;
|
|
501
|
+
}
|
|
502
|
+
.tables th {
|
|
503
|
+
text-align: left;
|
|
504
|
+
color: var(--muted);
|
|
505
|
+
font-size: 10.5px;
|
|
506
|
+
text-transform: uppercase;
|
|
507
|
+
letter-spacing: 0.8px;
|
|
508
|
+
font-weight: 600;
|
|
509
|
+
padding: 8px 14px;
|
|
510
|
+
border-bottom: 1px solid var(--border);
|
|
511
|
+
position: sticky;
|
|
512
|
+
top: 0;
|
|
513
|
+
background: var(--canvas);
|
|
514
|
+
z-index: 1;
|
|
515
|
+
}
|
|
516
|
+
.tables td {
|
|
517
|
+
padding: 8px 14px;
|
|
518
|
+
border-bottom: 1px solid var(--border);
|
|
519
|
+
color: var(--body);
|
|
520
|
+
transition: background 0.15s, color 0.15s;
|
|
521
|
+
}
|
|
522
|
+
.tables tbody tr {
|
|
523
|
+
transition: background 0.15s;
|
|
524
|
+
}
|
|
525
|
+
.tables tbody tr:hover {
|
|
526
|
+
background: var(--card);
|
|
527
|
+
}
|
|
528
|
+
.tables tbody tr:hover td {
|
|
529
|
+
color: var(--text);
|
|
530
|
+
}
|
|
531
|
+
.tables th.num, .tables td.num {
|
|
532
|
+
text-align: right;
|
|
533
|
+
font-variant-numeric: tabular-nums;
|
|
534
|
+
}
|
|
535
|
+
.swatch {
|
|
536
|
+
display: inline-block;
|
|
537
|
+
width: 10px;
|
|
538
|
+
height: 10px;
|
|
539
|
+
border-radius: 3px;
|
|
540
|
+
margin-right: 10px;
|
|
541
|
+
vertical-align: middle;
|
|
542
|
+
box-shadow: 0 0 6px rgba(255, 255, 255, 0.05);
|
|
543
|
+
}
|
|
544
|
+
.bar {
|
|
545
|
+
display: inline-block;
|
|
546
|
+
height: 6px;
|
|
547
|
+
border-radius: 3px;
|
|
548
|
+
min-width: 2px;
|
|
549
|
+
vertical-align: middle;
|
|
550
|
+
transition: width 0.4s ease;
|
|
551
|
+
}
|
|
552
|
+
.tables .muted, .ov-list .muted { color: var(--muted); }
|
|
553
|
+
|
|
554
|
+
/* ─── OVERVIEW ─── */
|
|
555
|
+
.ov-wrap { height: 100%; display: flex; }
|
|
556
|
+
.ov-graph { flex: 1 1 auto; min-width: 0; position: relative; }
|
|
557
|
+
.ov-list {
|
|
558
|
+
flex: 0 0 420px;
|
|
559
|
+
overflow: auto;
|
|
560
|
+
padding: 24px;
|
|
561
|
+
border-left: 1px solid var(--border);
|
|
562
|
+
background: var(--surface);
|
|
563
|
+
}
|
|
564
|
+
.ov-list h3 {
|
|
565
|
+
color: var(--text);
|
|
566
|
+
margin: 0 0 16px;
|
|
567
|
+
font-size: 12px;
|
|
568
|
+
letter-spacing: 1.2px;
|
|
569
|
+
text-transform: uppercase;
|
|
570
|
+
font-weight: 600;
|
|
571
|
+
display: flex;
|
|
572
|
+
align-items: center;
|
|
573
|
+
gap: 8px;
|
|
574
|
+
}
|
|
575
|
+
.ov-list h3::after {
|
|
576
|
+
content: "";
|
|
577
|
+
flex: 1;
|
|
578
|
+
height: 1px;
|
|
579
|
+
background: var(--border);
|
|
580
|
+
}
|
|
581
|
+
.ov-list th {
|
|
582
|
+
text-align: left;
|
|
583
|
+
color: var(--muted);
|
|
584
|
+
font-size: 10.5px;
|
|
585
|
+
text-transform: uppercase;
|
|
586
|
+
letter-spacing: 0.8px;
|
|
587
|
+
font-weight: 600;
|
|
588
|
+
padding: 8px 12px;
|
|
589
|
+
border-bottom: 1px solid var(--border);
|
|
590
|
+
position: sticky;
|
|
591
|
+
top: 0;
|
|
592
|
+
background: var(--surface);
|
|
593
|
+
z-index: 1;
|
|
594
|
+
}
|
|
595
|
+
.ov-list td {
|
|
596
|
+
padding: 10px 12px;
|
|
597
|
+
border-bottom: 1px solid var(--border);
|
|
598
|
+
color: var(--body);
|
|
599
|
+
font-size: 13px;
|
|
600
|
+
transition: background 0.15s, color 0.15s;
|
|
601
|
+
}
|
|
602
|
+
.ov-list th.num, .ov-list td.num {
|
|
603
|
+
text-align: right;
|
|
604
|
+
font-variant-numeric: tabular-nums;
|
|
605
|
+
}
|
|
606
|
+
.ov-list .hint {
|
|
607
|
+
color: var(--muted);
|
|
608
|
+
font-size: 11.5px;
|
|
609
|
+
margin: 16px 0 0;
|
|
610
|
+
padding: 0 12px;
|
|
611
|
+
line-height: 1.5;
|
|
612
|
+
}
|
|
613
|
+
.project-row {
|
|
614
|
+
cursor: pointer;
|
|
615
|
+
transition: background 0.15s;
|
|
616
|
+
}
|
|
617
|
+
.project-row:hover td {
|
|
618
|
+
background: var(--card-hover);
|
|
619
|
+
color: var(--text);
|
|
620
|
+
}
|
|
621
|
+
.proj-name {
|
|
622
|
+
color: var(--text);
|
|
623
|
+
font-family: 'JetBrains Mono', monospace;
|
|
624
|
+
font-size: 12px;
|
|
625
|
+
font-weight: 500;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/* ─── GLOW CURSOR EFFECT ─── */
|
|
62
629
|
#glow {
|
|
63
|
-
position: fixed;
|
|
64
|
-
|
|
65
|
-
|
|
630
|
+
position: fixed;
|
|
631
|
+
inset: 0;
|
|
632
|
+
pointer-events: none;
|
|
633
|
+
z-index: 9999;
|
|
634
|
+
background: radial-gradient(circle 180px at var(--gx, -9999px) var(--gy, -9999px),
|
|
635
|
+
rgba(250, 255, 105, 0.07), rgba(250, 255, 105, 0.02) 50%, transparent 75%);
|
|
66
636
|
opacity: 0;
|
|
637
|
+
transition: opacity 0.4s ease;
|
|
67
638
|
}
|
|
68
639
|
#glow.on { opacity: 1; }
|
|
640
|
+
|
|
641
|
+
/* ─── VIEW TRANSITIONS ─── */
|
|
642
|
+
.fade-in { animation: fadeIn 0.25s ease; }
|
|
643
|
+
@keyframes fadeIn {
|
|
644
|
+
from { opacity: 0; transform: translateY(4px); }
|
|
645
|
+
to { opacity: 1; transform: translateY(0); }
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
/* ─── SCROLLBAR ─── */
|
|
649
|
+
::-webkit-scrollbar { width: 6px; height: 6px; }
|
|
650
|
+
::-webkit-scrollbar-track { background: transparent; }
|
|
651
|
+
::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 3px; }
|
|
652
|
+
::-webkit-scrollbar-thumb:hover { background: var(--muted); }
|
|
69
653
|
</style>
|
|
70
654
|
</head>
|
|
71
655
|
<body>
|
|
72
656
|
<header>
|
|
73
|
-
<
|
|
74
|
-
|
|
75
|
-
|
|
657
|
+
<div class="logo">
|
|
658
|
+
<img src="images/icon.svg" alt="" />
|
|
659
|
+
<h1>Graphtrack</h1>
|
|
660
|
+
</div>
|
|
661
|
+
<button id="btnOverview" title="All sessions at a glance">Overview</button>
|
|
662
|
+
<div class="dropdown" id="sessionDropdown">
|
|
663
|
+
<button class="dropdown-trigger disabled" id="sessionTrigger">
|
|
664
|
+
<span class="dd-label placeholder">Loading sessions…</span>
|
|
665
|
+
<svg class="dd-chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>
|
|
666
|
+
</button>
|
|
667
|
+
<div class="dropdown-panel">
|
|
668
|
+
<div class="dd-search">
|
|
669
|
+
<svg class="dd-search-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>
|
|
670
|
+
<input type="text" id="ddSearch" placeholder="Search sessions…" autocomplete="off" />
|
|
671
|
+
</div>
|
|
672
|
+
<div class="dd-list" id="ddList"></div>
|
|
673
|
+
</div>
|
|
674
|
+
</div>
|
|
76
675
|
<div class="toggle">
|
|
77
676
|
<button id="btnAgg" class="active">Aggregate</button>
|
|
78
677
|
<button id="btnChain">Chronological</button>
|
|
678
|
+
<button id="btnTable">Table</button>
|
|
79
679
|
</div>
|
|
80
680
|
</header>
|
|
81
681
|
<div id="stats">
|
|
682
|
+
<div class="stat"><span class="label">Started</span><span class="value" id="sStarted">–</span></div>
|
|
683
|
+
<div class="stat"><span class="label">Duration</span><span class="value" id="sDur">–</span></div>
|
|
82
684
|
<div class="stat"><span class="label">Model</span><span class="value" id="sModel">–</span></div>
|
|
83
685
|
<div class="stat"><span class="label">Turns</span><span class="value" id="sTurns">–</span></div>
|
|
84
686
|
<div class="stat"><span class="label">Tool calls</span><span class="value" id="sCalls">–</span></div>
|
|
@@ -88,17 +690,40 @@
|
|
|
88
690
|
<div class="stat"><span class="label">Cache read</span><span class="value" id="sRead">–</span></div>
|
|
89
691
|
<div class="stat"><span class="label">Cache write</span><span class="value" id="sWrite">–</span></div>
|
|
90
692
|
</div>
|
|
91
|
-
<div id="graph"
|
|
693
|
+
<div id="graph"></div>
|
|
694
|
+
<div id="spinner"><span></span></div>
|
|
92
695
|
<div id="glow"></div>
|
|
93
696
|
<script>
|
|
94
697
|
const $ = (id) => document.getElementById(id);
|
|
95
698
|
const fmt = (n) => n == null ? "–" : n >= 1e6 ? (n/1e6).toFixed(1)+"M" : n >= 1e3 ? (n/1e3).toFixed(1)+"k" : n;
|
|
699
|
+
const fmtTime = (iso) => iso ? new Date(iso).toLocaleString([], { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }) : "–";
|
|
700
|
+
const fmtDur = (ms) => {
|
|
701
|
+
if (ms == null) return "–";
|
|
702
|
+
const s = Math.round(ms / 1000);
|
|
703
|
+
if (s < 60) return s + "s";
|
|
704
|
+
const m = Math.floor(s / 60), r = s % 60;
|
|
705
|
+
return m < 60 ? m + "m " + r + "s" : Math.floor(m / 60) + "h " + (m % 60) + "m";
|
|
706
|
+
};
|
|
707
|
+
function escapeHtml(s) {
|
|
708
|
+
return String(s).replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
709
|
+
}
|
|
710
|
+
function truncate(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + "…" : s; }
|
|
96
711
|
|
|
97
|
-
|
|
712
|
+
/* ─── Custom dropdown state ─── */
|
|
713
|
+
let sessions = [];
|
|
714
|
+
let selectedSession = null;
|
|
715
|
+
let activeFilter = null; // project slug or null
|
|
716
|
+
let ddOpen = false;
|
|
717
|
+
|
|
718
|
+
const dropdown = $("sessionDropdown");
|
|
719
|
+
const ddTrigger = $("sessionTrigger");
|
|
720
|
+
const ddList = $("ddList");
|
|
721
|
+
const ddSearch = $("ddSearch");
|
|
98
722
|
const spinner = $("spinner");
|
|
99
723
|
const container = $("graph");
|
|
100
|
-
let current = null;
|
|
101
|
-
let
|
|
724
|
+
let current = null;
|
|
725
|
+
let overview = null;
|
|
726
|
+
let view = "overview";
|
|
102
727
|
let network = null;
|
|
103
728
|
|
|
104
729
|
const glow = document.getElementById("glow");
|
|
@@ -112,28 +737,37 @@
|
|
|
112
737
|
|
|
113
738
|
const options = {
|
|
114
739
|
physics: { enabled: true, solver: "forceAtlas2Based", stabilization: { iterations: 200 } },
|
|
115
|
-
interaction: { hover: true, tooltipDelay: 200 },
|
|
116
|
-
nodes: {
|
|
117
|
-
|
|
740
|
+
interaction: { hover: true, tooltipDelay: 200, zoomView: true, dragView: true },
|
|
741
|
+
nodes: {
|
|
742
|
+
shape: "dot",
|
|
743
|
+
font: { color: "#a0a0ac", size: 12, face: "Inter, system-ui, sans-serif" },
|
|
744
|
+
widthConstraint: { maximum: 140 },
|
|
745
|
+
borderWidth: 2,
|
|
746
|
+
borderWidthSelected: 3,
|
|
747
|
+
shadow: false,
|
|
748
|
+
},
|
|
749
|
+
edges: {
|
|
750
|
+
color: { color: "#25252a", highlight: "#faff69", hover: "#4a4a52" },
|
|
751
|
+
smooth: { enabled: true, type: "dynamic" },
|
|
752
|
+
width: 1.2,
|
|
753
|
+
shadow: false,
|
|
754
|
+
},
|
|
118
755
|
};
|
|
119
756
|
|
|
120
|
-
function
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
if (network) network.destroy();
|
|
124
|
-
network = new vis.Network(container, { nodes: new vis.DataSet(data.nodes), edges: new vis.DataSet(data.edges) }, options);
|
|
757
|
+
function showSpinner(on) { spinner.classList.toggle("on", on); }
|
|
758
|
+
function setViewActive(active) {
|
|
759
|
+
["btnAgg", "btnChain", "btnTable", "btnOverview"].forEach((id) => $(id).classList.toggle("active", id === active));
|
|
125
760
|
}
|
|
126
|
-
|
|
127
|
-
function
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
if (title) { container.querySelector("h2").textContent = title; container.querySelector("p").textContent = body; }
|
|
132
|
-
// reattach glow/spinner refs are fixed; spinner is a child now
|
|
133
|
-
spinner.classList.remove("on");
|
|
761
|
+
function unmountGraph() { if (network) { network.destroy(); network = null; } }
|
|
762
|
+
function mountGraph(data, el) {
|
|
763
|
+
unmountGraph();
|
|
764
|
+
const target = el || container;
|
|
765
|
+
network = new vis.Network(target, { nodes: new vis.DataSet(data.nodes), edges: new vis.DataSet(data.edges) }, options);
|
|
134
766
|
}
|
|
135
767
|
|
|
136
768
|
function fillStats(s) {
|
|
769
|
+
$("sStarted").textContent = fmtTime(s.startedAt);
|
|
770
|
+
$("sDur").textContent = fmtDur(s.durationMs);
|
|
137
771
|
$("sModel").textContent = s.model;
|
|
138
772
|
$("sTurns").textContent = s.turns;
|
|
139
773
|
$("sCalls").textContent = s.toolCalls;
|
|
@@ -144,59 +778,308 @@
|
|
|
144
778
|
$("sWrite").textContent = fmt(s.totalCacheWrite);
|
|
145
779
|
}
|
|
146
780
|
|
|
781
|
+
function showEmpty(title, body) {
|
|
782
|
+
unmountGraph();
|
|
783
|
+
container.innerHTML = '<div class="empty">' +
|
|
784
|
+
'<img src="images/icon.svg" class="icon" alt="" />' +
|
|
785
|
+
(title ? "<h2></h2><p></p>" : "") + '</div>';
|
|
786
|
+
if (title) { container.querySelector("h2").textContent = title; container.querySelector("p").textContent = body; }
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// ─── session views ───
|
|
790
|
+
|
|
791
|
+
function render() {
|
|
792
|
+
if (!current) { showEmpty("No session selected", "Pick a session from the dropdown, or open Overview for all sessions."); return; }
|
|
793
|
+
if (view === "table") { renderTable(); return; }
|
|
794
|
+
mountGraph(view === "chronological" ? current.chronological : current.aggregate);
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function renderTable() {
|
|
798
|
+
unmountGraph();
|
|
799
|
+
const tools = current.toolAggregates || [];
|
|
800
|
+
const turns = current.turns || [];
|
|
801
|
+
const toolMax = Math.max(1, ...tools.map((t) => t.totalTokens));
|
|
802
|
+
const toolRows = tools.map((t) => `
|
|
803
|
+
<tr>
|
|
804
|
+
<td><span class="swatch" style="background:${t.color}"></span>${escapeHtml(t.name)}</td>
|
|
805
|
+
<td class="num">${t.calls}</td>
|
|
806
|
+
<td class="num">${fmt(t.totalTokens)}</td>
|
|
807
|
+
<td><div class="bar" style="width:${Math.max(2, Math.round(t.totalTokens / toolMax * 100))}%;background:${t.color}"></div></td>
|
|
808
|
+
</tr>`).join("");
|
|
809
|
+
const turnRows = turns.map((t) => `
|
|
810
|
+
<tr>
|
|
811
|
+
<td class="num">${t.index + 1}</td>
|
|
812
|
+
<td title="${escapeHtml(t.prompt)}">${escapeHtml(truncate(t.prompt, 64))}</td>
|
|
813
|
+
<td class="num">${t.toolCalls}</td>
|
|
814
|
+
<td class="num">${fmt(t.totalTokens)}</td>
|
|
815
|
+
</tr>`).join("");
|
|
816
|
+
container.innerHTML = `
|
|
817
|
+
<div class="tables fade-in">
|
|
818
|
+
<section class="tbl-sec">
|
|
819
|
+
<h3>Tool share</h3>
|
|
820
|
+
<table>
|
|
821
|
+
<thead><tr><th>Tool</th><th class="num">Calls</th><th class="num">Tokens</th><th style="width:34%">Share</th></tr></thead>
|
|
822
|
+
<tbody>${toolRows || '<tr><td colspan="4" class="muted">No tool calls in this session.</td></tr>'}</tbody>
|
|
823
|
+
</table>
|
|
824
|
+
</section>
|
|
825
|
+
<section class="tbl-sec">
|
|
826
|
+
<h3>Turns</h3>
|
|
827
|
+
<table>
|
|
828
|
+
<thead><tr><th class="num">#</th><th>Prompt</th><th class="num">Calls</th><th class="num">Tokens</th></tr></thead>
|
|
829
|
+
<tbody>${turnRows || '<tr><td colspan="4" class="muted">No turns in this session.</td></tr>'}</tbody>
|
|
830
|
+
</table>
|
|
831
|
+
</section>
|
|
832
|
+
</div>`;
|
|
833
|
+
}
|
|
834
|
+
|
|
147
835
|
async function loadSession(slug, id) {
|
|
148
|
-
|
|
836
|
+
showSpinner(true);
|
|
149
837
|
try {
|
|
150
838
|
const r = await fetch("/api/session/" + encodeURIComponent(slug) + "/" + encodeURIComponent(id));
|
|
151
839
|
if (!r.ok) throw new Error("no data");
|
|
152
840
|
current = await r.json();
|
|
841
|
+
lastTokens = current.session.totalTokens;
|
|
153
842
|
fillStats(current.session);
|
|
154
|
-
|
|
843
|
+
view = "aggregate";
|
|
844
|
+
setViewActive("btnAgg");
|
|
155
845
|
render();
|
|
156
846
|
} catch (e) {
|
|
157
|
-
spinner.classList.remove("on");
|
|
158
847
|
showEmpty("Could not load session", "The server couldn't parse this session file. It may be malformed or the API returned no data.");
|
|
848
|
+
} finally {
|
|
849
|
+
showSpinner(false);
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// ─── overview ───
|
|
854
|
+
|
|
855
|
+
function overviewStats(o) {
|
|
856
|
+
return {
|
|
857
|
+
model: "All sessions",
|
|
858
|
+
startedAt: "",
|
|
859
|
+
durationMs: null,
|
|
860
|
+
turns: o.totals.sessions,
|
|
861
|
+
toolCalls: o.toolAggregates.reduce((a, t) => a + t.calls, 0),
|
|
862
|
+
totalTokens: o.totals.totalTokens,
|
|
863
|
+
totalInput: o.totals.totalInput,
|
|
864
|
+
totalOutput: o.totals.totalOutput,
|
|
865
|
+
totalCacheRead: o.totals.totalCacheRead,
|
|
866
|
+
totalCacheWrite: o.totals.totalCacheWrite,
|
|
867
|
+
};
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
function filterSessions(project) {
|
|
871
|
+
activeFilter = project;
|
|
872
|
+
renderDdList();
|
|
873
|
+
// Auto-select first matching session
|
|
874
|
+
const filtered = getFilteredSessions();
|
|
875
|
+
if (filtered.length > 0) {
|
|
876
|
+
selectSession(filtered[0]);
|
|
877
|
+
}
|
|
878
|
+
}
|
|
879
|
+
|
|
880
|
+
function getFilteredSessions() {
|
|
881
|
+
let list = sessions.filter((s) => s.project !== "subagents");
|
|
882
|
+
if (activeFilter) list = list.filter((s) => s.project === activeFilter);
|
|
883
|
+
const q = ddSearch.value.trim().toLowerCase();
|
|
884
|
+
if (q) list = list.filter((s) => s.id.toLowerCase().includes(q) || s.project.toLowerCase().includes(q));
|
|
885
|
+
return list;
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
function selectSession(s) {
|
|
889
|
+
selectedSession = s;
|
|
890
|
+
const label = ddTrigger.querySelector(".dd-label");
|
|
891
|
+
label.classList.remove("placeholder");
|
|
892
|
+
label.textContent = s.project + " · " + s.id;
|
|
893
|
+
closeDropdown();
|
|
894
|
+
loadSession(s.project, s.id);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function openDropdown() {
|
|
898
|
+
ddOpen = true;
|
|
899
|
+
dropdown.classList.add("open");
|
|
900
|
+
ddSearch.value = "";
|
|
901
|
+
renderDdList();
|
|
902
|
+
requestAnimationFrame(() => ddSearch.focus());
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
function closeDropdown() {
|
|
906
|
+
ddOpen = false;
|
|
907
|
+
dropdown.classList.remove("open");
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
function toggleDropdown() {
|
|
911
|
+
ddOpen ? closeDropdown() : openDropdown();
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function renderDdList() {
|
|
915
|
+
const filtered = getFilteredSessions();
|
|
916
|
+
if (filtered.length === 0) {
|
|
917
|
+
ddList.innerHTML = '<div class="dd-empty">No sessions found</div>';
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
// Group by project
|
|
921
|
+
const groups = new Map();
|
|
922
|
+
for (const s of filtered) {
|
|
923
|
+
if (!groups.has(s.project)) groups.set(s.project, []);
|
|
924
|
+
groups.get(s.project).push(s);
|
|
925
|
+
}
|
|
926
|
+
let html = "";
|
|
927
|
+
for (const [proj, items] of groups) {
|
|
928
|
+
if (groups.size > 1) {
|
|
929
|
+
html += `<div class="dd-group-label">${escapeHtml(truncate(proj, 36))}</div>`;
|
|
930
|
+
}
|
|
931
|
+
for (const s of items) {
|
|
932
|
+
const sel = selectedSession && selectedSession.id === s.id ? " selected" : "";
|
|
933
|
+
html += `<div class="dd-item${sel}" data-id="${escapeHtml(s.id)}" data-project="${escapeHtml(s.project)}">
|
|
934
|
+
<span class="dd-dot"></span>
|
|
935
|
+
<div class="dd-item-info">
|
|
936
|
+
<span class="dd-item-id">${escapeHtml(s.id)}</span>
|
|
937
|
+
<span class="dd-item-proj">${escapeHtml(s.project)}</span>
|
|
938
|
+
</div>
|
|
939
|
+
</div>`;
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
ddList.innerHTML = html;
|
|
943
|
+
ddList.querySelectorAll(".dd-item").forEach((el) =>
|
|
944
|
+
el.addEventListener("click", (e) => {
|
|
945
|
+
e.stopPropagation();
|
|
946
|
+
selectSession({ id: el.dataset.id, project: el.dataset.project });
|
|
947
|
+
})
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
// Close on outside click
|
|
952
|
+
document.addEventListener("click", (e) => {
|
|
953
|
+
if (ddOpen && !dropdown.contains(e.target)) closeDropdown();
|
|
954
|
+
});
|
|
955
|
+
// Close on Escape
|
|
956
|
+
document.addEventListener("keydown", (e) => {
|
|
957
|
+
if (e.key === "Escape" && ddOpen) closeDropdown();
|
|
958
|
+
});
|
|
959
|
+
|
|
960
|
+
function renderOverview() {
|
|
961
|
+
unmountGraph();
|
|
962
|
+
const o = overview;
|
|
963
|
+
if (!o) { showEmpty("No overview data", "The server didn't return an overview."); return; }
|
|
964
|
+
fillStats(overviewStats(o));
|
|
965
|
+
o.projects = o.projects.filter((p) => p.project !== "subagents");
|
|
966
|
+
const projMax = Math.max(1, ...o.projects.map((p) => p.totalTokens));
|
|
967
|
+
const total = o.totals.totalTokens || 1;
|
|
968
|
+
const projRows = o.projects.map((p, i) => `
|
|
969
|
+
<tr class="project-row" data-project="${escapeHtml(p.project)}" style="animation-delay:${i * 30}ms">
|
|
970
|
+
<td class="proj-name">${escapeHtml(p.project)}</td>
|
|
971
|
+
<td class="num">${p.sessions}</td>
|
|
972
|
+
<td class="num">${fmt(p.totalTokens)}</td>
|
|
973
|
+
<td class="num">${Math.round(p.totalTokens / total * 100)}%</td>
|
|
974
|
+
<td><div class="bar" style="width:${Math.max(2, Math.round(p.totalTokens / projMax * 100))}%;background:var(--yellow)"></div></td>
|
|
975
|
+
</tr>`).join("");
|
|
976
|
+
container.innerHTML = `
|
|
977
|
+
<div class="ov-wrap fade-in">
|
|
978
|
+
<div class="ov-graph" id="ovGraph"></div>
|
|
979
|
+
<div class="ov-list">
|
|
980
|
+
<h3>Projects · ${o.totals.sessions} sessions</h3>
|
|
981
|
+
<table>
|
|
982
|
+
<thead><tr><th>Project</th><th class="num">Sess</th><th class="num">Tokens</th><th class="num">%</th><th style="width:30%"></th></tr></thead>
|
|
983
|
+
<tbody>${projRows || '<tr><td colspan="5" class="muted">No sessions found.</td></tr>'}</tbody>
|
|
984
|
+
</table>
|
|
985
|
+
<p class="hint">Click a project to filter sessions in the dropdown above.</p>
|
|
986
|
+
</div>
|
|
987
|
+
</div>`;
|
|
988
|
+
container.querySelectorAll(".project-row").forEach((tr) =>
|
|
989
|
+
tr.addEventListener("click", () => filterSessions(tr.dataset.project)));
|
|
990
|
+
const hub = o.graph || { nodes: [{ id: "session", label: "All sessions", value: 6, color: { background: "#faff69" } }], edges: [] };
|
|
991
|
+
if ($("ovGraph")) mountGraph(hub, $("ovGraph"));
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
async function loadOverview() {
|
|
995
|
+
showSpinner(true);
|
|
996
|
+
try {
|
|
997
|
+
const r = await fetch("/api/overview");
|
|
998
|
+
overview = await r.json();
|
|
999
|
+
view = "overview";
|
|
1000
|
+
setViewActive("btnOverview");
|
|
1001
|
+
renderOverview();
|
|
1002
|
+
} catch (e) {
|
|
1003
|
+
showEmpty("Can't reach the Graphtrack server", "Start it with: graphtrack — this page must be served from http://localhost:9090.");
|
|
1004
|
+
} finally {
|
|
1005
|
+
showSpinner(false);
|
|
159
1006
|
}
|
|
160
1007
|
}
|
|
161
1008
|
|
|
1009
|
+
// ─── boot ───
|
|
1010
|
+
|
|
162
1011
|
async function loadSessions() {
|
|
163
|
-
spinner.classList.add("on");
|
|
164
1012
|
try {
|
|
165
1013
|
const r = await fetch("/api/sessions");
|
|
166
1014
|
const data = await r.json();
|
|
167
|
-
spinner.classList.remove("on");
|
|
168
1015
|
if (!data.sessions || data.sessions.length === 0) {
|
|
169
1016
|
showEmpty("No Claude Code sessions found",
|
|
170
1017
|
"Graphtrack reads session logs from " + data.logsDir + ". Run Claude Code a few times, then restart with: graphtrack --dir <path> if your logs live elsewhere.");
|
|
171
1018
|
return;
|
|
172
1019
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
sessionSelect.appendChild(opt);
|
|
181
|
-
});
|
|
182
|
-
const first = data.sessions[0];
|
|
183
|
-
loadSession(first.project, first.id);
|
|
1020
|
+
sessions = data.sessions;
|
|
1021
|
+
ddTrigger.classList.remove("disabled");
|
|
1022
|
+
const label = ddTrigger.querySelector(".dd-label");
|
|
1023
|
+
label.classList.remove("placeholder");
|
|
1024
|
+
label.textContent = sessions.length + " sessions";
|
|
1025
|
+
ddTrigger.removeAttribute("disabled");
|
|
1026
|
+
loadOverview();
|
|
184
1027
|
} catch (e) {
|
|
185
|
-
spinner.classList.remove("on");
|
|
186
1028
|
showEmpty("Can't reach the Graphtrack server",
|
|
187
1029
|
"Start it with: graphtrack — this page must be served from http://localhost:9090.");
|
|
188
1030
|
}
|
|
189
1031
|
}
|
|
190
1032
|
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
1033
|
+
ddTrigger.addEventListener("click", (e) => {
|
|
1034
|
+
e.stopPropagation();
|
|
1035
|
+
toggleDropdown();
|
|
1036
|
+
});
|
|
1037
|
+
ddSearch.addEventListener("input", renderDdList);
|
|
1038
|
+
ddSearch.addEventListener("click", (e) => e.stopPropagation());
|
|
1039
|
+
$("btnOverview").addEventListener("click", () => {
|
|
1040
|
+
view = "overview";
|
|
1041
|
+
setViewActive("btnOverview");
|
|
1042
|
+
if (!overview) loadOverview(); else renderOverview();
|
|
194
1043
|
});
|
|
195
|
-
$("btnAgg").addEventListener("click", () => { view = "aggregate";
|
|
196
|
-
$("btnChain").addEventListener("click", () => { view = "chronological";
|
|
197
|
-
|
|
1044
|
+
$("btnAgg").addEventListener("click", () => { view = "aggregate"; setViewActive("btnAgg"); render(); });
|
|
1045
|
+
$("btnChain").addEventListener("click", () => { view = "chronological"; setViewActive("btnChain"); render(); });
|
|
1046
|
+
$("btnTable").addEventListener("click", () => { view = "table"; setViewActive("btnTable"); if (!current) render(); else renderTable(); });
|
|
1047
|
+
|
|
1048
|
+
// ─── auto-refresh every 10s ───
|
|
1049
|
+
let lastTokens = null;
|
|
1050
|
+
async function refreshData() {
|
|
1051
|
+
try {
|
|
1052
|
+
// Refresh session list silently
|
|
1053
|
+
const r = await fetch("/api/sessions");
|
|
1054
|
+
const data = await r.json();
|
|
1055
|
+
if (data.sessions) sessions = data.sessions;
|
|
1056
|
+
|
|
1057
|
+
// Refresh current view — only re-render if data actually changed
|
|
1058
|
+
if (view === "overview") {
|
|
1059
|
+
const ov = await fetch("/api/overview");
|
|
1060
|
+
const newOverview = await ov.json();
|
|
1061
|
+
if (newOverview.totals.totalTokens !== (overview?.totals?.totalTokens)) {
|
|
1062
|
+
overview = newOverview;
|
|
1063
|
+
renderOverview();
|
|
1064
|
+
}
|
|
1065
|
+
} else if (current && selectedSession) {
|
|
1066
|
+
const sr = await fetch("/api/session/" + encodeURIComponent(selectedSession.project) + "/" + encodeURIComponent(selectedSession.id));
|
|
1067
|
+
if (sr.ok) {
|
|
1068
|
+
const newData = await sr.json();
|
|
1069
|
+
const newTotal = newData.session.totalTokens;
|
|
1070
|
+
if (newTotal !== lastTokens) {
|
|
1071
|
+
lastTokens = newTotal;
|
|
1072
|
+
current = newData;
|
|
1073
|
+
fillStats(current.session);
|
|
1074
|
+
render();
|
|
1075
|
+
}
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
} catch { /* server may be restarting — skip this tick */ }
|
|
1079
|
+
}
|
|
1080
|
+
setInterval(refreshData, 10000);
|
|
198
1081
|
|
|
199
1082
|
loadSessions();
|
|
200
1083
|
</script>
|
|
201
1084
|
</body>
|
|
202
|
-
</html>
|
|
1085
|
+
</html>
|