@forinda/kickjs-devtools 2.0.1 → 2.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.
@@ -26,12 +26,18 @@
26
26
  <header class="border-b border-slate-800 px-6 py-4">
27
27
  <div class="flex items-center justify-between">
28
28
  <div>
29
- <h1 class="text-2xl font-bold text-kick-500">⚡ KickJS DevTools</h1>
29
+ <h1 class="text-2xl font-bold text-kick-500">&#9889; KickJS DevTools</h1>
30
30
  <p class="text-sm text-slate-500">Development introspection dashboard</p>
31
31
  </div>
32
32
  <div class="flex items-center gap-3 text-sm text-slate-500">
33
- <span class="inline-block w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
34
- Auto-refresh {{ pollInterval / 1000 }}s
33
+ <template v-if="sseConnected">
34
+ <span class="inline-block w-2 h-2 rounded-full bg-emerald-400 animate-pulse"></span>
35
+ <span class="text-emerald-400 font-medium">Live</span>
36
+ </template>
37
+ <template v-else>
38
+ <span class="inline-block w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
39
+ <span class="text-amber-400 font-medium">Polling</span>
40
+ </template>
35
41
  <span class="text-slate-600">&middot;</span>
36
42
  {{ lastUpdate }}
37
43
  </div>
@@ -220,7 +226,7 @@
220
226
  <td class="py-2 px-3 font-mono text-sm">{{ r.path }}</td>
221
227
  <td class="py-2 px-3">{{ r.controller }}</td>
222
228
  <td class="py-2 px-3 text-slate-400">{{ r.handler }}</td>
223
- <td class="py-2 px-3 text-slate-500 text-xs">{{ r.middleware.length ? r.middleware.join(', ') : '—' }}</td>
229
+ <td class="py-2 px-3 text-slate-500 text-xs">{{ r.middleware.length ? r.middleware.join(', ') : '&#8212;' }}</td>
224
230
  </tr>
225
231
  </tbody>
226
232
  </table>
@@ -228,7 +234,7 @@
228
234
  <!-- Pagination -->
229
235
  <div v-if="filteredRoutes.length > routePageSize" class="flex items-center justify-between mt-4 pt-3 border-t border-slate-800">
230
236
  <span class="text-sm text-slate-500">
231
- Showing {{ (routePage - 1) * routePageSize + 1 }}–{{ Math.min(routePage * routePageSize, filteredRoutes.length) }}
237
+ Showing {{ (routePage - 1) * routePageSize + 1 }}&#8211;{{ Math.min(routePage * routePageSize, filteredRoutes.length) }}
232
238
  of {{ filteredRoutes.length }}
233
239
  </span>
234
240
  <div class="flex gap-1">
@@ -248,11 +254,37 @@
248
254
  <!-- ─── Container Tab ────────────────────────────────────────── -->
249
255
  <div v-show="activeTab === 'container'">
250
256
  <div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
251
- <!-- Search -->
252
- <div class="relative mb-4">
253
- <svg class="absolute left-3 top-2.5 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
254
- <input v-model="containerSearch" type="text" placeholder="Search tokens..."
255
- class="w-full bg-slate-800 border border-slate-700 rounded-lg pl-10 pr-4 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-kick-500">
257
+ <!-- Search + Kind Filter + Scope Filter -->
258
+ <div class="flex flex-col gap-3 mb-4">
259
+ <div class="relative">
260
+ <svg class="absolute left-3 top-2.5 w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
261
+ <input v-model="containerSearch" type="text" placeholder="Search tokens..."
262
+ class="w-full bg-slate-800 border border-slate-700 rounded-lg pl-10 pr-4 py-2 text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-kick-500">
263
+ </div>
264
+ <div class="flex flex-col sm:flex-row gap-3">
265
+ <div class="flex gap-1">
266
+ <span class="text-xs text-slate-500 self-center mr-1">Kind:</span>
267
+ <button v-for="k in ['ALL','controller','service','repository','other']" :key="k"
268
+ @click="containerKindFilter = k"
269
+ :class="[
270
+ 'px-3 py-1.5 text-xs font-semibold rounded-lg transition-colors',
271
+ containerKindFilter === k
272
+ ? kindFilterActiveClass(k)
273
+ : 'bg-slate-800 text-slate-400 border border-slate-700 hover:text-slate-200'
274
+ ]">{{ k === 'ALL' ? 'ALL' : k }}</button>
275
+ </div>
276
+ <div class="flex gap-1">
277
+ <span class="text-xs text-slate-500 self-center mr-1">Scope:</span>
278
+ <button v-for="s in ['ALL','singleton','transient','request']" :key="s"
279
+ @click="containerScopeFilter = s"
280
+ :class="[
281
+ 'px-3 py-1.5 text-xs font-semibold rounded-lg transition-colors',
282
+ containerScopeFilter === s
283
+ ? 'bg-kick-500/20 text-kick-500 border border-kick-500/30'
284
+ : 'bg-slate-800 text-slate-400 border border-slate-700 hover:text-slate-200'
285
+ ]">{{ s === 'ALL' ? 'ALL' : s }}</button>
286
+ </div>
287
+ </div>
256
288
  </div>
257
289
 
258
290
  <!-- Container Table -->
@@ -260,30 +292,96 @@
260
292
  <table class="w-full text-sm">
