qdadm 0.36.0 → 0.38.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.
Files changed (43) hide show
  1. package/README.md +27 -174
  2. package/package.json +2 -1
  3. package/src/auth/SessionAuthAdapter.js +114 -3
  4. package/src/components/editors/PermissionEditor.vue +535 -0
  5. package/src/components/forms/FormField.vue +1 -11
  6. package/src/components/index.js +1 -0
  7. package/src/components/layout/AppLayout.vue +20 -8
  8. package/src/components/layout/defaults/DefaultToaster.vue +3 -3
  9. package/src/components/pages/LoginPage.vue +26 -5
  10. package/src/composables/useCurrentEntity.js +26 -17
  11. package/src/composables/useForm.js +7 -0
  12. package/src/composables/useFormPageBuilder.js +7 -0
  13. package/src/composables/useNavContext.js +30 -16
  14. package/src/core/index.js +0 -3
  15. package/src/debug/AuthCollector.js +175 -33
  16. package/src/debug/Collector.js +24 -2
  17. package/src/debug/EntitiesCollector.js +8 -0
  18. package/src/debug/SignalCollector.js +60 -2
  19. package/src/debug/components/panels/AuthPanel.vue +157 -27
  20. package/src/debug/components/panels/EntitiesPanel.vue +17 -1
  21. package/src/entity/EntityManager.js +183 -34
  22. package/src/entity/auth/EntityAuthAdapter.js +54 -46
  23. package/src/entity/auth/SecurityChecker.js +110 -42
  24. package/src/entity/auth/factory.js +11 -2
  25. package/src/entity/auth/factory.test.js +29 -0
  26. package/src/entity/storage/factory.test.js +6 -5
  27. package/src/index.js +3 -0
  28. package/src/kernel/Kernel.js +132 -21
  29. package/src/kernel/KernelContext.js +158 -0
  30. package/src/security/EntityRoleGranterAdapter.js +350 -0
  31. package/src/security/PermissionMatcher.js +148 -0
  32. package/src/security/PermissionRegistry.js +263 -0
  33. package/src/security/PersistableRoleGranterAdapter.js +618 -0
  34. package/src/security/RoleGranterAdapter.js +123 -0
  35. package/src/security/RoleGranterStorage.js +161 -0
  36. package/src/security/RolesManager.js +81 -0
  37. package/src/security/SecurityModule.js +73 -0
  38. package/src/security/StaticRoleGranterAdapter.js +114 -0
  39. package/src/security/UsersManager.js +122 -0
  40. package/src/security/index.js +45 -0
  41. package/src/security/pages/RoleForm.vue +212 -0
  42. package/src/security/pages/RoleList.vue +106 -0
  43. package/src/styles/main.css +62 -2
@@ -1,44 +1,53 @@
1
1
  /**
2
- * useCurrentEntity - Share page entity data with navigation context
2
+ * useCurrentEntity - Share page entity data with navigation context (breadcrumb)
3
3
  *
4
- * When a page loads an entity (e.g., ProductDetailPage fetches a product),
5
- * it can call setCurrentEntity() to share that data with the navigation context.
6
- * This avoids a second fetch for breadcrumb display.
4
+ * When a page loads an entity, it calls setBreadcrumbEntity() to share
5
+ * the data with AppLayout for breadcrumb display.
7
6
  *
8
7
  * Usage in a detail page:
9
8
  * ```js
10
- * const { setCurrentEntity } = useCurrentEntity()
9
+ * const { setBreadcrumbEntity } = useCurrentEntity()
11
10
  *
12
11
  * async function loadProduct() {
13
12
  * product.value = await productsManager.get(productId)
14
- * setCurrentEntity(product.value) // Share with navigation
13
+ * setBreadcrumbEntity(product.value) // Level 1 (main entity)
15
14
  * }
16
15
  * ```
17
16
  *
18
- * The navigation context (useNavContext) will use this data instead of
19
- * fetching the entity again for the breadcrumb.
17
+ * For nested routes with parent/child entities:
18
+ * ```js
19
+ * // Parent page loaded first
20
+ * setBreadcrumbEntity(book, 1) // Level 1: the book
21
+ *
22
+ * // Child page
23
+ * setBreadcrumbEntity(loan, 2) // Level 2: the loan under the book
24
+ * ```
20
25
  */
