qdadm 0.38.1 → 0.40.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,657 @@
1
+ <script setup>
2
+ /**
3
+ * RouterPanel - Debug panel for Vue Router state
4
+ *
5
+ * Displays:
6
+ * - Current route (name, path, params, query, meta)
7
+ * - Computed breadcrumb
8
+ * - Navigation history (recent route changes)
9
+ * - All registered routes
10
+ */
11
+ import { ref, computed, watch } from 'vue'
12
+ import ObjectTree from '../ObjectTree.vue'
13
+
14
+ const props = defineProps({
15
+ collector: { type: Object, required: true },
16
+ entries: { type: Array, required: true }
17
+ })
18
+
19
+ // Active tab
20
+ const activeTab = ref('current')
21
+ const tabs = [
22
+ { id: 'current', label: 'Current', icon: 'pi-map-marker' },
23
+ { id: 'history', label: 'History', icon: 'pi-history' },
24
+ { id: 'routes', label: 'Routes', icon: 'pi-sitemap' }
25
+ ]
26
+
27
+ // Route filter for routes tab
28
+ const routeFilter = ref('')
29
+
30
+ // Max history - persisted in localStorage
31
+ const STORAGE_KEY_MAX = 'qdadm-router-max'
32
+ const maxHistory = ref(parseInt(localStorage.getItem(STORAGE_KEY_MAX)) || 20)
33
+
34
+ watch(maxHistory, (val) => {
35
+ localStorage.setItem(STORAGE_KEY_MAX, String(val))
36
+ })
37
+
38
+ // Current route (reactive via entries change)
39
+ const currentRoute = computed(() => {
40
+ // Trigger reactivity from entries
41
+ props.entries.length
42
+ return props.collector.getCurrentRoute()
43
+ })
44
+
45
+ // Breadcrumb
46
+ const breadcrumb = computed(() => {
47
+ props.entries.length
48
+ return props.collector.getBreadcrumb()
49
+ })
50
+
51
+ // Navigation history (already newest-first from collector, apply max limit)
52
+ const history = computed(() => {
53
+ let result = props.entries
54
+ if (maxHistory.value > 0 && result.length > maxHistory.value) {
55
+ result = result.slice(0, maxHistory.value)
56
+ }
57
+ return result
58
+ })
59
+
60
+ // All routes filtered
61
+ const routes = computed(() => {
62
+ props.entries.length // trigger
63
+ const all = props.collector.getRoutes()
64
+ if (!routeFilter.value) return all
65
+ const filter = routeFilter.value.toLowerCase()
66
+ return all.filter(r =>
67
+ r.name.toLowerCase().includes(filter) ||
68
+ r.path.toLowerCase().includes(filter)
69
+ )
70
+ })
71
+
72
+ // Mark history as seen when viewing history tab
73
+ watch(activeTab, (tab) => {
74
+ if (tab === 'history') {
75
+ props.collector.markSeen()
76
+ }
77
+ })
78
+
79
+ function formatTime(ts) {
80
+ const d = new Date(ts)
81
+ return d.toLocaleTimeString('en-US', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
82
+ }
83
+
84
+ function hasParams(obj) {
85
+ return obj && Object.keys(obj).length > 0
86
+ }
87
+ </script>
88
+
89
+ <template>
90
+ <div class="router-panel">
91
+ <!-- Tabs -->
92
+ <div class="router-tabs">
93
+ <button
94
+ v-for="tab in tabs"
95
+ :key="tab.id"
96
+ class="router-tab"
97
+ :class="{ 'router-tab-active': activeTab === tab.id }"
98
+ @click="activeTab = tab.id"
99
+ >
100
+ <i :class="['pi', tab.icon]" />
101
+ {{ tab.label }}
102
+ <span v-if="tab.id === 'history' && collector.getBadge() > 0" class="tab-badge">
103
+ {{ collector.getBadge() }}
104
+ </span>
105
+ <span v-if="tab.id === 'routes'" class="tab-count">
106
+ {{ routes.length }}
107
+ </span>
108
+ </button>
109
+ </div>
110
+
111
+ <!-- Current Route -->
112
+ <div v-if="activeTab === 'current'" class="router-content">
113
+ <div v-if="currentRoute" class="route-current">
114
+ <!-- Route name and path -->
115
+ <div class="route-header">
116
+ <span class="route-name">{{ currentRoute.name }}</span>
117
+ <span class="route-path">{{ currentRoute.fullPath }}</span>
118
+ </div>
119
+
120
+ <!-- Breadcrumb -->
121
+ <div v-if="breadcrumb.length > 0" class="route-section">
122
+ <div class="section-label">Breadcrumb</div>
123
+ <div class="breadcrumb-list">
124
+ <div v-for="(item, idx) in breadcrumb" :key="idx" class="breadcrumb-entry" :class="'breadcrumb-' + item.kind">
125
+ <div class="breadcrumb-entry-header">
126
+ <span class="breadcrumb-index">{{ idx + 1 }}</span>
127
+ <span class="breadcrumb-kind">{{ item.kind }}</span>
128
+ </div>
129
+ <div class="breadcrumb-entry-props">
130
+ <template v-if="item.kind === 'route'">
131
+ <span class="breadcrumb-prop">route: <code>{{ item.route }}</code></span>
132
+ </template>
133
+ <template v-else>
134
+ <span class="breadcrumb-prop">entity: <code>{{ item.entity }}</code></span>
135
+ <span v-if="item.id" class="breadcrumb-prop">id: <code>{{ item.id }}</code></span>
136
+ </template>
137
+ </div>
138
+ </div>
139
+ </div>
140
+ </div>
141
+
142
+ <!-- Params -->
143
+ <div v-if="hasParams(currentRoute.params)" class="route-section">
144
+ <div class="section-label">Params</div>
145
+ <ObjectTree :data="currentRoute.params" :expanded="true" />
146
+ </div>
147
+
148
+ <!-- Query -->
149
+ <div v-if="hasParams(currentRoute.query)" class="route-section">
150
+ <div class="section-label">Query</div>
151
+ <ObjectTree :data="currentRoute.query" :expanded="true" />
152
+ </div>
153
+
154
+ <!-- Meta -->
155
+ <div v-if="hasParams(currentRoute.meta)" class="route-section">
156
+ <div class="section-label">Meta</div>
157
+ <ObjectTree :data="currentRoute.meta" :expanded="false" />
158
+ </div>
159
+
160
+ <!-- Hash -->
161
+ <div v-if="currentRoute.hash" class="route-section">
162
+ <div class="section-label">Hash</div>
163
+ <code class="route-hash">{{ currentRoute.hash }}</code>
164
+ </div>
165
+ </div>
166
+ <div v-else class="router-empty">
167
+ <i class="pi pi-compass" />
168
+ <span>No route active</span>
169
+ </div>
170
+ </div>
171
+
172
+ <!-- History -->
173
+ <div v-if="activeTab === 'history'" class="router-content">
174
+ <!-- Max selector -->
175
+ <div class="history-header-bar">
176
+ <span class="history-stats">{{ history.length }}/{{ entries.length }}</span>
177
+ <div class="history-max">
178
+ <label>Max:</label>
179
+ <select v-model.number="maxHistory" class="max-select">
180
+ <option :value="10">10</option>
181
+ <option :value="20">20</option>
182
+ <option :value="50">50</option>
183
+ <option :value="100">100</option>
184
+ </select>
185
+ </div>
186
+ </div>
187
+ <div v-if="history.length === 0" class="router-empty">
188
+ <i class="pi pi-history" />
189
+ <span>No navigation history</span>
190
+ </div>
191
+ <div v-else class="history-list">
192
+ <div
193
+ v-for="nav in history"
194
+ :key="nav.id"
195
+ class="history-entry"
196
+ :class="{ 'history-new': !nav.seen }"
197
+ >
198
+ <div class="history-header">
199
+ <span v-if="!nav.seen" class="history-dot" title="New" />
200
+ <span class="history-time">{{ formatTime(nav.timestamp) }}</span>
201
+ <span class="history-arrow">
202
+ <template v-if="nav.from">
203
+ {{ nav.from.name }}
204
+ <i class="pi pi-arrow-right" />
205
+ </template>
206
+ {{ nav.to.name }}
207
+ </span>
208
+ </div>
209
+ <div class="history-path">{{ nav.to.fullPath }}</div>
210
+ </div>
211
+ </div>
212
+ </div>
213
+
214
+ <!-- Routes -->
215
+ <div v-if="activeTab === 'routes'" class="router-content">
216
+ <div class="routes-filter">
217
+ <i class="pi pi-search" />
218
+ <input
219
+ v-model="routeFilter"
220
+ type="text"
221
+ placeholder="Filter routes..."
222
+ class="filter-input"
223
+ />
224
+ </div>
225
+ <div class="routes-list">
226
+ <div
227
+ v-for="route in routes"
228
+ :key="route.path"
229
+ class="route-entry"
230
+ :class="{ 'route-current-entry': currentRoute?.name === route.name }"
231
+ >
232
+ <div class="route-entry-header">
233
+ <span class="route-entry-name">{{ route.name }}</span>
234
+ <span v-if="!route.hasComponent" class="route-no-component" title="No component">
235
+ <i class="pi pi-exclamation-triangle" />
236
+ </span>
237
+ </div>
238
+ <div class="route-entry-path">{{ route.path }}</div>
239
+ <div v-if="hasParams(route.meta)" class="route-entry-meta">
240
+ <span v-if="route.meta.public" class="meta-tag meta-public">public</span>
241
+ <span v-if="route.meta.entity" class="meta-tag meta-entity">{{ route.meta.entity }}</span>
242
+ <span v-if="route.meta.permission" class="meta-tag meta-permission">{{ route.meta.permission }}</span>
243
+ </div>
244
+ </div>
245
+ </div>
246
+ </div>
247
+ </div>
248
+ </template>
249
+
250
+ <style scoped>
251
+ .router-panel {
252
+ display: flex;
253
+ flex-direction: column;
254
+ height: 100%;
255
+ }
256
+
257
+ .router-tabs {
258
+ display: flex;
259
+ gap: 2px;
260
+ padding: 8px 12px;
261
+ background: #27272a;
262
+ border-bottom: 1px solid #3f3f46;
263
+ }
264
+
265
+ .router-tab {
266
+ display: flex;
267
+ align-items: center;
268
+ gap: 6px;
269
+ padding: 4px 12px;
270
+ font-size: 11px;
271
+ background: transparent;
272
+ border: none;
273
+ border-radius: 4px;
274
+ color: #a1a1aa;
275
+ cursor: pointer;
276
+ }
277
+
278
+ .router-tab:hover {
279
+ background: #3f3f46;
280
+ color: #f4f4f5;
281
+ }
282
+
283
+ .router-tab-active {
284
+ background: #3b82f6;
285
+ color: white;
286
+ }
287
+
288
+ .router-tab .pi {
289
+ font-size: 12px;
290
+ }
291
+
292
+ .tab-badge {
293
+ background: #f59e0b;
294
+ color: #18181b;
295
+ font-size: 10px;
296
+ padding: 0 5px;
297
+ border-radius: 8px;
298
+ font-weight: 600;
299
+ }
300
+
301
+ .tab-count {
302
+ font-size: 10px;
303
+ color: #71717a;
304
+ }
305
+
306
+ .router-content {
307
+ flex: 1;
308
+ overflow-y: auto;
309
+ padding: 8px;
310
+ }
311
+
312
+ .router-empty {
313
+ display: flex;
314
+ flex-direction: column;
315
+ align-items: center;
316
+ justify-content: center;
317
+ height: 100%;
318
+ color: #71717a;
319
+ gap: 8px;
320
+ }
321
+
322
+ .router-empty .pi {
323
+ font-size: 24px;
324
+ }
325
+
326
+ /* Current Route */
327
+ .route-current {
328
+ padding: 8px;
329
+ }
330
+
331
+ .route-header {
332
+ display: flex;
333
+ flex-direction: column;
334
+ gap: 4px;
335
+ margin-bottom: 12px;
336
+ }
337
+
338
+ .route-name {
339
+ font-size: 14px;
340
+ font-weight: 600;
341
+ color: #3b82f6;
342
+ }
343
+
344
+ .route-path {
345
+ font-size: 12px;
346
+ font-family: monospace;
347
+ color: #a1a1aa;
348
+ word-break: break-all;
349
+ }
350
+
351
+ /* Breadcrumb list */
352
+ .breadcrumb-list {
353
+ display: flex;
354
+ flex-direction: column;
355
+ gap: 4px;
356
+ }
357
+
358
+ .breadcrumb-entry {
359
+ padding: 6px 8px;
360
+ background: #27272a;
361
+ border-radius: 4px;
362
+ font-size: 11px;
363
+ border-left: 2px solid #3f3f46;
364
+ }
365
+
366
+ .breadcrumb-entry-header {
367
+ display: flex;
368
+ align-items: center;
369
+ gap: 8px;
370
+ }
371
+
372
+ .breadcrumb-index {
373
+ width: 16px;
374
+ height: 16px;
375
+ display: flex;
376
+ align-items: center;
377
+ justify-content: center;
378
+ background: #3f3f46;
379
+ border-radius: 50%;
380
+ font-size: 10px;
381
+ color: #a1a1aa;
382
+ }
383
+
384
+ .breadcrumb-kind {
385
+ font-weight: 500;
386
+ color: #f4f4f5;
387
+ }
388
+
389
+ .breadcrumb-entry-props {
390
+ display: flex;
391
+ flex-wrap: wrap;
392
+ gap: 12px;
393
+ padding-left: 24px;
394
+ margin-top: 4px;
395
+ }
396
+
397
+ .breadcrumb-prop {
398
+ color: #71717a;
399
+ font-size: 10px;
400
+ }
401
+
402
+ .breadcrumb-prop code {
403
+ color: #60a5fa;
404
+ background: #18181b;
405
+ padding: 1px 4px;
406
+ border-radius: 2px;
407
+ }
408
+
409
+ /* Kind-based colors */
410
+ .breadcrumb-route {
411
+ border-left-color: #71717a;
412
+ }
413
+
414
+ .breadcrumb-entity-list {
415
+ border-left-color: #3b82f6;
416
+ }
417
+
418
+ .breadcrumb-entity-show {
419
+ border-left-color: #10b981;
420
+ }
421
+
422
+ .breadcrumb-entity-edit {
423
+ border-left-color: #f59e0b;
424
+ }
425
+
426
+ .breadcrumb-entity-create {
427
+ border-left-color: #8b5cf6;
428
+ }
429
+
430
+ .breadcrumb-entity-delete {
431
+ border-left-color: #ef4444;
432
+ }
433
+
434
+ .route-section {
435
+ margin-bottom: 12px;
436
+ }
437
+
438
+ .section-label {
439
+ font-size: 10px;
440
+ font-weight: 600;
441
+ color: #71717a;
442
+ text-transform: uppercase;
443
+ margin-bottom: 4px;
444
+ }
445
+
446
+ .route-hash {
447
+ font-size: 12px;
448
+ color: #fbbf24;
449
+ background: #27272a;
450
+ padding: 2px 6px;
451
+ border-radius: 3px;
452
+ }
453
+
454
+ /* History */
455
+ .history-header-bar {
456
+ display: flex;
457
+ justify-content: space-between;
458
+ align-items: center;
459
+ padding: 8px;
460
+ background: #27272a;
461
+ border-radius: 4px;
462
+ margin-bottom: 8px;
463
+ }
464
+
465
+ .history-stats {
466
+ font-size: 11px;
467
+ color: #71717a;
468
+ }
469
+
470
+ .history-max {
471
+ display: flex;
472
+ align-items: center;
473
+ gap: 6px;
474
+ font-size: 11px;
475
+ color: #a1a1aa;
476
+ }
477
+
478
+ .max-select {
479
+ padding: 2px 6px;
480
+ font-size: 11px;
481
+ background: #18181b;
482
+ border: 1px solid #3f3f46;
483
+ border-radius: 4px;
484
+ color: #f4f4f5;
485
+ cursor: pointer;
486
+ }
487
+
488
+ .max-select:focus {
489
+ border-color: #3b82f6;
490
+ outline: none;
491
+ }
492
+
493
+ .history-list {
494
+ display: flex;
495
+ flex-direction: column;
496
+ gap: 4px;
497
+ }
498
+
499
+ .history-entry {
500
+ padding: 8px;
501
+ background: #27272a;
502
+ border-radius: 4px;
503
+ font-size: 12px;
504
+ }
505
+
506
+ .history-entry:hover {
507
+ background: #3f3f46;
508
+ }
509
+
510
+ .history-new {
511
+ border-left: 2px solid #f59e0b;
512
+ padding-left: 6px;
513
+ }
514
+
515
+ .history-header {
516
+ display: flex;
517
+ align-items: center;
518
+ gap: 8px;
519
+ }
520
+
521
+ .history-dot {
522
+ width: 6px;
523
+ height: 6px;
524
+ background: #f59e0b;
525
+ border-radius: 50%;
526
+ flex-shrink: 0;
527
+ }
528
+
529
+ .history-time {
530
+ font-size: 10px;
531
+ color: #71717a;
532
+ font-family: monospace;
533
+ }
534
+
535
+ .history-arrow {
536
+ display: flex;
537
+ align-items: center;
538
+ gap: 4px;
539
+ color: #d4d4d8;
540
+ }
541
+
542
+ .history-arrow .pi {
543
+ font-size: 10px;
544
+ color: #52525b;
545
+ }
546
+
547
+ .history-path {
548
+ font-size: 11px;
549
+ color: #71717a;
550
+ font-family: monospace;
551
+ margin-top: 4px;
552
+ word-break: break-all;
553
+ }
554
+
555
+ /* Routes */
556
+ .routes-filter {
557
+ display: flex;
558
+ align-items: center;
559
+ gap: 8px;
560
+ padding: 8px;
561
+ background: #27272a;
562
+ border-radius: 4px;
563
+ margin-bottom: 8px;
564
+ }
565
+
566
+ .routes-filter .pi {
567
+ color: #71717a;
568
+ font-size: 12px;
569
+ }
570
+
571
+ .filter-input {
572
+ flex: 1;
573
+ font-size: 12px;
574
+ padding: 4px 8px;
575
+ background: #18181b;
576
+ border: 1px solid #3f3f46;
577
+ border-radius: 4px;
578
+ color: #f4f4f5;
579
+ }
580
+
581
+ .filter-input:focus {
582
+ border-color: #3b82f6;
583
+ outline: none;
584
+ }
585
+
586
+ .routes-list {
587
+ display: flex;
588
+ flex-direction: column;
589
+ gap: 4px;
590
+ }
591
+
592
+ .route-entry {
593
+ padding: 8px;
594
+ background: #27272a;
595
+ border-radius: 4px;
596
+ font-size: 12px;
597
+ }
598
+
599
+ .route-entry:hover {
600
+ background: #3f3f46;
601
+ }
602
+
603
+ .route-current-entry {
604
+ border-left: 2px solid #3b82f6;
605
+ padding-left: 6px;
606
+ }
607
+
608
+ .route-entry-header {
609
+ display: flex;
610
+ align-items: center;
611
+ gap: 6px;
612
+ }
613
+
614
+ .route-entry-name {
615
+ font-weight: 500;
616
+ color: #d4d4d8;
617
+ }
618
+
619
+ .route-no-component {
620
+ color: #f59e0b;
621
+ font-size: 10px;
622
+ }
623
+
624
+ .route-entry-path {
625
+ font-size: 11px;
626
+ color: #71717a;
627
+ font-family: monospace;
628
+ margin-top: 2px;
629
+ }
630
+
631
+ .route-entry-meta {
632
+ display: flex;
633
+ gap: 4px;
634
+ margin-top: 4px;
635
+ }
636
+
637
+ .meta-tag {
638
+ padding: 1px 6px;
639
+ font-size: 10px;
640
+ border-radius: 3px;
641
+ }
642
+
643
+ .meta-public {
644
+ background: #065f46;
645
+ color: #6ee7b7;
646
+ }
647
+
648
+ .meta-entity {
649
+ background: #1e3a5f;
650
+ color: #93c5fd;
651
+ }
652
+
653
+ .meta-permission {
654
+ background: #4c1d95;
655
+ color: #c4b5fd;
656
+ }
657
+ </style>
@@ -7,3 +7,4 @@ export { default as EntitiesPanel } from './EntitiesPanel.vue'
7
7
  export { default as ToastsPanel } from './ToastsPanel.vue'
8
8
  export { default as EntriesPanel } from './EntriesPanel.vue'
9
9
  export { default as SignalsPanel } from './SignalsPanel.vue'
10
+ export { default as RouterPanel } from './RouterPanel.vue'
@@ -13,6 +13,7 @@ export { ToastCollector } from './ToastCollector.js'
13
13
  export { ZonesCollector } from './ZonesCollector.js'
14
14
  export { AuthCollector } from './AuthCollector.js'
15
15
  export { EntitiesCollector } from './EntitiesCollector.js'
16
+ export { RouterCollector } from './RouterCollector.js'
16
17
  export { LocalStorageAdapter, createLocalStorageAdapter } from './LocalStorageAdapter.js'
17
18
 
18
19
  // Module System v2 integration
@@ -637,6 +637,18 @@ export class Kernel {
637
637
  ]
638
638
  }
639
639
 
640
+ // Add 404 catch-all route
641
+ const notFoundComponent = pages.notFound || (() => import('../components/pages/NotFoundPage.vue'))
642
+ const notFoundRoute = {
643
+ path: '/:pathMatch(.*)*',
644
+ name: 'not-found',
645
+ component: notFoundComponent,
646
+ meta: { public: true }
647
+ }
648
+
649
+ // Insert 404 route at the end of top-level routes
650
+ routes.push(notFoundRoute)
651
+
640
652
  this.router = createRouter({
641
653
  history: createWebHistory(basePath),
642
654
  routes