261
293
  <thead>
262
294
  <tr class="border-b-2 border-slate-700">
295
+ <th class="text-left py-2 px-3 text-slate-400 font-semibold w-6"></th>
263
296
  <th class="text-left py-2 px-3 text-slate-400 font-semibold">Token</th>
297
+ <th class="text-left py-2 px-3 text-slate-400 font-semibold">Kind</th>
264
298
  <th class="text-left py-2 px-3 text-slate-400 font-semibold">Scope</th>
265
- <th class="text-left py-2 px-3 text-slate-400 font-semibold">Instantiated</th>
299
+ <th class="text-left py-2 px-3 text-slate-400 font-semibold">Status</th>
300
+ <th class="text-left py-2 px-3 text-slate-400 font-semibold">Resolves</th>
301
+ <th class="text-left py-2 px-3 text-slate-400 font-semibold">Deps</th>
266
302
  </tr>
267
303
  </thead>
268
304
  <tbody>
269
- <tr v-for="r in paginatedContainer" :key="r.token"
270
- class="border-b border-slate-800 hover:bg-slate-800/50">
271
- <td class="py-2 px-3 font-mono text-sm">{{ r.token }}</td>
272
- <td class="py-2 px-3">
273
- <span class="bg-blue-900/50 text-blue-300 px-2 py-0.5 rounded text-xs font-semibold">{{ r.scope }}</span>
274
- </td>
275
- <td class="py-2 px-3">
276
- <span :class="r.instantiated ? 'bg-emerald-900/50 text-emerald-300' : 'bg-amber-900/50 text-amber-300'"
277
- class="px-2 py-0.5 rounded text-xs font-semibold">{{ r.instantiated ? 'yes' : 'no' }}</span>
278
- </td>
279
- </tr>
305
+ <template v-for="r in paginatedContainer" :key="r.token">
306
+ <tr @click="toggleContainerRow(r.token)"
307
+ class="border-b border-slate-800 hover:bg-slate-800/50 cursor-pointer select-none">
308
+ <td class="py-2 px-3">
309
+ <svg :class="{'rotate-90': expandedContainerRows[r.token]}" class="w-3 h-3 text-slate-500 transition-transform" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"/></svg>
310
+ </td>
311
+ <td class="py-2 px-3 font-mono text-sm">{{ r.token }}</td>
312
+ <td class="py-2 px-3">
313
+ <span :class="kindBadgeClass(r.kind)" class="px-2 py-0.5 rounded text-xs font-semibold">{{ r.kind || 'unknown' }}</span>
314
+ </td>
315
+ <td class="py-2 px-3">
316
+ <span class="bg-slate-700/50 text-slate-300 px-2 py-0.5 rounded text-xs font-semibold">{{ r.scope }}</span>
317
+ </td>
318
+ <td class="py-2 px-3">
319
+ <span :class="statusBadgeClass(r)" class="px-2 py-0.5 rounded text-xs font-semibold">{{ statusLabel(r) }}</span>
320
+ </td>
321
+ <td class="py-2 px-3 tabular-nums">{{ r.resolveCount ?? 0 }}</td>
322
+ <td class="py-2 px-3 text-slate-500 text-xs">{{ r.dependencies?.length || 0 }}</td>
323
+ </tr>
324
+ <!-- Expanded detail row -->
325
+ <tr v-if="expandedContainerRows[r.token]">
326
+ <td colspan="7" class="bg-slate-800/30 px-6 py-4 border-b border-slate-800">
327
+ <div class="grid grid-cols-1 md:grid-cols-2 gap-4">
328
+ <div>
329
+ <h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">Dependencies</h4>
330
+ <div v-if="r.dependencies && r.dependencies.length" class="flex flex-wrap gap-1">
331
+ <button v-for="dep in r.dependencies" :key="dep"
332
+ @click.stop="openDetailModal(findRegistration(dep))"
333
+ class="bg-slate-700/50 text-kick-500 hover:bg-slate-700 px-2 py-0.5 rounded text-xs font-mono transition-colors">
334
+ {{ dep }}
335
+ </button>
336
+ </div>
337
+ <span v-else class="text-slate-600 text-sm">None</span>
338
+ </div>
339
+ <div>
340
+ <h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">Resolve Stats</h4>
341
+ <div class="space-y-1 text-sm">
342
+ <div class="flex justify-between">
343
+ <span class="text-slate-400">Resolve Count</span>
344
+ <span class="tabular-nums">{{ r.resolveCount ?? 0 }}</span>
345
+ </div>
346
+ <div v-if="r.firstResolved" class="flex justify-between">
347
+ <span class="text-slate-400">First Resolved</span>
348
+ <span class="text-xs">{{ new Date(r.firstResolved).toLocaleTimeString() }}</span>
349
+ </div>
350
+ <div v-if="r.lastResolved" class="flex justify-between">
351
+ <span class="text-slate-400">Last Resolved</span>
352
+ <span class="text-xs">{{ new Date(r.lastResolved).toLocaleTimeString() }}</span>
353
+ </div>
354
+ <div v-if="r.resolveDurationMs != null" class="flex justify-between">
355
+ <span class="text-slate-400">Duration</span>
356
+ <span class="tabular-nums">{{ r.resolveDurationMs.toFixed(2) }}ms</span>
357
+ </div>
358
+ </div>
359
+ </div>
360
+ <div>
361
+ <h4 class="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">PostConstruct</h4>
362
+ <span :class="{
363
+ 'bg-emerald-900/50 text-emerald-300': r.postConstructStatus === 'done',
364
+ 'bg-red-900/50 text-red-300': r.postConstructStatus === 'failed',
365
+ 'bg-slate-700/50 text-slate-400': !r.postConstructStatus || r.postConstructStatus === 'none',
366
+ }" class="px-2 py-0.5 rounded text-xs font-semibold">{{ r.postConstructStatus || 'none' }}</span>
367
+ </div>
368
+ <div class="flex items-end">
369
+ <button @click.stop="openDetailModal(r)"
370
+ class="px-3 py-1.5 text-xs font-semibold rounded-lg bg-kick-500/20 text-kick-500 border border-kick-500/30 hover:bg-kick-500/30 transition-colors">
371
+ View Full Details
372
+ </button>
373
+ </div>
374
+ </div>
375
+ </td>
376
+ </tr>
377
+ </template>
280
378
  </tbody>
