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/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:#0a0a0a; --card:#1a1a1a; --yellow:#faff69; --body:#cccccc; --muted:#888888;
12
- }
13
- * { box-sizing: border-box; }
14
- html, body { height:100%; }
15
- body { margin:0; font-family: Inter, system-ui, sans-serif; background:var(--canvas); color:var(--body); display:flex; flex-direction:column; }
16
- header { padding:12px 28px; border-bottom:1px solid #2a2a2a; display:flex; align-items:center; gap:18px; flex: 0 0 auto; flex-wrap:wrap; }
17
- header h1 { font-size:18px; margin:0; color:#fff; letter-spacing:-0.5px; font-weight:700; }
18
- header .dot { color:var(--yellow); margin-right:2px; }
19
- header .sub { font-size:12px; color:var(--muted); }
20
- select {
21
- background:var(--card); color:var(--body); border:1px solid #3a3a3a;
22
- padding:6px 10px; border-radius:6px; font-size:13px; max-width:320px;
23
- }
24
- select:disabled { opacity:0.5; }
25
- .toggle { margin-left:auto; display:flex; gap:8px; }
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:var(--card); color:var(--body); border:1px solid #3a3a3a;
28
- padding:6px 12px; border-radius:6px; cursor:pointer; font-size:13px;
29
- }
30
- .toggle button:hover { border-color:#5a5a5a; }
31
- .toggle button.active { background:var(--yellow); color:#0a0a0a; border-color:var(--yellow); font-weight:600; }
32
- #stats { flex: 0 0 auto; padding:12px 28px; display:flex; gap:28px; flex-wrap:wrap; font-size:13px; border-bottom:1px solid #2a2a2a; }
33
- #stats .stat { display:flex; flex-direction:column; gap:2px; }
34
- #stats .stat .label { color:var(--muted); font-size:11px; text-transform:uppercase; letter-spacing:0.5px; }
35
- #stats .stat .value { color:#fff; font-weight:600; font-size:14px; }
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; min-height:0; width:100%;
395
+ flex: 1 1 auto;
396
+ min-height: 0;
397
+ width: 100%;
38
398
  background-color: var(--canvas);
39
399
  background-image:
40
- linear-gradient(rgba(255,255,255,0.035) 1px, transparent 1px),
41
- linear-gradient(90deg, rgba(255,255,255,0.035) 1px, transparent 1px);
42
- background-size: 42px 42px;
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; inset: 0; pointer-events: none;
48
- background: radial-gradient(ellipse at center, rgba(255,250,105,0.04) 0%, transparent 55%);
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; inset:0; display:flex; flex-direction:column; align-items:center; justify-content:center;
53
- gap:8px; z-index:2; text-align:center; padding:24px;
54
- }
55
- #graph .empty h2 { color:#fff; margin:0; font-size:20px; }
56
- #graph .empty p { color:var(--muted); margin:0; font-size:13px; max-width:460px; line-height:1.5; }
57
- #graph .empty code { background:var(--card); padding:2px 6px; border-radius:4px; color:var(--yellow); }
58
- #spinner { display:none; position:absolute; inset:0; z-index:3; align-items:center; justify-content:center; }
59
- #spinner.on { display:flex; }
60
- #spinner span { color:var(--yellow); border:2px solid #3a3a3a; border-top-color:var(--yellow); border-radius:50%; width:34px; height:34px; animation:spin 0.8s linear infinite; }
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; inset: 0; pointer-events: none; z-index: 9999;
64
- background: radial-gradient(circle 200px at var(--gx, -9999px) var(--gy, -9999px),
65
- rgba(255,250,105,0.16), rgba(255,250,105,0.05) 45%, transparent 72%);
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
- <h1><span class="dot">▪</span>Graphtrack</h1>
74
- <span class="sub">Claude Code token usage, as a graph</span>
75
- <select id="sessionSelect" disabled title="Select a session"><option>Loading sessions…</option></select>
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"><div id="spinner"><span></span></div></div>
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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
709
+ }
710
+ function truncate(s, n) { s = String(s); return s.length > n ? s.slice(0, n - 1) + "…" : s; }
96
711
 
97
- const sessionSelect = $("sessionSelect");
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; // { session, aggregate, chronological, toolAggregates }
101
- let view = "aggregate";
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: { shape: "dot", font: { color: "#cccccc", size: 13 }, widthConstraint: { maximum: 140 } },
117
- edges: { color: { color: "#3a3a3a", highlight: "#faff69" }, smooth: { enabled: true, type: "dynamic" } },
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 render() {
121
- if (!current) return;
122
- const data = view === "aggregate" ? current.aggregate : current.chronological;
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 showEmpty(title, body) {
128
- if (network) network.destroy();
129
- network = null;
130
- container.innerHTML = '<div class="empty">' + (title ? "<h2></h2><p></p>" : "") + '</div><div id="spinner"><span></span></div>';
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
- spinner.classList.add("on");
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
- spinner.classList.remove("on");
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
- sessionSelect.disabled = false;
174
- sessionSelect.innerHTML = "";
175
- data.sessions.forEach((s) => {
176
- const opt = document.createElement("option");
177
- opt.value = s.id;
178
- opt.dataset.slug = s.project;
179
- opt.textContent = s.project + " · " + s.id;
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
- sessionSelect.addEventListener("change", () => {
192
- const opt = sessionSelect.selectedOptions[0];
193
- if (opt) loadSession(opt.dataset.slug, opt.value);
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"; setActive("btnAgg"); render(); });
196
- $("btnChain").addEventListener("click", () => { view = "chronological"; setActive("btnChain"); render(); });
197
- function setActive(id) { $("btnAgg").classList.toggle("active", id==="btnAgg"); $("btnChain").classList.toggle("active", id==="btnChain"); }
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>