qdadm 0.34.0 → 0.36.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.
- package/package.json +1 -1
- package/src/debug/AuthCollector.js +26 -0
- package/src/debug/EntitiesCollector.js +6 -3
- package/src/debug/SignalCollector.js +4 -4
- package/src/debug/components/DebugBar.vue +8 -1
- package/src/debug/components/panels/AuthPanel.vue +2 -0
- package/src/debug/components/panels/EntitiesPanel.vue +9 -0
- package/src/debug/components/panels/EntriesPanel.vue +17 -5
- package/src/debug/components/panels/SignalsPanel.vue +335 -0
- package/src/debug/components/panels/index.js +1 -0
- package/src/entity/EntityManager.js +88 -16
- package/src/kernel/EventRouter.js +7 -4
- package/src/kernel/Kernel.js +3 -4
- package/src/kernel/KernelContext.js +8 -0
package/package.json
CHANGED
|
@@ -36,6 +36,7 @@ export class AuthCollector extends Collector {
|
|
|
36
36
|
constructor(options = {}) {
|
|
37
37
|
super(options)
|
|
38
38
|
this._authAdapter = null
|
|
39
|
+
this._securityChecker = null
|
|
39
40
|
this._ctx = null
|
|
40
41
|
this._signalCleanups = []
|
|
41
42
|
// Activity tracking for login/logout events
|
|
@@ -58,6 +59,7 @@ export class AuthCollector extends Collector {
|
|
|
58
59
|
// Try alternate locations
|
|
59
60
|
this._authAdapter = ctx.authAdapter
|
|
60
61
|
}
|
|
62
|
+
this._securityChecker = ctx.security
|
|
61
63
|
this._setupSignals()
|
|
62
64
|
}
|
|
63
65
|
|
|
@@ -164,6 +166,7 @@ export class AuthCollector extends Collector {
|
|
|
164
166
|
}
|
|
165
167
|
this._signalCleanups = []
|
|
166
168
|
this._authAdapter = null
|
|
169
|
+
this._securityChecker = null
|
|
167
170
|
this._ctx = null
|
|
168
171
|
}
|
|
169
172
|
|
|
@@ -292,6 +295,29 @@ export class AuthCollector extends Collector {
|
|
|
292
295
|
// Permissions not available
|
|
293
296
|
}
|
|
294
297
|
|
|
298
|
+
// Role hierarchy & permissions (lazy fetch - securityChecker created after module connect)
|
|
299
|
+
try {
|
|
300
|
+
const securityChecker = this._securityChecker || this._ctx?.security
|
|
301
|
+
const hierarchy = securityChecker?.roleHierarchy?.map
|
|
302
|
+
if (hierarchy && Object.keys(hierarchy).length > 0) {
|
|
303
|
+
entries.push({
|
|
304
|
+
type: 'hierarchy',
|
|
305
|
+
label: 'Role Hierarchy',
|
|
306
|
+
data: hierarchy
|
|
307
|
+
})
|
|
308
|
+
}
|
|
309
|
+
const rolePermissions = securityChecker?.rolePermissions
|
|
310
|
+
if (rolePermissions && Object.keys(rolePermissions).length > 0) {
|
|
311
|
+
entries.push({
|
|
312
|
+
type: 'role-permissions',
|
|
313
|
+
label: 'Role Permissions',
|
|
314
|
+
data: rolePermissions
|
|
315
|
+
})
|
|
316
|
+
}
|
|
317
|
+
} catch (e) {
|
|
318
|
+
// Security checker not available
|
|
319
|
+
}
|
|
320
|
+
|
|
295
321
|
// Adapter info
|
|
296
322
|
entries.push({
|
|
297
323
|
type: 'adapter',
|
|
@@ -77,12 +77,12 @@ export class EntitiesCollector extends Collector {
|
|
|
77
77
|
})
|
|
78
78
|
this._signalCleanups.push(entityCleanup)
|
|
79
79
|
|
|
80
|
-
// Listen to
|
|
81
|
-
const
|
|
80
|
+
// Listen to entity data changes (CRUD operations)
|
|
81
|
+
const dataCleanup = signals.on('entity:data-invalidate', () => {
|
|
82
82
|
this._lastUpdate = Date.now()
|
|
83
83
|
this.notifyChange()
|
|
84
84
|
})
|
|
85
|
-
this._signalCleanups.push(
|
|
85
|
+
this._signalCleanups.push(dataCleanup)
|
|
86
86
|
}
|
|
87
87
|
|
|
88
88
|
/**
|
|
@@ -248,6 +248,9 @@ export class EntitiesCollector extends Collector {
|
|
|
248
248
|
canList: manager.canList?.() ?? true
|
|
249
249
|
},
|
|
250
250
|
|
|
251
|
+
// Auth sensitivity (auto-invalidates on auth events)
|
|
252
|
+
authSensitive: manager._authSensitive ?? false,
|
|
253
|
+
|
|
251
254
|
// Warmup
|
|
252
255
|
warmup: {
|
|
253
256
|
enabled: manager.warmupEnabled ?? false
|
|
@@ -18,8 +18,8 @@ import { Collector } from './Collector.js'
|
|
|
18
18
|
* Collector for SignalBus events
|
|
19
19
|
*
|
|
20
20
|
* Records all signals with their name, data, and source for debugging.
|
|
21
|
-
* Uses the
|
|
22
|
-
*
|
|
21
|
+
* Uses the `**` wildcard pattern to capture all signals including
|
|
22
|
+
* multi-segment names (e.g., entity:data-invalidate, auth:impersonate:start).
|
|
23
23
|
*/
|
|
24
24
|
export class SignalCollector extends Collector {
|
|
25
25
|
/**
|
|
@@ -43,8 +43,8 @@ export class SignalCollector extends Collector {
|
|
|
43
43
|
|
|
44
44
|
// Subscribe to all signals using wildcard pattern
|
|
45
45
|
// QuarKernel supports wildcards with the configured delimiter (:)
|
|
46
|
-
// '
|
|
47
|
-
this._unsubscribe = ctx.signals.on('
|
|
46
|
+
// '**' matches all signals including multi-segment (entity:data-invalidate)
|
|
47
|
+
this._unsubscribe = ctx.signals.on('**', (event) => {
|
|
48
48
|
this.record({
|
|
49
49
|
name: event.name,
|
|
50
50
|
data: event.data,
|
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
|
13
13
|
import Badge from 'primevue/badge'
|
|
14
14
|
import Button from 'primevue/button'
|
|
15
|
-
import { ZonesPanel, AuthPanel, EntitiesPanel, ToastsPanel, EntriesPanel } from './panels'
|
|
15
|
+
import { ZonesPanel, AuthPanel, EntitiesPanel, ToastsPanel, EntriesPanel, SignalsPanel } from './panels'
|
|
16
16
|
|
|
17
17
|
const props = defineProps({
|
|
18
18
|
bridge: { type: Object, required: true },
|
|
@@ -658,6 +658,13 @@ function getCollectorColor(name) {
|
|
|
658
658
|
@update="notifyBridge"
|
|
659
659
|
/>
|
|
660
660
|
|
|
661
|
+
<!-- Signals collector -->
|
|
662
|
+
<SignalsPanel
|
|
663
|
+
v-else-if="currentCollector.name === 'signals' || currentCollector.name === 'SignalCollector'"
|
|
664
|
+
:collector="currentCollector.collector"
|
|
665
|
+
:entries="currentCollector.entries"
|
|
666
|
+
/>
|
|
667
|
+
|
|
661
668
|
<div v-else-if="currentCollector.entries.length === 0" class="debug-empty">
|
|
662
669
|
<i class="pi pi-inbox" />
|
|
663
670
|
<span>No entries</span>
|
|
@@ -138,6 +138,11 @@ function getCapabilityLabel(cap) {
|
|
|
138
138
|
class="pi pi-eye perm-icon-readonly"
|
|
139
139
|
title="Read-only entity"
|
|
140
140
|
/>
|
|
141
|
+
<i
|
|
142
|
+
v-if="entity.authSensitive"
|
|
143
|
+
class="pi pi-shield perm-icon-auth-sensitive"
|
|
144
|
+
title="Auth-sensitive"
|
|
145
|
+
/>
|
|
141
146
|
</div>
|
|
142
147
|
<span class="entity-label">{{ entity.label }}</span>
|
|
143
148
|
<span v-if="entity.cache.enabled" class="entity-cache" :class="{ 'entity-cache-valid': entity.cache.valid }">
|
|
@@ -397,6 +402,10 @@ function getCapabilityLabel(cap) {
|
|
|
397
402
|
color: #f59e0b;
|
|
398
403
|
margin-left: 2px;
|
|
399
404
|
}
|
|
405
|
+
.perm-icon-auth-sensitive {
|
|
406
|
+
color: #8b5cf6;
|
|
407
|
+
margin-left: 2px;
|
|
408
|
+
}
|
|
400
409
|
.entity-label {
|
|
401
410
|
color: #a1a1aa;
|
|
402
411
|
font-size: 11px;
|
|
@@ -46,6 +46,7 @@ async function copyEntry(entry, idx) {
|
|
|
46
46
|
:class="{ 'entry-new': entry._isNew }"
|
|
47
47
|
>
|
|
48
48
|
<div class="entry-meta">
|
|
49
|
+
<span v-if="entry._isNew" class="entry-new-dot" title="New (unseen)" />
|
|
49
50
|
<span class="entry-time">{{ formatTime(entry.timestamp) }}</span>
|
|
50
51
|
<span v-if="entry.name" class="entry-name">{{ entry.name }}</span>
|
|
51
52
|
</div>
|
|
@@ -62,6 +63,7 @@ async function copyEntry(entry, idx) {
|
|
|
62
63
|
:class="{ 'entry-new': entry._isNew }"
|
|
63
64
|
>
|
|
64
65
|
<div class="entry-header">
|
|
66
|
+
<span v-if="entry._isNew" class="entry-new-dot" title="New (unseen)" />
|
|
65
67
|
<span class="entry-time">{{ formatTime(entry.timestamp) }}</span>
|
|
66
68
|
<span v-if="entry.name" class="entry-name">{{ entry.name }}</span>
|
|
67
69
|
<span v-if="entry.message" class="entry-message">{{ entry.message }}</span>
|
|
@@ -98,9 +100,6 @@ async function copyEntry(entry, idx) {
|
|
|
98
100
|
.entry-h:last-child {
|
|
99
101
|
border-right: none;
|
|
100
102
|
}
|
|
101
|
-
.entry-h.entry-new {
|
|
102
|
-
border-left: 3px solid #8b5cf6;
|
|
103
|
-
}
|
|
104
103
|
|
|
105
104
|
/* Vertical entries (right/fullscreen mode) */
|
|
106
105
|
.entries-v {
|
|
@@ -118,7 +117,7 @@ async function copyEntry(entry, idx) {
|
|
|
118
117
|
/* New entry indicator */
|
|
119
118
|
.entry-new {
|
|
120
119
|
position: relative;
|
|
121
|
-
background: rgba(
|
|
120
|
+
background: rgba(245, 158, 11, 0.08);
|
|
122
121
|
}
|
|
123
122
|
.entry-v.entry-new::before {
|
|
124
123
|
content: '';
|
|
@@ -127,7 +126,20 @@ async function copyEntry(entry, idx) {
|
|
|
127
126
|
top: 0;
|
|
128
127
|
bottom: 0;
|
|
129
128
|
width: 3px;
|
|
130
|
-
background: #
|
|
129
|
+
background: #f59e0b;
|
|
130
|
+
}
|
|
131
|
+
.entry-h.entry-new {
|
|
132
|
+
border-left: 3px solid #f59e0b;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.entry-new-dot {
|
|
136
|
+
display: inline-block;
|
|
137
|
+
width: 6px;
|
|
138
|
+
height: 6px;
|
|
139
|
+
background: #f59e0b;
|
|
140
|
+
border-radius: 50%;
|
|
141
|
+
margin-right: 4px;
|
|
142
|
+
flex-shrink: 0;
|
|
131
143
|
}
|
|
132
144
|
|
|
133
145
|
/* Entry parts */
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
<script setup>
|
|
2
|
+
/**
|
|
3
|
+
* SignalsPanel - Debug panel for signals with pattern filter
|
|
4
|
+
*
|
|
5
|
+
* Supports QuarKernel wildcard patterns:
|
|
6
|
+
* - auth:** → all auth signals (auth:login, auth:impersonate:start)
|
|
7
|
+
* - cache:** → all cache signals
|
|
8
|
+
* - *:created → all creation signals
|
|
9
|
+
* - ** → all signals (default)
|
|
10
|
+
*/
|
|
11
|
+
import { ref, computed, watch } from 'vue'
|
|
12
|
+
import InputText from 'primevue/inputtext'
|
|
13
|
+
import ObjectTree from '../ObjectTree.vue'
|
|
14
|
+
|
|
15
|
+
const props = defineProps({
|
|
16
|
+
collector: { type: Object, required: true },
|
|
17
|
+
entries: { type: Array, required: true }
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
// Filter state - persisted in localStorage
|
|
21
|
+
const STORAGE_KEY = 'qdadm-signals-filter'
|
|
22
|
+
const STORAGE_KEY_MAX = 'qdadm-signals-max'
|
|
23
|
+
const filterPattern = ref(localStorage.getItem(STORAGE_KEY) || '')
|
|
24
|
+
const maxSignals = ref(parseInt(localStorage.getItem(STORAGE_KEY_MAX)) || 50)
|
|
25
|
+
|
|
26
|
+
watch(filterPattern, (val) => {
|
|
27
|
+
localStorage.setItem(STORAGE_KEY, val)
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
watch(maxSignals, (val) => {
|
|
31
|
+
localStorage.setItem(STORAGE_KEY_MAX, String(val))
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
// Convert wildcard pattern to regex
|
|
35
|
+
function wildcardToRegex(pattern) {
|
|
36
|
+
if (!pattern || pattern === '**') return null // No filter
|
|
37
|
+
|
|
38
|
+
// Escape regex special chars except * and :
|
|
39
|
+
let regex = pattern
|
|
40
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
41
|
+
// ** matches anything (including colons)
|
|
42
|
+
.replace(/\*\*/g, '.*')
|
|
43
|
+
// * matches anything except colon
|
|
44
|
+
.replace(/\*/g, '[^:]*')
|
|
45
|
+
|
|
46
|
+
return new RegExp(`^${regex}$`)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const filterRegex = computed(() => wildcardToRegex(filterPattern.value.trim()))
|
|
50
|
+
|
|
51
|
+
// Apply filter, max limit, and reverse for top-down (newest first)
|
|
52
|
+
const filteredEntries = computed(() => {
|
|
53
|
+
let result = props.entries
|
|
54
|
+
if (filterRegex.value) {
|
|
55
|
+
result = result.filter(e => filterRegex.value.test(e.name))
|
|
56
|
+
}
|
|
57
|
+
// Apply max limit (slice from end to keep newest)
|
|
58
|
+
if (maxSignals.value > 0 && result.length > maxSignals.value) {
|
|
59
|
+
result = result.slice(-maxSignals.value)
|
|
60
|
+
}
|
|
61
|
+
// Reverse for top-down display (newest first)
|
|
62
|
+
return [...result].reverse()
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
const filterStats = computed(() => {
|
|
66
|
+
const total = props.entries.length
|
|
67
|
+
const shown = filteredEntries.value.length
|
|
68
|
+
return total !== shown ? `${shown}/${total}` : `${total}`
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// Preset filters
|
|
72
|
+
const presets = [
|
|
73
|
+
{ label: 'All', pattern: '' },
|
|
74
|
+
{ label: 'data', pattern: 'entity:data-invalidate' },
|
|
75
|
+
{ label: 'datalayer', pattern: 'entity:datalayer-invalidate' },
|
|
76
|
+
{ label: 'auth', pattern: 'auth:**' },
|
|
77
|
+
{ label: 'entity', pattern: 'entity:**' },
|
|
78
|
+
{ label: 'toast', pattern: 'toast:**' }
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
function applyPreset(pattern) {
|
|
82
|
+
filterPattern.value = pattern
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function formatTime(ts) {
|
|
86
|
+
const d = new Date(ts)
|
|
87
|
+
return d.toLocaleTimeString('en-US', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3, '0')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Extract domain from signal name (first segment)
|
|
91
|
+
function getDomain(name) {
|
|
92
|
+
return name.split(':')[0]
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const domainColors = {
|
|
96
|
+
auth: '#10b981',
|
|
97
|
+
cache: '#f59e0b',
|
|
98
|
+
entity: '#3b82f6',
|
|
99
|
+
toast: '#8b5cf6',
|
|
100
|
+
route: '#06b6d4',
|
|
101
|
+
error: '#ef4444'
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getDomainColor(name) {
|
|
105
|
+
const domain = getDomain(name)
|
|
106
|
+
return domainColors[domain] || '#6b7280'
|
|
107
|
+
}
|
|
108
|
+
</script>
|
|
109
|
+
|
|
110
|
+
<template>
|
|
111
|
+
<div class="signals-panel">
|
|
112
|
+
<!-- Filter bar -->
|
|
113
|
+
<div class="signals-filter">
|
|
114
|
+
<div class="signals-filter-input">
|
|
115
|
+
<i class="pi pi-filter" />
|
|
116
|
+
<InputText
|
|
117
|
+
v-model="filterPattern"
|
|
118
|
+
placeholder="Filter: auth:** cache:** *:created"
|
|
119
|
+
class="filter-input"
|
|
120
|
+
/>
|
|
121
|
+
<span class="signals-count">{{ filterStats }}</span>
|
|
122
|
+
<span class="signals-max-label">max</span>
|
|
123
|
+
<input
|
|
124
|
+
v-model.number="maxSignals"
|
|
125
|
+
type="number"
|
|
126
|
+
min="10"
|
|
127
|
+
max="500"
|
|
128
|
+
class="max-input"
|
|
129
|
+
/>
|
|
130
|
+
</div>
|
|
131
|
+
<div class="signals-presets">
|
|
132
|
+
<button
|
|
133
|
+
v-for="p in presets"
|
|
134
|
+
:key="p.pattern"
|
|
135
|
+
class="preset-btn"
|
|
136
|
+
:class="{ 'preset-active': filterPattern === p.pattern }"
|
|
137
|
+
@click="applyPreset(p.pattern)"
|
|
138
|
+
>
|
|
139
|
+
{{ p.label }}
|
|
140
|
+
</button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
|
|
144
|
+
<!-- Entries list -->
|
|
145
|
+
<div class="signals-list">
|
|
146
|
+
<div v-if="filteredEntries.length === 0" class="signals-empty">
|
|
147
|
+
<i class="pi pi-inbox" />
|
|
148
|
+
<span>{{ entries.length === 0 ? 'No signals' : 'No matching signals' }}</span>
|
|
149
|
+
</div>
|
|
150
|
+
|
|
151
|
+
<div
|
|
152
|
+
v-for="(entry, idx) in filteredEntries"
|
|
153
|
+
:key="idx"
|
|
154
|
+
class="signal-entry"
|
|
155
|
+
:class="{ 'signal-new': entry._isNew }"
|
|
156
|
+
>
|
|
157
|
+
<div class="signal-header">
|
|
158
|
+
<span v-if="entry._isNew" class="signal-new-dot" title="New (unseen)" />
|
|
159
|
+
<span class="signal-time">{{ formatTime(entry.timestamp) }}</span>
|
|
160
|
+
<span class="signal-name" :style="{ color: getDomainColor(entry.name) }">
|
|
161
|
+
{{ entry.name }}
|
|
162
|
+
</span>
|
|
163
|
+
</div>
|
|
164
|
+
<div v-if="entry.data && Object.keys(entry.data).length > 0" class="signal-data">
|
|
165
|
+
<ObjectTree :data="entry.data" :expanded="false" />
|
|
166
|
+
</div>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
</div>
|
|
170
|
+
</template>
|
|
171
|
+
|
|
172
|
+
<style scoped>
|
|
173
|
+
.signals-panel {
|
|
174
|
+
display: flex;
|
|
175
|
+
flex-direction: column;
|
|
176
|
+
height: 100%;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.signals-filter {
|
|
180
|
+
padding: 8px 12px;
|
|
181
|
+
background: #27272a;
|
|
182
|
+
border-bottom: 1px solid #3f3f46;
|
|
183
|
+
display: flex;
|
|
184
|
+
flex-direction: column;
|
|
185
|
+
gap: 6px;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
.signals-filter-input {
|
|
189
|
+
display: flex;
|
|
190
|
+
align-items: center;
|
|
191
|
+
gap: 8px;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.signals-filter-input .pi {
|
|
195
|
+
color: #71717a;
|
|
196
|
+
font-size: 12px;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.filter-input {
|
|
200
|
+
flex: 1;
|
|
201
|
+
font-size: 12px;
|
|
202
|
+
padding: 4px 8px;
|
|
203
|
+
background: #18181b;
|
|
204
|
+
border: 1px solid #3f3f46;
|
|
205
|
+
border-radius: 4px;
|
|
206
|
+
color: #f4f4f5;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
.filter-input:focus {
|
|
210
|
+
border-color: #8b5cf6;
|
|
211
|
+
outline: none;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
.signals-count {
|
|
215
|
+
font-size: 11px;
|
|
216
|
+
color: #71717a;
|
|
217
|
+
min-width: 40px;
|
|
218
|
+
text-align: right;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.signals-max-label {
|
|
222
|
+
font-size: 10px;
|
|
223
|
+
color: #52525b;
|
|
224
|
+
margin-left: 8px;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.max-input {
|
|
228
|
+
width: 50px;
|
|
229
|
+
font-size: 11px;
|
|
230
|
+
padding: 2px 4px;
|
|
231
|
+
background: #18181b;
|
|
232
|
+
border: 1px solid #3f3f46;
|
|
233
|
+
border-radius: 3px;
|
|
234
|
+
color: #a1a1aa;
|
|
235
|
+
text-align: center;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
.max-input:focus {
|
|
239
|
+
border-color: #8b5cf6;
|
|
240
|
+
outline: none;
|
|
241
|
+
color: #f4f4f5;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
.signals-presets {
|
|
245
|
+
display: flex;
|
|
246
|
+
gap: 4px;
|
|
247
|
+
flex-wrap: wrap;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
.preset-btn {
|
|
251
|
+
padding: 2px 8px;
|
|
252
|
+
font-size: 10px;
|
|
253
|
+
background: #3f3f46;
|
|
254
|
+
border: none;
|
|
255
|
+
border-radius: 3px;
|
|
256
|
+
color: #a1a1aa;
|
|
257
|
+
cursor: pointer;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
.preset-btn:hover {
|
|
261
|
+
background: #52525b;
|
|
262
|
+
color: #f4f4f5;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
.preset-active {
|
|
266
|
+
background: #8b5cf6;
|
|
267
|
+
color: white;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
.signals-list {
|
|
271
|
+
flex: 1;
|
|
272
|
+
overflow-y: auto;
|
|
273
|
+
padding: 4px;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
.signals-empty {
|
|
277
|
+
display: flex;
|
|
278
|
+
flex-direction: column;
|
|
279
|
+
align-items: center;
|
|
280
|
+
justify-content: center;
|
|
281
|
+
height: 100%;
|
|
282
|
+
color: #71717a;
|
|
283
|
+
gap: 8px;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
.signal-entry {
|
|
287
|
+
padding: 6px 8px;
|
|
288
|
+
margin-bottom: 2px;
|
|
289
|
+
background: #27272a;
|
|
290
|
+
border-radius: 4px;
|
|
291
|
+
font-size: 12px;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
.signal-entry:hover {
|
|
295
|
+
background: #3f3f46;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
.signal-entry.signal-new {
|
|
299
|
+
border-left: 2px solid #f59e0b;
|
|
300
|
+
padding-left: 6px;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
.signal-new-dot {
|
|
304
|
+
display: inline-block;
|
|
305
|
+
width: 6px;
|
|
306
|
+
height: 6px;
|
|
307
|
+
background: #f59e0b;
|
|
308
|
+
border-radius: 50%;
|
|
309
|
+
margin-right: 4px;
|
|
310
|
+
flex-shrink: 0;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.signal-header {
|
|
314
|
+
display: flex;
|
|
315
|
+
align-items: center;
|
|
316
|
+
gap: 8px;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.signal-time {
|
|
320
|
+
font-size: 10px;
|
|
321
|
+
color: #71717a;
|
|
322
|
+
font-family: monospace;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
.signal-name {
|
|
326
|
+
font-weight: 500;
|
|
327
|
+
font-family: monospace;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
.signal-data {
|
|
331
|
+
margin-top: 4px;
|
|
332
|
+
padding-left: 12px;
|
|
333
|
+
font-size: 11px;
|
|
334
|
+
}
|
|
335
|
+
</style>
|
|
@@ -6,3 +6,4 @@ export { default as AuthPanel } from './AuthPanel.vue'
|
|
|
6
6
|
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
|
+
export { default as SignalsPanel } from './SignalsPanel.vue'
|
|
@@ -59,6 +59,7 @@ export class EntityManager {
|
|
|
59
59
|
localFilterThreshold = null, // Items threshold to switch to local filtering (null = use default)
|
|
60
60
|
readOnly = false, // If true, canCreate/canUpdate/canDelete return false
|
|
61
61
|
warmup = true, // If true, cache is preloaded at boot via DeferredRegistry
|
|
62
|
+
authSensitive, // If true, auto-invalidate datalayer on auth events (auto-inferred from storage.requiresAuth if not set)
|
|
62
63
|
// Scope control
|
|
63
64
|
scopeWhitelist = null, // Array of scopes/modules that can bypass restrictions
|
|
64
65
|
// Relations
|
|
@@ -85,6 +86,8 @@ export class EntityManager {
|
|
|
85
86
|
this.localFilterThreshold = localFilterThreshold
|
|
86
87
|
this._readOnly = readOnly
|
|
87
88
|
this._warmup = warmup
|
|
89
|
+
// Auto-infer authSensitive from storage.requiresAuth if not explicitly set
|
|
90
|
+
this._authSensitive = authSensitive ?? this._getStorageRequiresAuth()
|
|
88
91
|
|
|
89
92
|
// Scope control
|
|
90
93
|
this._scopeWhitelist = scopeWhitelist
|
|
@@ -174,6 +177,25 @@ export class EntityManager {
|
|
|
174
177
|
this._signals.emitEntity(this.name, action, data)
|
|
175
178
|
}
|
|
176
179
|
|
|
180
|
+
/**
|
|
181
|
+
* Emit entity:data-invalidate signal for client cache invalidation
|
|
182
|
+
*
|
|
183
|
+
* This is a unified signal for clients to know when entity data has changed.
|
|
184
|
+
* Clients can listen to `entity:data-invalidate` to refresh their views.
|
|
185
|
+
*
|
|
186
|
+
* @param {string} action - 'created', 'updated', 'deleted'
|
|
187
|
+
* @param {string|number} id - The affected record ID
|
|
188
|
+
* @private
|
|
189
|
+
*/
|
|
190
|
+
_emitDataInvalidate(action, id) {
|
|
191
|
+
if (!this._signals) return
|
|
192
|
+
this._signals.emit('entity:data-invalidate', {
|
|
193
|
+
entity: this.name,
|
|
194
|
+
action,
|
|
195
|
+
id
|
|
196
|
+
})
|
|
197
|
+
}
|
|
198
|
+
|
|
177
199
|
// ============ LIFECYCLE HOOKS ============
|
|
178
200
|
|
|
179
201
|
/**
|
|
@@ -860,6 +882,7 @@ export class EntityManager {
|
|
|
860
882
|
manager: this.name,
|
|
861
883
|
id: result?.[this.idField]
|
|
862
884
|
})
|
|
885
|
+
this._emitDataInvalidate('created', result?.[this.idField])
|
|
863
886
|
return result
|
|
864
887
|
}
|
|
865
888
|
throw new Error(`[EntityManager:${this.name}] create() not implemented`)
|
|
@@ -896,6 +919,7 @@ export class EntityManager {
|
|
|
896
919
|
manager: this.name,
|
|
897
920
|
id
|
|
898
921
|
})
|
|
922
|
+
this._emitDataInvalidate('updated', id)
|
|
899
923
|
return result
|
|
900
924
|
}
|
|
901
925
|
throw new Error(`[EntityManager:${this.name}] update() not implemented`)
|
|
@@ -932,6 +956,7 @@ export class EntityManager {
|
|
|
932
956
|
manager: this.name,
|
|
933
957
|
id
|
|
934
958
|
})
|
|
959
|
+
this._emitDataInvalidate('updated', id)
|
|
935
960
|
return result
|
|
936
961
|
}
|
|
937
962
|
throw new Error(`[EntityManager:${this.name}] patch() not implemented`)
|
|
@@ -959,6 +984,7 @@ export class EntityManager {
|
|
|
959
984
|
manager: this.name,
|
|
960
985
|
id
|
|
961
986
|
})
|
|
987
|
+
this._emitDataInvalidate('deleted', id)
|
|
962
988
|
return result
|
|
963
989
|
}
|
|
964
990
|
throw new Error(`[EntityManager:${this.name}] delete() not implemented`)
|
|
@@ -1009,6 +1035,25 @@ export class EntityManager {
|
|
|
1009
1035
|
return caps.supportsTotal ?? false
|
|
1010
1036
|
}
|
|
1011
1037
|
|
|
1038
|
+
/**
|
|
1039
|
+
* Check if storage requires authentication
|
|
1040
|
+
*
|
|
1041
|
+
* Used to auto-infer authSensitive when not explicitly set.
|
|
1042
|
+
* Checks both instance capabilities and static capabilities.
|
|
1043
|
+
*
|
|
1044
|
+
* @returns {boolean} - true if storage requires auth
|
|
1045
|
+
* @private
|
|
1046
|
+
*/
|
|
1047
|
+
_getStorageRequiresAuth() {
|
|
1048
|
+
// Check instance capabilities first (may have dynamic requiresAuth)
|
|
1049
|
+
if (this.storage?.capabilities?.requiresAuth !== undefined) {
|
|
1050
|
+
return this.storage.capabilities.requiresAuth
|
|
1051
|
+
}
|
|
1052
|
+
// Fallback to static capabilities
|
|
1053
|
+
const caps = this.storage?.constructor?.capabilities || {}
|
|
1054
|
+
return caps.requiresAuth ?? false
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1012
1057
|
/**
|
|
1013
1058
|
* Get searchable fields declared by storage adapter
|
|
1014
1059
|
*
|
|
@@ -1143,11 +1188,12 @@ export class EntityManager {
|
|
|
1143
1188
|
|
|
1144
1189
|
const cleanups = []
|
|
1145
1190
|
|
|
1146
|
-
// Listen for parent
|
|
1191
|
+
// Listen for parent data changes (if parents defined)
|
|
1192
|
+
// When a parent entity's data changes, clear search cache to re-resolve parent fields
|
|
1147
1193
|
if (this._parents && Object.keys(this._parents).length > 0) {
|
|
1148
1194
|
const parentEntities = Object.values(this._parents).map(p => p.entity)
|
|
1149
1195
|
cleanups.push(
|
|
1150
|
-
this._signals.on('
|
|
1196
|
+
this._signals.on('entity:data-invalidate', ({ entity }) => {
|
|
1151
1197
|
if (parentEntities.includes(entity)) {
|
|
1152
1198
|
this._clearSearchCache()
|
|
1153
1199
|
}
|
|
@@ -1155,12 +1201,28 @@ export class EntityManager {
|
|
|
1155
1201
|
)
|
|
1156
1202
|
}
|
|
1157
1203
|
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1204
|
+
// Design choice: EntityManager-centric architecture
|
|
1205
|
+
// Storages stay simple (pure data access), EntityManager owns invalidation logic.
|
|
1206
|
+
//
|
|
1207
|
+
// Signal: entity:datalayer-invalidate { entity, actuator? }
|
|
1208
|
+
// - entity: target entity name, '*' for all, or null/undefined for all
|
|
1209
|
+
// - actuator: source of the signal ('auth' for auth events, undefined for manual)
|
|
1210
|
+
//
|
|
1211
|
+
// Routing is handled by EventRouter (configured at Kernel level):
|
|
1212
|
+
// 'auth:**' → { signal: 'entity:datalayer-invalidate', transform: () => ({ actuator: 'auth' }) }
|
|
1213
|
+
//
|
|
1214
|
+
// Only authSensitive entities react to actuator='auth' signals.
|
|
1215
|
+
// All entities react to manual signals (no actuator).
|
|
1216
|
+
|
|
1161
1217
|
cleanups.push(
|
|
1162
|
-
this._signals.on(
|
|
1163
|
-
|
|
1218
|
+
this._signals.on('entity:datalayer-invalidate', ({ entity, actuator } = {}) => {
|
|
1219
|
+
// Auth-triggered: only react if authSensitive
|
|
1220
|
+
if (actuator === 'auth' && !this._authSensitive) return
|
|
1221
|
+
|
|
1222
|
+
// Check entity match (global or targeted)
|
|
1223
|
+
if (!entity || entity === '*' || entity === this.name) {
|
|
1224
|
+
this.invalidateDataLayer()
|
|
1225
|
+
}
|
|
1164
1226
|
})
|
|
1165
1227
|
)
|
|
1166
1228
|
|
|
@@ -1216,24 +1278,34 @@ export class EntityManager {
|
|
|
1216
1278
|
}
|
|
1217
1279
|
|
|
1218
1280
|
/**
|
|
1219
|
-
* Invalidate the cache (
|
|
1281
|
+
* Invalidate the cache, forcing next list() to refetch
|
|
1220
1282
|
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1283
|
+
* Note: entity:data-invalidate signal is emitted by CRUD methods,
|
|
1284
|
+
* not here (to include action and id context).
|
|
1223
1285
|
*/
|
|
1224
1286
|
invalidateCache() {
|
|
1225
|
-
const wasValid = this._cache.valid
|
|
1226
|
-
|
|
1227
1287
|
this._cache.valid = false
|
|
1228
1288
|
this._cache.items = []
|
|
1229
1289
|
this._cache.total = 0
|
|
1230
1290
|
this._cache.loadedAt = null
|
|
1231
1291
|
this._cacheLoading = null
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
/**
|
|
1295
|
+
* Invalidate the entire data layer (cache + storage state)
|
|
1296
|
+
*
|
|
1297
|
+
* Called when external context changes (auth, impersonation, etc.)
|
|
1298
|
+
* that may affect what data the storage returns.
|
|
1299
|
+
*
|
|
1300
|
+
* - Clears EntityManager cache
|
|
1301
|
+
* - Calls storage.reset() if available (for storages with internal state)
|
|
1302
|
+
*/
|
|
1303
|
+
invalidateDataLayer() {
|
|
1304
|
+
this.invalidateCache()
|
|
1232
1305
|
|
|
1233
|
-
//
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
this._signals.emit('cache:entity:invalidated', { entity: this.name })
|
|
1306
|
+
// Reset storage if it supports it (e.g., clear auth tokens, cached responses)
|
|
1307
|
+
if (typeof this.storage?.reset === 'function') {
|
|
1308
|
+
this.storage.reset()
|
|
1237
1309
|
}
|
|
1238
1310
|
}
|
|
1239
1311
|
|
|
@@ -10,10 +10,13 @@
|
|
|
10
10
|
* signals, // SignalBus instance
|
|
11
11
|
* orchestrator, // Orchestrator instance (optional, for callbacks)
|
|
12
12
|
* routes: {
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
13
|
+
* // Auth events → datalayer invalidation with actuator (only authSensitive entities react)
|
|
14
|
+
* 'auth:**': [{ signal: 'entity:datalayer-invalidate', transform: () => ({ actuator: 'auth' }) }],
|
|
15
|
+
*
|
|
16
|
+
* // Custom routing with transform
|
|
17
|
+
* 'order:completed': [
|
|
18
|
+
* { signal: 'notify', transform: (p) => ({ msg: `Order ${p.id} done` }) },
|
|
19
|
+
* (payload, ctx) => { ... } // callback
|
|
17
20
|
* ]
|
|
18
21
|
* }
|
|
19
22
|
* })
|
package/src/kernel/Kernel.js
CHANGED
|
@@ -39,7 +39,7 @@
|
|
|
39
39
|
|
|
40
40
|
import { createApp, h } from 'vue'
|
|
41
41
|
import { createPinia } from 'pinia'
|
|
42
|
-
import { createRouter, createWebHistory
|
|
42
|
+
import { createRouter, createWebHistory } from 'vue-router'
|
|
43
43
|
import ToastService from 'primevue/toastservice'
|
|
44
44
|
import ConfirmationService from 'primevue/confirmationservice'
|
|
45
45
|
import Tooltip from 'primevue/tooltip'
|
|
@@ -86,7 +86,6 @@ export class Kernel {
|
|
|
86
86
|
* @param {string} options.homeRoute - Route name for home redirect (or object { name, component })
|
|
87
87
|
* @param {Array} options.coreRoutes - Additional routes as layout children (before module routes)
|
|
88
88
|
* @param {string} options.basePath - Base path for router (e.g., '/dashboard/')
|
|
89
|
-
* @param {boolean} options.hashMode - Use hash-based routing (/#/path) for static hosting
|
|
90
89
|
* @param {object} options.app - App config { name, shortName, version, logo, theme }
|
|
91
90
|
* @param {object} options.features - Feature toggles { auth, poweredBy }
|
|
92
91
|
* @param {object} options.primevue - PrimeVue config { plugin, theme, options }
|
|
@@ -520,7 +519,7 @@ export class Kernel {
|
|
|
520
519
|
* - **Layout-only mode**: Layout at root with all routes as children
|
|
521
520
|
*/
|
|
522
521
|
_createRouter() {
|
|
523
|
-
const { pages, homeRoute, coreRoutes, basePath
|
|
522
|
+
const { pages, homeRoute, coreRoutes, basePath } = this.options
|
|
524
523
|
|
|
525
524
|
// Layout is required (shell is optional)
|
|
526
525
|
if (!pages?.layout) {
|
|
@@ -598,7 +597,7 @@ export class Kernel {
|
|
|
598
597
|
}
|
|
599
598
|
|
|
600
599
|
this.router = createRouter({
|
|
601
|
-
history:
|
|
600
|
+
history: createWebHistory(basePath),
|
|
602
601
|
routes
|
|
603
602
|
})
|
|
604
603
|
}
|
|
@@ -121,6 +121,14 @@ export class KernelContext {
|
|
|
121
121
|
return this._kernel.options?.authAdapter ?? null
|
|
122
122
|
}
|
|
123
123
|
|
|
124
|
+
/**
|
|
125
|
+
* Get security checker (role hierarchy, permissions)
|
|
126
|
+
* @returns {import('../entity/auth/SecurityChecker.js').SecurityChecker|null}
|
|
127
|
+
*/
|
|
128
|
+
get security() {
|
|
129
|
+
return this._kernel.securityChecker
|
|
130
|
+
}
|
|
131
|
+
|
|
124
132
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
125
133
|
// Fluent registration methods (return this for chaining)
|
|
126
134
|
// ─────────────────────────────────────────────────────────────────────────────
|