281
379
  </table>
282
380
 
283
381
  <!-- Pagination -->
284
382
  <div v-if="filteredContainer.length > containerPageSize" class="flex items-center justify-between mt-4 pt-3 border-t border-slate-800">
285
383
  <span class="text-sm text-slate-500">
286
- Showing {{ (containerPage - 1) * containerPageSize + 1 }}–{{ Math.min(containerPage * containerPageSize, filteredContainer.length) }}
384
+ Showing {{ (containerPage - 1) * containerPageSize + 1 }}&#8211;{{ Math.min(containerPage * containerPageSize, filteredContainer.length) }}
287
385
  of {{ filteredContainer.length }}
288
386
  </span>
289
387
  <div class="flex gap-1">
@@ -296,7 +394,126 @@
296
394
  </div>
297
395
  </div>
298
396
  </div>
299
- <p v-else class="text-slate-600 italic">{{ containerSearch ? 'No matching tokens' : 'No DI registrations' }}</p>
397
+ <p v-else class="text-slate-600 italic">{{ containerSearch || containerKindFilter !== 'ALL' || containerScopeFilter !== 'ALL' ? 'No matching tokens' : 'No DI registrations' }}</p>
398
+ </div>
399
+ </div>
400
+
401
+ <!-- ─── Graph Tab ────────────────────────────────────────────── -->
402
+ <div v-show="activeTab === 'graph'">
403
+ <div class="bg-slate-900 rounded-xl border border-slate-800 p-5">
404
+ <div class="flex items-center justify-between mb-4">
405
+ <h2 class="text-sm font-semibold text-slate-400">Dependency Graph</h2>
406
+ <button @click="fetchGraph" class="px-3 py-1.5 text-xs font-semibold rounded-lg bg-slate-800 text-slate-400 border border-slate-700 hover:text-slate-200 transition-colors">
407
+ Refresh
408
+ </button>
409
+ </div>
410
+
411
+ <template v-if="graphData">
412
+ <div v-if="graphData.nodes && graphData.nodes.length" class="space-y-6">
413
+ <!-- Controllers -->
414
+ <div v-if="graphNodesByKind.controllers.length">
415
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-violet-400 mb-2">Controllers</h3>
416
+ <div class="space-y-1">
417
+ <div v-for="node in graphNodesByKind.controllers" :key="node.id">
418
+ <button @click="openDetailModalFromGraph(node)"
419
+ class="w-full text-left px-3 py-2 rounded-lg hover:bg-slate-800/50 transition-colors group">
420
+ <div class="flex items-center gap-2">
421
+ <span class="bg-violet-900/50 text-violet-300 px-2 py-0.5 rounded text-xs font-semibold">ctrl</span>
422
+ <span class="font-mono text-sm text-slate-200">{{ node.id }}</span>
423
+ <span v-if="node.scope" class="bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded text-xs">{{ node.scope }}</span>
424
+ <span v-if="node.resolveCount" class="text-slate-600 text-xs ml-auto">{{ node.resolveCount }} resolves</span>
425
+ </div>
426
+ <!-- Dependencies (edges from this node) -->
427
+ <div v-if="graphEdgesFrom(node.id).length" class="ml-8 mt-1 space-y-0.5">
428
+ <div v-for="edge in graphEdgesFrom(node.id)" :key="edge.to" class="flex items-center gap-2 text-xs text-slate-500">
429
+ <svg class="w-3 h-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5-5 5M6 12h12"/></svg>
430
+ <span :class="graphNodeBadgeClass(edge.to)" class="px-1.5 py-0.5 rounded text-xs font-semibold">{{ graphNodeKindLabel(edge.to) }}</span>
431
+ <span class="font-mono">{{ edge.to }}</span>
432
+ </div>
433
+ </div>
434
+ </button>
435
+ </div>
436
+ </div>
437
+ </div>
438
+
439
+ <!-- Services -->
440
+ <div v-if="graphNodesByKind.services.length">
441
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-blue-400 mb-2">Services</h3>
442
+ <div class="space-y-1">
443
+ <div v-for="node in graphNodesByKind.services" :key="node.id">
444
+ <button @click="openDetailModalFromGraph(node)"
445
+ class="w-full text-left px-3 py-2 rounded-lg hover:bg-slate-800/50 transition-colors group">
446
+ <div class="flex items-center gap-2">
447
+ <span class="bg-blue-900/50 text-blue-300 px-2 py-0.5 rounded text-xs font-semibold">svc</span>
448
+ <span class="font-mono text-sm text-slate-200">{{ node.id }}</span>
449
+ <span v-if="node.scope" class="bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded text-xs">{{ node.scope }}</span>
450
+ <span v-if="node.resolveCount" class="text-slate-600 text-xs ml-auto">{{ node.resolveCount }} resolves</span>
451
+ </div>
452
+ <div v-if="graphEdgesFrom(node.id).length" class="ml-8 mt-1 space-y-0.5">
453
+ <div v-for="edge in graphEdgesFrom(node.id)" :key="edge.to" class="flex items-center gap-2 text-xs text-slate-500">
454
+ <svg class="w-3 h-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5-5 5M6 12h12"/></svg>
455
+ <span :class="graphNodeBadgeClass(edge.to)" class="px-1.5 py-0.5 rounded text-xs font-semibold">{{ graphNodeKindLabel(edge.to) }}</span>
456
+ <span class="font-mono">{{ edge.to }}</span>
457
+ </div>
458
+ </div>
459
+ </button>
460
+ </div>
461
+ </div>
462
+ </div>
463
+
464
+ <!-- Repositories -->
465
+ <div v-if="graphNodesByKind.repositories.length">
466
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-teal-400 mb-2">Repositories</h3>
467
+ <div class="space-y-1">
468
+ <div v-for="node in graphNodesByKind.repositories" :key="node.id">
469
+ <button @click="openDetailModalFromGraph(node)"
470
+ class="w-full text-left px-3 py-2 rounded-lg hover:bg-slate-800/50 transition-colors group">
471
+ <div class="flex items-center gap-2">
472
+ <span class="bg-teal-900/50 text-teal-300 px-2 py-0.5 rounded text-xs font-semibold">repo</span>
473
+ <span class="font-mono text-sm text-slate-200">{{ node.id }}</span>
474
+ <span v-if="node.scope" class="bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded text-xs">{{ node.scope }}</span>
475
+ <span v-if="node.resolveCount" class="text-slate-600 text-xs ml-auto">{{ node.resolveCount }} resolves</span>
476
+ </div>
477
+ <div v-if="graphEdgesFrom(node.id).length" class="ml-8 mt-1 space-y-0.5">
478
+ <div v-for="edge in graphEdgesFrom(node.id)" :key="edge.to" class="flex items-center gap-2 text-xs text-slate-500">
479
+ <svg class="w-3 h-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5-5 5M6 12h12"/></svg>
480
+ <span :class="graphNodeBadgeClass(edge.to)" class="px-1.5 py-0.5 rounded text-xs font-semibold">{{ graphNodeKindLabel(edge.to) }}</span>
481
+ <span class="font-mono">{{ edge.to }}</span>
482
+ </div>
483
+ </div>
484
+ </button>
485
+ </div>
486
+ </div>
487
+ </div>
488
+
489
+ <!-- Other -->
490
+ <div v-if="graphNodesByKind.other.length">
491
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-2">Other</h3>
492
+ <div class="space-y-1">
493
+ <div v-for="node in graphNodesByKind.other" :key="node.id">
494
+ <button @click="openDetailModalFromGraph(node)"
495
+ class="w-full text-left px-3 py-2 rounded-lg hover:bg-slate-800/50 transition-colors group">
496
+ <div class="flex items-center gap-2">
497
+ <span class="bg-slate-700/50 text-slate-300 px-2 py-0.5 rounded text-xs font-semibold">other</span>
498
+ <span class="font-mono text-sm text-slate-200">{{ node.id }}</span>
499
+ <span v-if="node.scope" class="bg-slate-700/50 text-slate-400 px-1.5 py-0.5 rounded text-xs">{{ node.scope }}</span>
500
+ <span v-if="node.resolveCount" class="text-slate-600 text-xs ml-auto">{{ node.resolveCount }} resolves</span>
501
+ </div>
502
+ <div v-if="graphEdgesFrom(node.id).length" class="ml-8 mt-1 space-y-0.5">
503
+ <div v-for="edge in graphEdgesFrom(node.id)" :key="edge.to" class="flex items-center gap-2 text-xs text-slate-500">
504
+ <svg class="w-3 h-3 text-slate-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 7l5 5-5 5M6 12h12"/></svg>
505
+ <span :class="graphNodeBadgeClass(edge.to)" class="px-1.5 py-0.5 rounded text-xs font-semibold">{{ graphNodeKindLabel(edge.to) }}</span>
506
+ <span class="font-mono">{{ edge.to }}</span>
507
+ </div>
508
+ </div>
509
+ </button>
510
+ </div>
511
+ </div>
512
+ </div>
513
+ </div>
514
+ <p v-else class="text-slate-600 italic">No nodes in graph</p>
515
+ </template>
516
+ <p v-else class="text-slate-600 italic">Loading graph...</p>
300
517
  </div>