21
26
  import { inject } from 'vue'
22
27
 
23
28
  /**
24
- * Composable to share current page entity with navigation context
25
- * @returns {{ setCurrentEntity: (data: object) => void }}
29
+ * Composable to share page entity data with breadcrumb
30
+ * @returns {{ setBreadcrumbEntity: (data: object, level?: number) => void }}
26
31
  */
27
32
  export function useCurrentEntity() {
28
- const currentEntityData = inject('qdadmCurrentEntityData', null)
33
+ const setBreadcrumbEntityFn = inject('qdadmSetBreadcrumbEntity', null)
29
34
 
30
35
  /**
31
- * Set the current entity data for navigation context
32
- * Call this after loading an entity to avoid double fetch
36
+ * Set entity data for breadcrumb at a specific level
33
37
  * @param {object} data - Entity data
38
+ * @param {number} level - Breadcrumb level (1 = main entity, 2 = child, etc.)
34
39
  */
35
- function setCurrentEntity(data) {
36
- if (currentEntityData) {
37
- currentEntityData.value = data
40
+ function setBreadcrumbEntity(data, level = 1) {
41
+ if (setBreadcrumbEntityFn) {
42
+ setBreadcrumbEntityFn(data, level)
38
43
  }
39
44
  }
40
45
 
46
+ // Backwards compat alias
47
+ const setCurrentEntity = (data) => setBreadcrumbEntity(data, 1)
48
+
41
49
  return {
42
- setCurrentEntity
50
+ setBreadcrumbEntity,
51
+ setCurrentEntity // deprecated alias
43
52
  }
44
53
  }
@@ -65,6 +65,7 @@
65
65
  import { ref, computed, watch, onMounted, inject, provide } from 'vue'
66
66
  import { useBareForm } from './useBareForm'
67
67
  import { useHooks } from './useHooks'
68
+ import { useCurrentEntity } from './useCurrentEntity'
68
69
  import { deepClone } from '../utils/transformers'
69
70
 
70
71
  export function useForm(options = {}) {
@@ -93,6 +94,9 @@ export function useForm(options = {}) {
93
94
  // Get HookRegistry for form:alter hook (optional, may not exist in tests)
94
95
  const hooks = useHooks()
95
96
 
97
+ // Share entity data with navigation context (for breadcrumb)
98
+ const { setCurrentEntity } = useCurrentEntity()
99
+
96
100
  // Read config from manager with option overrides
97
101
  const routePrefix = options.routePrefix ?? manager.routePrefix
98
102
  const entityName = options.entityName ?? manager.label
@@ -241,6 +245,9 @@ export function useForm(options = {}) {
241
245
  originalData.value = deepClone(data)
242
246
  takeSnapshot()
243
247
 
248
+ // Share with navigation context for breadcrumb
249
+ setCurrentEntity(data)
250
+
244
251
  // Invoke form:alter hooks after data is loaded
245
252
  await invokeFormAlterHook()
246
253
 
@@ -84,6 +84,7 @@ import { useConfirm } from 'primevue/useconfirm'
84
84
  import { useDirtyState } from './useDirtyState'
85
85
  import { useUnsavedChangesGuard } from './useUnsavedChangesGuard'
86
86
  import { useBreadcrumb } from './useBreadcrumb'
87
+ import { useCurrentEntity } from './useCurrentEntity'
87
88
  import { registerGuardDialog, unregisterGuardDialog } from './useGuardStore'
88
89
  import { deepClone } from '../utils/transformers'
89
90
  import { onUnmounted } from 'vue'
@@ -133,6 +134,9 @@ export function useFormPageBuilder(config = {}) {
133
134
  // Provide entity context for child components (e.g., SeverityTag auto-discovery)
134
135
  provide('mainEntity', entity)
135
136
 
137
+ // Share entity data with navigation context (for breadcrumb)
138
+ const { setCurrentEntity } = useCurrentEntity()
139
+
136
140
  // Read config from manager with option overrides
137
141
  const entityName = config.entityName ?? manager.label
138
142
  const routePrefix = config.routePrefix ?? manager.routePrefix
@@ -258,6 +262,9 @@ export function useFormPageBuilder(config = {}) {
258
262
  originalData.value = deepClone(transformed)
259
263
  takeSnapshot()
260
264
 
265
+ // Share with navigation context for breadcrumb
266
+ setCurrentEntity(transformed)
267
+
261
268
  if (onLoadSuccess) {
262
269
  await onLoadSuccess(transformed)
263
270
  }
@@ -27,7 +27,7 @@
27
27
  * Path: /books/:bookId/loans/:id/edit meta: { entity: 'loans', parent: { entity: 'books', param: 'bookId' } }
28
28
  * → Home > Books > "Le Petit Prince" > Loans > "Loan #abc123"
29
29
  */
30
- import { ref, computed, watch, inject, unref } from 'vue'
30
+ import { ref, computed, watch, inject } from 'vue'
31
31
  import { useRoute, useRouter } from 'vue-router'
32
32
  import { getSiblingRoutes } from '../module/moduleRegistry.js'
33
33
 
@@ -42,6 +42,12 @@ export function useNavContext(options = {}) {
42
42
  const orchestrator = inject('qdadmOrchestrator', null)
43
43
  const homeRouteName = inject('qdadmHomeRoute', null)
44
44
 
45
+ // Breadcrumb entity data - multi-level Map from AppLayout
46
+ // Updated by pages via setBreadcrumbEntity(data, level)
47
+ // Can be passed directly (for layout component that provides AND uses breadcrumb)
48
+ // or injected from parent (for child pages)
49
+ const breadcrumbEntities = options.breadcrumbEntities ?? inject('qdadmBreadcrumbEntities', null)
50
+
45
51
  // Entity data cache
46
52
  const entityDataCache = ref(new Map())
47
53
 
@@ -215,34 +221,42 @@ export function useNavContext(options = {}) {
215
221
  *
216
222
  * For PARENT items: always fetches from manager
217
223
  */
218
- // Watch navChain and entityData ref (deep watch to catch value changes)
219
- const entityDataRef = computed(() => options.entityData ? unref(options.entityData) : null)
220
- watch([navChain, entityDataRef], async ([chain, externalData]) => {
224
+ // Watch navChain and breadcrumbEntities to populate chainData
225
+ // breadcrumbEntities is a ref to Map: level -> entityData (set by pages via setBreadcrumbEntity)
226
+ // Note: watch ref directly, not () => ref.value, for proper reactivity tracking
227
+ watch([navChain, breadcrumbEntities], async ([chain, entitiesMap]) => {
221
228
  // Build new Map (reassignment triggers Vue reactivity, Map.set() doesn't)
222
229
  const newChainData = new Map()
223
230
 
231
+ // Count item segments to determine their level (1-based)
232
+ let itemLevel = 0
233
+
224
234
  for (let i = 0; i < chain.length; i++) {
225
235
  const segment = chain[i]
226
236
  if (segment.type !== 'item') continue
227
237
 
238
+ itemLevel++
228
239
  const isLastItem = !chain.slice(i + 1).some(s => s.type === 'item')
229
240
 
230
- // For the last item, use external data from page (don't fetch)
231
- if (isLastItem) {
232
- if (externalData) {
233
- newChainData.set(i, externalData)
234
- }
235
- // If no externalData, breadcrumb will show "..." until page provides it
241
+ // Check if page provided data for this level via setBreadcrumbEntity
242
+ const providedData = entitiesMap?.get(itemLevel)
243
+ if (providedData) {
244
+ newChainData.set(i, providedData)
236
245
  continue
237
246
  }
238
247
 
239
- // For parent items, fetch from manager
240
- try {
241
- const data = await segment.manager.get(segment.id)
242
- newChainData.set(i, data)
243
- } catch (e) {
244
- console.warn(`[useNavContext] Failed to fetch ${segment.entity}:${segment.id}`, e)
248
+ // For items without provided data:
249
+ // - Last item: show "..." (page should call setBreadcrumbEntity)
250
+ // - Parent items: fetch from manager
251
+ if (!isLastItem) {
252
+ try {
253
+ const data = await segment.manager.get(segment.id)
254
+ newChainData.set(i, data)
255
+ } catch (e) {
256
+ console.warn(`[useNavContext] Failed to fetch ${segment.entity}:${segment.id}`, e)
257
+ }
245
258
  }
259
+ // Last item without data will show "..." in breadcrumb
246
260
  }
247
261
 
248
262
  // Assign new Map to trigger reactivity
package/src/core/index.js CHANGED
@@ -11,9 +11,6 @@ export {
11
11
 
12
12
  export {
13
13
  createDecoratedManager,
14
- withAuditLog as withAuditLogDecorator,
15
- withSoftDelete as withSoftDeleteDecorator,
16
- withTimestamps as withTimestampsDecorator,
17
14
  withValidation,
18
15
  } from './decorator.js'
19
16
 
@@ -40,9 +40,8 @@ export class AuthCollector extends Collector {
40
40
  this._ctx = null
41
41
  this._signalCleanups = []
42
42
  // Activity tracking for login/logout events
43
- // Keep recent events to show stacked (last N events)
43
+ // Events auto-expire after TTL (no max limit)
44
44
  this._recentEvents = [] // Array of { type: 'login'|'logout', timestamp: Date, seen: boolean }
45
- this._maxEvents = 5
46
45
  this._eventTtl = options.eventTtl ?? 60000 // Events expire after 60s by default
47
46
  this._expiryTimer = null
48
47
  }
@@ -97,6 +96,11 @@ export class AuthCollector extends Collector {
97
96
  this._addEvent('impersonate-stop', payload)
98
97
  })
99
98
  this._signalCleanups.push(impersonateStopCleanup)
99
+
100
+ const loginErrorCleanup = signals.on('auth:login:error', (payload) => {
101
+ this._addEvent('login-error', payload)
102
+ })
103
+ this._signalCleanups.push(loginErrorCleanup)
100
104
  }
101
105
 
102
106
  /**
@@ -113,23 +117,33 @@ export class AuthCollector extends Collector {
113
117
  seen: false,
114
118
  data
115
119
  })
116
- // Keep only last N events
117
- if (this._recentEvents.length > this._maxEvents) {
118
- this._recentEvents.pop()
119
- }
120
+ // Events auto-expire via TTL, no max limit
120
121
  this._scheduleExpiry()
121
122
  this.notifyChange()
122
123
  }
123
124
 
124
125
  /**
125
- * Schedule event expiry check
126
+ * Schedule event expiry check based on oldest event
126
127
  * @private
127
128
  */
128
129
  _scheduleExpiry() {
129
- if (this._expiryTimer) return // Already scheduled
130
+ // Clear existing timer
131
+ if (this._expiryTimer) {
132
+ clearTimeout(this._expiryTimer)
133
+ this._expiryTimer = null
134
+ }
135
+
136
+ if (this._recentEvents.length === 0) return
137
+
138
+ // Find the oldest event and calculate when it expires
139
+ const now = Date.now()
140
+ const oldest = this._recentEvents[this._recentEvents.length - 1]
141
+ const age = now - oldest.timestamp.getTime()
142
+ const delay = Math.max(100, this._eventTtl - age) // At least 100ms
143
+
130
144
  this._expiryTimer = setTimeout(() => {
131
145
  this._expireOldEvents()
132
- }, this._eventTtl)
146
+ }, delay)
133
147
  }
134
148
 
135
149
  /**
@@ -225,32 +239,71 @@ export class AuthCollector extends Collector {
225
239
  * @returns {Array<object>} Auth info as entries
226
240
  */
227
241
  getEntries() {
228
- if (!this._authAdapter) {
242
+ // Always get fresh authAdapter from ctx (may have updated state)
243
+ const authAdapter = this._ctx?.auth || this._ctx?.authAdapter || this._authAdapter
244
+ if (!authAdapter) {
229
245
  return [{ type: 'status', message: 'No auth adapter configured' }]
230
246
  }
231
247
 
232
248
  const entries = []
233
249
 
234
- // User info
250
+ // User info with effective permissions
235
251
  try {
236
- const user = this._authAdapter.getUser?.()
252
+ const user = authAdapter.getUser?.()
253
+ // Always get fresh security (created after collector install)
254
+ const securityChecker = this._ctx?.security || this._securityChecker
255
+
237
256
  if (user) {
257
+ // Check if impersonating (use fresh authAdapter)
258
+ const isImpersonating = authAdapter.isImpersonating?.() || false
259
+ const originalUser = isImpersonating ? authAdapter.getOriginalUser?.() : null
260
+
261
+ // Current User = the real logged in user (original when impersonating)
262
+ const realUser = originalUser || user
263
+ const realRoles = this._normalizeRoles(realUser.roles || realUser.role)
264
+ const realPermissions = this._getEffectivePermissions(securityChecker, realRoles)
265
+
238
266
  entries.push({
239
267
  type: 'user',
240
268
  label: 'Current User',
241
269
  data: {
242
- id: user.id || user.userId,
243
- username: user.username || user.name || user.email,
244
- email: user.email,
245
- roles: user.roles || [],
246
- ...this._sanitizeUser(user)
270
+ id: realUser.id || realUser.userId,
271
+ username: realUser.username || realUser.name || realUser.email,
272
+ email: realUser.email,
273
+ roles: realRoles,
274
+ permissions: realPermissions
247
275
  }
248
276
  })
277
+
278
+ // Impersonated User (when active) - shown separately with type 'impersonated'
279
+ if (isImpersonating) {
280
+ const impersonatedRoles = this._normalizeRoles(user.roles || user.role)
281
+ const impersonatedPermissions = this._getEffectivePermissions(securityChecker, impersonatedRoles)
282
+
283
+ entries.push({
284
+ type: 'impersonated',
285
+ label: 'Impersonated User',
286
+ data: {
287
+ id: user.id || user.userId,
288
+ username: user.username || user.name || user.email,
289
+ roles: impersonatedRoles,
290
+ permissions: impersonatedPermissions // These are the ACTIVE permissions!
291
+ }
292
+ })
293
+ }
249
294
  } else {
295
+ // Show anonymous role when not authenticated
296
+ const anonymousRole = securityChecker?.roleGranter?.getAnonymousRole?.() || 'ROLE_ANONYMOUS'
297
+ const effectivePermissions = this._getEffectivePermissions(securityChecker, [anonymousRole])
298
+
250
299
  entries.push({
251
- type: 'status',
252
- label: 'Status',
253
- message: 'Not authenticated'
300
+ type: 'user',
301
+ label: 'Anonymous',
302
+ data: {
303
+ role: anonymousRole,
304
+ authenticated: false,
305
+ permissions: effectivePermissions
306
+ }
254
307
  })
255
308
  }
256
309
  } catch (e) {
@@ -263,7 +316,7 @@ export class AuthCollector extends Collector {
263
316
 
264
317
  // Token info
265
318
  try {
266
- const token = this._authAdapter.getToken?.()
319
+ const token = authAdapter.getToken?.()
267
320
  if (token) {
268
321
  const decoded = this._decodeToken(token)
269
322
  entries.push({
@@ -281,14 +334,14 @@ export class AuthCollector extends Collector {
281
334
  // Token not available or decode failed
282
335
  }
283
336
 
284
- // Permissions
337
+ // User's effective permissions (from authAdapter)
285
338
  try {
286
- const permissions = this._authAdapter.getPermissions?.()
287
- if (permissions && permissions.length > 0) {
339
+ const userPermissions = authAdapter.getPermissions?.()
340
+ if (userPermissions && userPermissions.length > 0) {
288
341
  entries.push({
289
- type: 'permissions',
290
- label: 'Permissions',
291
- data: permissions
342
+ type: 'user-permissions',
343
+ label: 'User Permissions',
344
+ data: userPermissions
292
345
  })
293
346
  }
294
347
  } catch (e) {
@@ -318,23 +371,112 @@ export class AuthCollector extends Collector {
318
371
  // Security checker not available
319
372
  }
320
373
 
374
+ // Registered permissions from PermissionRegistry (flat list of all permission keys)
375
+ try {
376
+ // Try multiple paths to find permissionRegistry
377
+ const permissionRegistry = this._ctx?.permissionRegistry
378
+ || this._ctx?._kernel?.permissionRegistry
379
+ || this._ctx?.orchestrator?.kernel?.permissionRegistry
380
+ if (permissionRegistry && permissionRegistry.size > 0) {
381
+ // Get flat list of permission keys (e.g., ['entity:books:read', 'auth:impersonate'])
382
+ const permissions = permissionRegistry.getKeys()
383
+ entries.push({
384
+ type: 'permissions',
385
+ label: 'Permissions',
386
+ data: permissions
387
+ })
388
+ }
389
+ } catch (e) {
390
+ // Permission registry not available
391
+ console.warn('[AuthCollector] Error accessing permissionRegistry:', e)
392
+ }
393
+
321
394
  // Adapter info
322
395
  entries.push({
323
396
  type: 'adapter',
324
397
  label: 'Auth Adapter',
325
398
  data: {
326
- type: this._authAdapter.constructor?.name || 'Unknown',
327
- hasUser: !!this._authAdapter.getUser,
328
- hasToken: !!this._authAdapter.getToken,
329
- hasPermissions: !!this._authAdapter.getPermissions,
330
- hasLogin: !!this._authAdapter.login,
331
- hasLogout: !!this._authAdapter.logout
399
+ type: authAdapter.constructor?.name || 'Unknown',
400
+ hasUser: !!authAdapter.getUser,
401
+ hasToken: !!authAdapter.getToken,
402
+ hasPermissions: !!authAdapter.getPermissions,
403
+ hasLogin: !!authAdapter.login,
404
+ hasLogout: !!authAdapter.logout,
405
+ hasImpersonate: !!authAdapter.impersonate,
406
+ isImpersonating: authAdapter.isImpersonating?.() || false
332
407
  }
333
408
  })
334
409
 
335
410
  return entries
336
411
  }
337
412
 
413
+ /**
414
+ * Get effective permissions for a set of roles
415
+ * Uses SecurityChecker.getUserPermissions() which resolves hierarchy
416
+ *
417
+ * @param {object} securityChecker - SecurityChecker instance
418
+ * @param {string[]} roles - User's roles
419
+ * @returns {string[]} Effective permissions (deduplicated, sorted)
420
+ * @private
421
+ */
422
+ _getEffectivePermissions(securityChecker, roles) {
423
+ if (!securityChecker || !roles || roles.length === 0) {
424
+ return []
425
+ }
426
+
427
+ try {
428
+ // Use SecurityChecker.getUserPermissions with a mock user
429
+ if (securityChecker.getUserPermissions) {
430
+ const mockUser = { roles }
431
+ const perms = securityChecker.getUserPermissions(mockUser)
432
+ return [...new Set(perms)].sort()
433
+ }
434
+
435
+ // Fallback: direct access to roleGranter
436
+ const roleGranter = securityChecker.roleGranter
437
+ if (!roleGranter) return []
438
+
439
+ const permissions = new Set()
440
+ for (const role of roles) {
441
+ // Get reachable roles (includes inherited)
442
+ const reachable = securityChecker.roleHierarchy?.getReachableRoles?.(role) || [role]
443
+ for (const r of reachable) {
444
+ const rolePerms = roleGranter.getPermissions?.(r) || []
445
+ for (const perm of rolePerms) {
446
+ permissions.add(perm)
447
+ }
448
+ }
449
+ }
450
+
451
+ return [...permissions].sort()
452
+ } catch (e) {
453
+ console.warn('[AuthCollector] Error getting effective permissions:', e)
454
+ return []
455
+ }
456
+ }
457
+
458
+ /**
459
+ * Normalize roles to array with ROLE_ prefix
460
+ * Supports: 'admin' → ['ROLE_ADMIN'], ['user'] → ['ROLE_USER'], ['ROLE_ADMIN'] → ['ROLE_ADMIN']
461
+ *
462
+ * @param {string|string[]} roles - Role(s) to normalize
463
+ * @returns {string[]} Normalized roles array
464
+ * @private
465
+ */
466
+ _normalizeRoles(roles) {
467
+ if (!roles) return []
468
+
469
+ // Convert to array
470
+ const arr = Array.isArray(roles) ? roles : [roles]
471
+
472
+ // Add ROLE_ prefix if missing and uppercase
473
+ return arr.map(role => {
474
+ if (!role) return null
475
+ const upper = role.toUpperCase()
476
+ return upper.startsWith('ROLE_') ? upper : `ROLE_${upper}`
477
+ }).filter(Boolean)
478
+ }
479
+
338
480
  /**
339
481
  * Sanitize user object - remove sensitive fields
340
482
  * @param {object} user - User object
@@ -55,6 +55,7 @@ export class Collector {
55
55
  this._ctx = null
56
56
  this._seenCount = 0 // Number of entries that have been "seen"
57
57
  this._bridge = null // Set by DebugBridge when added
58
+ this._notifyCallbacks = [] // Direct notification subscribers
58
59
  }
59
60
 
60
61
  /**
@@ -120,8 +121,8 @@ export class Collector {
120
121
  this._seenCount--
121
122
  }
122
123
  }
123
- // Notify bridge for reactive UI update
124
- this._bridge?.notify()
124
+ // Notify bridge and direct subscribers
125
+ this.notifyChange()
125
126
  }
126
127
 
127
128
  /**
@@ -130,6 +131,27 @@ export class Collector {
130
131
  */
131
132
  notifyChange() {
132
133
  this._bridge?.notify()
134
+ // Call direct subscribers
135
+ for (const cb of this._notifyCallbacks) {
136
+ try {
137
+ cb()
138
+ } catch (e) {
139
+ console.warn('[Collector] Notify callback error:', e)
140
+ }
141
+ }
142
+ }
143
+
144
+ /**
145
+ * Subscribe to change notifications
146
+ * @param {Function} callback - Called when collector state changes
147
+ * @returns {Function} Unsubscribe function
148
+ */
149
+ onNotify(callback) {
150
+ this._notifyCallbacks.push(callback)
151
+ return () => {
152
+ const idx = this._notifyCallbacks.indexOf(callback)
153
+ if (idx >= 0) this._notifyCallbacks.splice(idx, 1)
154
+ }
133
155
  }
134
156
 
135
157
  /**
@@ -196,6 +196,13 @@ export class EntitiesCollector extends Collector {
196
196
  }
197
197
  }
198
198
 
199
+ // Sort: system entities first, then alphabetically
200
+ entries.sort((a, b) => {
201
+ if (a.system && !b.system) return -1
202
+ if (!a.system && b.system) return 1
203
+ return (a.name || '').localeCompare(b.name || '')
204
+ })
205
+
199
206
  return entries
200
207
  }
201
208
 
@@ -212,6 +219,7 @@ export class EntitiesCollector extends Collector {
212
219
 
213
220
  return {
214
221
  name,
222
+ system: manager.system ?? false,
215
223
  hasActivity: this._activeEntities.has(name),
216
224
  label: manager.label,
217
225
  labelPlural: manager.labelPlural,
@@ -28,6 +28,13 @@ export class SignalCollector extends Collector {
28
28
  */
29
29
  static name = 'signals'
30
30
 
31
+ /**
32
+ * Signals to skip recording (internal kernel signals with non-serializable data)
33
+ * @type {Set<string>}
34
+ * @private
35
+ */
36
+ static _skipSignals = new Set(['kernel:ready', 'kernel:shutdown'])
37
+
31
38
  /**
32
39
  * Internal install - subscribe to all signals
33
40
  *
@@ -45,14 +52,65 @@ export class SignalCollector extends Collector {
45
52
  // QuarKernel supports wildcards with the configured delimiter (:)
46
53
  // '**' matches all signals including multi-segment (entity:data-invalidate)
47
54
  this._unsubscribe = ctx.signals.on('**', (event) => {
55
+ // Skip internal signals with non-serializable data (kernel, orchestrator)
56
+ if (SignalCollector._skipSignals.has(event.name)) {
57
+ return
58
+ }
59
+
60
+ // Sanitize data to avoid cyclic references
61
+ const data = this._sanitizeData(event.data)
62
+
48
63
  this.record({
49
64
  name: event.name,
50
- data: event.data,
51
- source: event.data?.source ?? null
65
+ data,
66
+ source: data?.source ?? null
52
67
  })
53
68
  })
54
69
  }
55
70
 
71
+ /**
72
+ * Sanitize event data to remove non-serializable objects
73
+ *
74
+ * Handles cases where data contains references to Kernel, Orchestrator,
75
+ * or other complex objects that would cause cyclic reference errors.
76
+ *
77
+ * @param {*} data - Raw event data
78
+ * @returns {*} Sanitized data safe for recording
79
+ * @private
80
+ */
81
+ _sanitizeData(data) {
82
+ if (data === null || data === undefined) return data
83
+ if (typeof data !== 'object') return data
84
+
85
+ // Try to create a simple clone, fallback to description if cyclic
86
+ try {
87
+ // Quick check for obviously problematic properties
88
+ if (data.kernel || data.orchestrator || data._kernel) {
89
+ // Extract only safe properties
90
+ const safe = {}
91
+ for (const [key, value] of Object.entries(data)) {
92
+ if (key !== 'kernel' && key !== 'orchestrator' && key !== '_kernel') {
93
+ if (typeof value !== 'object' || value === null) {
94
+ safe[key] = value
95
+ } else if (Array.isArray(value)) {
96
+ safe[key] = `[Array(${value.length})]`
97
+ } else {
98
+ safe[key] = '[Object]'
99
+ }
100
+ }
101
+ }
102
+ return safe
103
+ }
104
+
105
+ // For other objects, try JSON roundtrip to detect cycles
106
+ JSON.stringify(data)
107
+ return data
108
+ } catch {
109
+ // If serialization fails, return a safe representation
110
+ return { _type: 'unserializable', keys: Object.keys(data) }
111
+ }
112
+ }
113
+
56
114
  /**
57
115
  * Internal uninstall - cleanup subscription
58
116
  * @protected