301
518
  </div>
302
519
 
@@ -347,11 +564,90 @@
347
564
 
348
565
  </main>
349
566
 
350
- <!-- Auth Gate — shown when token is required but not found -->
567
+ <!-- ─── Detail Modal ───────────────────────────────────────────── -->
568
+ <div v-if="detailModal" class="fixed inset-0 bg-slate-950/80 flex items-center justify-center z-50" @click.self="detailModal = null">
569
+ <div class="bg-slate-900 border border-slate-700 rounded-2xl p-6 max-w-lg w-full mx-4 shadow-2xl max-h-[90vh] overflow-y-auto">
570
+ <!-- Header -->
571
+ <div class="flex items-start justify-between mb-4">
572
+ <div>
573
+ <h2 class="text-lg font-bold font-mono text-slate-200 mb-1">{{ detailModal.token || detailModal.id }}</h2>
574
+ <div class="flex gap-2">
575
+ <span :class="kindBadgeClass(detailModal.kind)" class="px-2 py-0.5 rounded text-xs font-semibold">{{ detailModal.kind || 'unknown' }}</span>
576
+ <span class="bg-slate-700/50 text-slate-300 px-2 py-0.5 rounded text-xs font-semibold">{{ detailModal.scope || 'singleton' }}</span>
577
+ <span :class="statusBadgeClass(detailModal)" class="px-2 py-0.5 rounded text-xs font-semibold">{{ statusLabel(detailModal) }}</span>
578
+ </div>
579
+ </div>
580
+ <button @click="detailModal = null" class="text-slate-500 hover:text-slate-300 transition-colors p-1">
581
+ <svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"/></svg>
582
+ </button>
583
+ </div>
584
+
585
+ <!-- Dependencies -->
586
+ <div class="mb-4">
587
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">Dependencies</h3>
588
+ <div v-if="detailModalDependencies.length" class="flex flex-wrap gap-1">
589
+ <button v-for="dep in detailModalDependencies" :key="dep"
590
+ @click="openDetailModal(findRegistration(dep))"
591
+ class="bg-slate-800 text-kick-500 hover:bg-slate-700 px-2.5 py-1 rounded text-xs font-mono transition-colors border border-slate-700">
592
+ {{ dep }}
593
+ </button>
594
+ </div>
595
+ <span v-else class="text-slate-600 text-sm">None</span>
596
+ </div>
597
+
598
+ <!-- Dependents -->
599
+ <div class="mb-4">
600
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">Dependents</h3>
601
+ <div v-if="detailModalDependents.length" class="flex flex-wrap gap-1">
602
+ <button v-for="dep in detailModalDependents" :key="dep"
603
+ @click="openDetailModal(findRegistration(dep))"
604
+ class="bg-slate-800 text-amber-400 hover:bg-slate-700 px-2.5 py-1 rounded text-xs font-mono transition-colors border border-slate-700">
605
+ {{ dep }}
606
+ </button>
607
+ </div>
608
+ <span v-else class="text-slate-600 text-sm">None</span>
609
+ </div>
610
+
611
+ <!-- Resolve Stats -->
612
+ <div class="mb-4">
613
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">Resolve Stats</h3>
614
+ <div class="bg-slate-800/50 rounded-lg border border-slate-700 p-3 space-y-2 text-sm">
615
+ <div class="flex justify-between">
616
+ <span class="text-slate-400">Resolve Count</span>
617
+ <span class="font-semibold tabular-nums">{{ detailModal.resolveCount ?? 0 }}</span>
618
+ </div>
619
+ <div v-if="detailModal.firstResolved" class="flex justify-between">
620
+ <span class="text-slate-400">First Resolved</span>
621
+ <span>{{ new Date(detailModal.firstResolved).toLocaleString() }}</span>
622
+ </div>
623
+ <div v-if="detailModal.lastResolved" class="flex justify-between">
624
+ <span class="text-slate-400">Last Resolved</span>
625
+ <span>{{ new Date(detailModal.lastResolved).toLocaleString() }}</span>
626
+ </div>
627
+ <div v-if="detailModal.resolveDurationMs != null" class="flex justify-between">
628
+ <span class="text-slate-400">Resolution Duration</span>
629
+ <span class="tabular-nums">{{ detailModal.resolveDurationMs.toFixed(2) }}ms</span>
630
+ </div>
631
+ </div>
632
+ </div>
633
+
634
+ <!-- PostConstruct -->
635
+ <div>
636
+ <h3 class="text-xs font-semibold uppercase tracking-wider text-slate-500 mb-2">PostConstruct</h3>
637
+ <span :class="{
638
+ 'bg-emerald-900/50 text-emerald-300': detailModal.postConstructStatus === 'done',
639
+ 'bg-red-900/50 text-red-300': detailModal.postConstructStatus === 'failed',
640
+ 'bg-slate-700/50 text-slate-400': !detailModal.postConstructStatus || detailModal.postConstructStatus === 'none',
641
+ }" class="px-2 py-0.5 rounded text-xs font-semibold">{{ detailModal.postConstructStatus || 'none' }}</span>
642
+ </div>
643
+ </div>
644
+ </div>
645
+
646
+ <!-- Auth Gate -->
351
647
  <div v-if="showAuthGate" class="fixed inset-0 bg-slate-950/90 flex items-center justify-center z-50">
352
648
  <div class="bg-slate-900 border border-slate-700 rounded-2xl p-8 max-w-md w-full mx-4 shadow-2xl">
353
649
  <div class="text-center mb-6">
354
- <h2 class="text-xl font-bold text-kick-500 mb-2">⚡ KickJS DevTools</h2>
650
+ <h2 class="text-xl font-bold text-kick-500 mb-2">&#9889; KickJS DevTools</h2>
355
651
  <p class="text-sm text-slate-400">Enter your DevTools token to continue</p>
356
652
  <p class="text-xs text-slate-600 mt-1">Token is shown in your server console on startup</p>
357
653
  </div>
@@ -378,15 +674,12 @@ const BASE = document.body.dataset.base || '/_debug'
378
674
  function getToken() {
379
675
  const fromUrl = new URLSearchParams(window.location.search).get('token')
380
676
  if (fromUrl) {
381
- // Save to cookie for future visits
382
677
  document.cookie = `kickjs_devtools_token=${fromUrl}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`
383
- // Clean URL (remove token from query string)
384
678
  const url = new URL(window.location)
385
679
  url.searchParams.delete('token')
386
680
  window.history.replaceState({}, '', url.toString())
387
681
  return fromUrl
388
682
  }
389
- // Try cookie
390
683
  const match = document.cookie.match(/kickjs_devtools_token=([^;]+)/)
391
684
  return match ? match[1] : ''
392
685
  }
@@ -396,11 +689,11 @@ let TOKEN = getToken()
396
689
  createApp({
397
690
  setup() {
398
691
  const activeTab = ref('overview')
399
- const pollInterval = ref(30000)
400
692
  const lastUpdate = ref('loading...')
401
693
  const showAuthGate = ref(false)
402
694
  const tokenInput = ref('')
403
695
  const authError = ref('')
696
+ const sseConnected = ref(false)
404
697
 
405
698
  // Data
406
699
  const health = ref(null)
@@ -409,6 +702,10 @@ createApp({
409
702
  const routeData = ref(null)
410
703
  const containerData = ref(null)
411
704
  const queueData = ref(null)
705
+ const graphData = ref(null)
706
+
707
+ // Detail modal
708
+ const detailModal = ref(null)
412
709
 
413
710
  // Collapsible state
414
711
  const collapsed = reactive({
@@ -449,16 +746,28 @@ createApp({
449
746
  return filteredRoutes.value.slice(start, start + routePageSize.value)
450
747
  })
451
748
 
452
- // Reset page when search/filter changes
453
749
  watch([routeSearch, routeMethodFilter], () => { routePage.value = 1 })
454
750
 
455
- // Container search + pagination
751
+ // Container search + filter + pagination
456
752
  const containerSearch = ref('')
753
+ const containerKindFilter = ref('ALL')
754
+ const containerScopeFilter = ref('ALL')
457
755
  const containerPage = ref(1)
458
756
  const containerPageSize = ref(20)
757
+ const expandedContainerRows = reactive({})
459
758
 
460
759
  const filteredContainer = computed(() => {
461
760
  let regs = containerData.value?.registrations || []
761
+ if (containerKindFilter.value !== 'ALL') {
762
+ if (containerKindFilter.value === 'other') {
763
+ regs = regs.filter(r => !['controller', 'service', 'repository'].includes(r.kind))
764
+ } else {
765
+ regs = regs.filter(r => r.kind === containerKindFilter.value)
766
+ }
767
+ }
768
+ if (containerScopeFilter.value !== 'ALL') {
769
+ regs = regs.filter(r => r.scope === containerScopeFilter.value)
770
+ }
462
771
  if (containerSearch.value) {
463
772
  const q = containerSearch.value.toLowerCase()
464
773
  regs = regs.filter(r => r.token.toLowerCase().includes(q))
@@ -472,13 +781,134 @@ createApp({
472
781
  return filteredContainer.value.slice(start, start + containerPageSize.value)
473
782
  })
474
783
 
475
- watch(containerSearch, () => { containerPage.value = 1 })
784
+ watch([containerSearch, containerKindFilter, containerScopeFilter], () => { containerPage.value = 1 })
785
+
786
+ function toggleContainerRow(token) {
787
+ expandedContainerRows[token] = !expandedContainerRows[token]
788
+ }
789
+
790
+ // Helper to find a registration by token
791
+ function findRegistration(token) {
792
+ const regs = containerData.value?.registrations || []
793
+ return regs.find(r => r.token === token) || { token: token, kind: 'unknown', scope: 'unknown' }
794
+ }
795
+
796
+ // Detail modal helpers
797
+ function openDetailModal(reg) {
798
+ if (reg) detailModal.value = reg
799
+ }
800
+
801
+ function openDetailModalFromGraph(node) {
802
+ const reg = findRegistration(node.id)
803
+ // Merge graph node data into the registration
804
+ detailModal.value = {
805
+ ...reg,
806
+ token: reg.token || node.id,
807
+ kind: reg.kind || node.kind,
808
+ scope: reg.scope || node.scope,
809
+ resolveCount: reg.resolveCount ?? node.resolveCount,
810
+ }
811
+ }
812
+
813
+ const detailModalDependencies = computed(() => {
814
+ if (!detailModal.value) return []
815
+ // From container registration
816
+ if (detailModal.value.dependencies && detailModal.value.dependencies.length) {
817
+ return detailModal.value.dependencies
818
+ }
819
+ // From graph edges
820
+ if (graphData.value && graphData.value.edges) {
821
+ const token = detailModal.value.token || detailModal.value.id
822
+ return graphData.value.edges.filter(e => e.from === token).map(e => e.to)
823
+ }
824
+ return []
825
+ })
826
+
827
+ const detailModalDependents = computed(() => {
828
+ if (!detailModal.value) return []
829
+ const token = detailModal.value.token || detailModal.value.id
830
+ // From container: find all registrations that list this token as a dependency
831
+ const regs = containerData.value?.registrations || []
832
+ const fromContainer = regs
833
+ .filter(r => r.dependencies && r.dependencies.includes(token))
834
+ .map(r => r.token)
835
+ if (fromContainer.length) return fromContainer
836
+ // From graph edges (reverse)
837
+ if (graphData.value && graphData.value.edges) {
838
+ return graphData.value.edges.filter(e => e.to === token).map(e => e.from)
839
+ }
840
+ return []
841
+ })
842
+
843
+ // Kind/status badge helpers
844
+ function kindBadgeClass(kind) {
845
+ return {
846
+ 'bg-violet-900/50 text-violet-300': kind === 'controller',
847
+ 'bg-blue-900/50 text-blue-300': kind === 'service',
848
+ 'bg-teal-900/50 text-teal-300': kind === 'repository',
849
+ 'bg-slate-700/50 text-slate-300': !['controller', 'service', 'repository'].includes(kind),
850
+ }
851
+ }
852
+
853
+ function statusBadgeClass(r) {
854
+ return {
855
+ 'bg-emerald-900/50 text-emerald-300': r.instantiated,
856
+ 'bg-amber-900/50 text-amber-300': !r.instantiated && r.postConstructStatus !== 'failed',
857
+ 'bg-red-900/50 text-red-300': r.postConstructStatus === 'failed',
858
+ }
859
+ }
860
+
861
+ function statusLabel(r) {
862
+ return r.instantiated ? 'active' : (r.postConstructStatus === 'failed' ? 'failed' : 'registered')
863
+ }
864
+
865
+ function kindFilterActiveClass(k) {
866
+ if (k === 'ALL') return 'bg-kick-500/20 text-kick-500 border border-kick-500/30'
867
+ if (k === 'controller') return 'bg-violet-900/50 text-violet-300 border border-violet-700/50'
868
+ if (k === 'service') return 'bg-blue-900/50 text-blue-300 border border-blue-700/50'
869
+ if (k === 'repository') return 'bg-teal-900/50 text-teal-300 border border-teal-700/50'
870
+ return 'bg-slate-700/50 text-slate-300 border border-slate-600/50'
871
+ }
872
+
873
+ // Graph helpers
874
+ const graphNodesByKind = computed(() => {
875
+ const nodes = graphData.value?.nodes || []
876
+ return {
877
+ controllers: nodes.filter(n => n.kind === 'controller'),
878
+ services: nodes.filter(n => n.kind === 'service'),
879
+ repositories: nodes.filter(n => n.kind === 'repository'),
880
+ other: nodes.filter(n => !['controller', 'service', 'repository'].includes(n.kind)),
881
+ }
882
+ })
883
+
884
+ function graphEdgesFrom(nodeId) {
885
+ return (graphData.value?.edges || []).filter(e => e.from === nodeId)
886
+ }
887
+
888
+ function graphNodeKindLabel(nodeId) {
889
+ const node = (graphData.value?.nodes || []).find(n => n.id === nodeId)
890
+ if (!node) return '?'
891
+ if (node.kind === 'controller') return 'ctrl'
892
+ if (node.kind === 'service') return 'svc'
893
+ if (node.kind === 'repository') return 'repo'
894
+ return 'other'
895
+ }
896
+
897
+ function graphNodeBadgeClass(nodeId) {
898
+ const node = (graphData.value?.nodes || []).find(n => n.id === nodeId)
899
+ if (!node) return 'bg-slate-700/50 text-slate-300'
900
+ if (node.kind === 'controller') return 'bg-violet-900/50 text-violet-300'
901
+ if (node.kind === 'service') return 'bg-blue-900/50 text-blue-300'
902
+ if (node.kind === 'repository') return 'bg-teal-900/50 text-teal-300'
903
+ return 'bg-slate-700/50 text-slate-300'
904
+ }
476
905
 
477
906
  // Tab badges
478
907
  const tabs = computed(() => [
479
908
  { id: 'overview', label: 'Overview' },
480
909
  { id: 'routes', label: 'Routes', count: routeData.value?.routes?.length },
481
910
  { id: 'container', label: 'Container', count: containerData.value?.count },
911
+ { id: 'graph', label: 'Graph', count: graphData.value?.nodes?.length },
482
912
  { id: 'queues', label: 'Queues', count: queueData.value?.queues?.length },
483
913
  ])
484
914
 
@@ -500,7 +930,6 @@ createApp({
500
930
  const testToken = tokenInput.value.trim()
501
931
  if (!testToken) { authError.value = 'Token is required'; return }
502
932
 
503
- // Test the token
504
933
  try {
505
934
  const r = await fetch(BASE + '/health', { headers: { 'x-devtools-token': testToken } })
506
935
  if (r.status === 403) {
@@ -512,11 +941,11 @@ createApp({
512
941
  return
513
942
  }
514
943
 
515
- // Save to cookie and reload
516
944
  TOKEN = testToken
517
945
  document.cookie = `kickjs_devtools_token=${testToken}; path=/; max-age=${60 * 60 * 24 * 30}; SameSite=Lax`
518
946
  showAuthGate.value = false
519
947
  refresh()
948
+ connectSSE()
520
949
  }
521
950
 
522
951
  async function refresh() {
@@ -537,6 +966,11 @@ createApp({
537
966
  lastUpdate.value = new Date().toLocaleTimeString()
538
967
  }
539
968
 
969
+ async function fetchGraph() {
970
+ const g = await fetchJSON('/graph')
971
+ if (g) graphData.value = g
972
+ }
973
+
540
974
  function formatDuration(seconds) {
541
975
  if (seconds < 60) return seconds + 's'
542
976
  if (seconds < 3600) return Math.floor(seconds / 60) + 'm ' + (seconds % 60) + 's'
@@ -552,19 +986,101 @@ createApp({
552
986
  }[method] || 'text-slate-400'
553
987
  }
554
988
 
555
- let timer
556
- onMounted(() => { refresh(); timer = setInterval(refresh, pollInterval.value) })
557
- onUnmounted(() => clearInterval(timer))
989
+ // SSE connection
990
+ let eventSource = null
991
+ let pollTimer = null
992
+
993
+ function connectSSE() {
994
+ if (eventSource) {
995
+ eventSource.close()
996
+ eventSource = null
997
+ }
998
+ if (pollTimer) {
999
+ clearInterval(pollTimer)
1000
+ pollTimer = null
1001
+ }
1002
+
1003
+ const sseUrl = `${BASE}/stream${TOKEN ? '?token=' + TOKEN : ''}`
1004
+ eventSource = new EventSource(sseUrl)
1005
+
1006
+ eventSource.onopen = () => {
1007
+ sseConnected.value = true
1008
+ if (pollTimer) {
1009
+ clearInterval(pollTimer)
1010
+ pollTimer = null
1011
+ }
1012
+ }
1013
+
1014
+ eventSource.onmessage = (event) => {
1015
+ try {
1016
+ const data = JSON.parse(event.data)
1017
+ if (data.type === 'metrics') {
1018
+ if (metrics.value) {
1019
+ metrics.value.requests = data.requestCount
1020
+ metrics.value.serverErrors = data.errorCount
1021
+ metrics.value.clientErrors = data.clientErrorCount
1022
+ metrics.value.errorRate = data.errorRate
1023
+ }
1024
+ if (health.value) {
1025
+ health.value.uptime = data.uptimeSeconds
1026
+ health.value.errorRate = data.errorRate
1027
+ }
1028
+ lastUpdate.value = new Date().toLocaleTimeString()
1029
+ }
1030
+ if (data.type === 'container') {
1031
+ fetchJSON('/container').then(c => { if (c) containerData.value = c })
1032
+ fetchJSON('/routes').then(r => { if (r) routeData.value = r })
1033
+ fetchGraph()
1034
+ }
1035
+ } catch {}
1036
+ }
1037
+
1038
+ eventSource.onerror = () => {
1039
+ sseConnected.value = false
1040
+ if (eventSource) {
1041
+ eventSource.close()
1042
+ eventSource = null
1043
+ }
1044
+ // Fallback to polling
1045
+ if (!pollTimer) {
1046
+ pollTimer = setInterval(refresh, 5000)
1047
+ }
1048
+ }
1049
+ }
1050
+
1051
+ onMounted(() => {
1052
+ refresh()
1053
+ fetchGraph()
1054
+ connectSSE()
1055
+ })
1056
+
1057
+ onUnmounted(() => {
1058
+ if (eventSource) {
1059
+ eventSource.close()
1060
+ eventSource = null
1061
+ }
1062
+ if (pollTimer) {
1063
+ clearInterval(pollTimer)
1064
+ pollTimer = null
1065
+ }
1066
+ })
558
1067
 
559
1068
  return {
560
- activeTab, tabs, pollInterval, lastUpdate,
561
- health, metrics, ws, routeData, containerData, queueData,
1069
+ activeTab, tabs, lastUpdate, sseConnected,
1070
+ health, metrics, ws, routeData, containerData, queueData, graphData,
562
1071
  collapsed, toggle,
563
1072
  showAuthGate, tokenInput, authError, submitToken,
564
1073
  routeSearch, routeMethodFilter, routePage, routePageSize,
565
1074
  filteredRoutes, paginatedRoutes, routeTotalPages,
566
- containerSearch, containerPage, containerPageSize,
1075
+ containerSearch, containerKindFilter, containerScopeFilter,
1076
+ containerPage, containerPageSize,
567
1077
  filteredContainer, paginatedContainer, containerTotalPages,
1078
+ expandedContainerRows, toggleContainerRow,
1079
+ detailModal, detailModalDependencies, detailModalDependents,
1080
+ openDetailModal, openDetailModalFromGraph, findRegistration,
1081
+ graphData, graphNodesByKind, graphEdgesFrom, graphNodeKindLabel, graphNodeBadgeClass,
1082
+ fetchGraph,
1083
+ kindBadgeClass, statusBadgeClass, statusLabel, kindFilterActiveClass,
568
1084
  formatDuration, methodColor,
569
1085
  }
570
1086
  }