qdadm 0.34.0 → 0.35.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/EntitiesCollector.js +6 -3
- package/src/debug/SignalCollector.js +4 -4
- package/src/debug/components/DebugBar.vue +8 -1
- 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 +68 -16
- package/src/kernel/EventRouter.js +7 -4
package/package.json
CHANGED
|
@@ -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 (auto-invalidates on auth events)"
|
|
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 = false, // If true, auto-invalidate datalayer on auth events
|
|
62
63
|
// Scope control
|
|
63
64
|
scopeWhitelist = null, // Array of scopes/modules that can bypass restrictions
|
|
64
65
|
// Relations
|
|
@@ -85,6 +86,7 @@ export class EntityManager {
|
|
|
85
86
|
this.localFilterThreshold = localFilterThreshold
|
|
86
87
|
this._readOnly = readOnly
|
|
87
88
|
this._warmup = warmup
|
|
89
|
+
this._authSensitive = authSensitive
|
|
88
90
|
|
|
89
91
|
// Scope control
|
|
90
92
|
this._scopeWhitelist = scopeWhitelist
|
|
@@ -174,6 +176,25 @@ export class EntityManager {
|
|
|
174
176
|
this._signals.emitEntity(this.name, action, data)
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Emit entity:data-invalidate signal for client cache invalidation
|
|
181
|
+
*
|
|
182
|
+
* This is a unified signal for clients to know when entity data has changed.
|
|
183
|
+
* Clients can listen to `entity:data-invalidate` to refresh their views.
|
|
184
|
+
*
|
|
185
|
+
* @param {string} action - 'created', 'updated', 'deleted'
|
|
186
|
+
* @param {string|number} id - The affected record ID
|
|
187
|
+
* @private
|
|
188
|
+
*/
|
|
189
|
+
_emitDataInvalidate(action, id) {
|
|
190
|
+
if (!this._signals) return
|
|
191
|
+
this._signals.emit('entity:data-invalidate', {
|
|
192
|
+
entity: this.name,
|
|
193
|
+
action,
|
|
194
|
+
id
|
|
195
|
+
})
|
|
196
|
+
}
|
|
197
|
+
|
|
177
198
|
// ============ LIFECYCLE HOOKS ============
|
|
178
199
|
|
|
179
200
|
/**
|
|
@@ -860,6 +881,7 @@ export class EntityManager {
|
|
|
860
881
|
manager: this.name,
|
|
861
882
|
id: result?.[this.idField]
|
|
862
883
|
})
|
|
884
|
+
this._emitDataInvalidate('created', result?.[this.idField])
|
|
863
885
|
return result
|
|
864
886
|
}
|
|
865
887
|
throw new Error(`[EntityManager:${this.name}] create() not implemented`)
|
|
@@ -896,6 +918,7 @@ export class EntityManager {
|
|
|
896
918
|
manager: this.name,
|
|
897
919
|
id
|
|
898
920
|
})
|
|
921
|
+
this._emitDataInvalidate('updated', id)
|
|
899
922
|
return result
|
|
900
923
|
}
|
|
901
924
|
throw new Error(`[EntityManager:${this.name}] update() not implemented`)
|
|
@@ -932,6 +955,7 @@ export class EntityManager {
|
|
|
932
955
|
manager: this.name,
|
|
933
956
|
id
|
|
934
957
|
})
|
|
958
|
+
this._emitDataInvalidate('updated', id)
|
|
935
959
|
return result
|
|
936
960
|
}
|
|
937
961
|
throw new Error(`[EntityManager:${this.name}] patch() not implemented`)
|
|
@@ -959,6 +983,7 @@ export class EntityManager {
|
|
|
959
983
|
manager: this.name,
|
|
960
984
|
id
|
|
961
985
|
})
|
|
986
|
+
this._emitDataInvalidate('deleted', id)
|
|
962
987
|
return result
|
|
963
988
|
}
|
|
964
989
|
throw new Error(`[EntityManager:${this.name}] delete() not implemented`)
|
|
@@ -1143,11 +1168,12 @@ export class EntityManager {
|
|
|
1143
1168
|
|
|
1144
1169
|
const cleanups = []
|
|
1145
1170
|
|
|
1146
|
-
// Listen for parent
|
|
1171
|
+
// Listen for parent data changes (if parents defined)
|
|
1172
|
+
// When a parent entity's data changes, clear search cache to re-resolve parent fields
|
|
1147
1173
|
if (this._parents && Object.keys(this._parents).length > 0) {
|
|
1148
1174
|
const parentEntities = Object.values(this._parents).map(p => p.entity)
|
|
1149
1175
|
cleanups.push(
|
|
1150
|
-
this._signals.on('
|
|
1176
|
+
this._signals.on('entity:data-invalidate', ({ entity }) => {
|
|
1151
1177
|
if (parentEntities.includes(entity)) {
|
|
1152
1178
|
this._clearSearchCache()
|
|
1153
1179
|
}
|
|
@@ -1155,12 +1181,28 @@ export class EntityManager {
|
|
|
1155
1181
|
)
|
|
1156
1182
|
}
|
|
1157
1183
|
|
|
1158
|
-
//
|
|
1159
|
-
//
|
|
1160
|
-
//
|
|
1184
|
+
// Design choice: EntityManager-centric architecture
|
|
1185
|
+
// Storages stay simple (pure data access), EntityManager owns invalidation logic.
|
|
1186
|
+
//
|
|
1187
|
+
// Signal: entity:datalayer-invalidate { entity, actuator? }
|
|
1188
|
+
// - entity: target entity name, '*' for all, or null/undefined for all
|
|
1189
|
+
// - actuator: source of the signal ('auth' for auth events, undefined for manual)
|
|
1190
|
+
//
|
|
1191
|
+
// Routing is handled by EventRouter (configured at Kernel level):
|
|
1192
|
+
// 'auth:**' → { signal: 'entity:datalayer-invalidate', transform: () => ({ actuator: 'auth' }) }
|
|
1193
|
+
//
|
|
1194
|
+
// Only authSensitive entities react to actuator='auth' signals.
|
|
1195
|
+
// All entities react to manual signals (no actuator).
|
|
1196
|
+
|
|
1161
1197
|
cleanups.push(
|
|
1162
|
-
this._signals.on(
|
|
1163
|
-
|
|
1198
|
+
this._signals.on('entity:datalayer-invalidate', ({ entity, actuator } = {}) => {
|
|
1199
|
+
// Auth-triggered: only react if authSensitive
|
|
1200
|
+
if (actuator === 'auth' && !this._authSensitive) return
|
|
1201
|
+
|
|
1202
|
+
// Check entity match (global or targeted)
|
|
1203
|
+
if (!entity || entity === '*' || entity === this.name) {
|
|
1204
|
+
this.invalidateDataLayer()
|
|
1205
|
+
}
|
|
1164
1206
|
})
|
|
1165
1207
|
)
|
|
1166
1208
|
|
|
@@ -1216,24 +1258,34 @@ export class EntityManager {
|
|
|
1216
1258
|
}
|
|
1217
1259
|
|
|
1218
1260
|
/**
|
|
1219
|
-
* Invalidate the cache (
|
|
1261
|
+
* Invalidate the cache, forcing next list() to refetch
|
|
1220
1262
|
*
|
|
1221
|
-
*
|
|
1222
|
-
*
|
|
1263
|
+
* Note: entity:data-invalidate signal is emitted by CRUD methods,
|
|
1264
|
+
* not here (to include action and id context).
|
|
1223
1265
|
*/
|
|
1224
1266
|
invalidateCache() {
|
|
1225
|
-
const wasValid = this._cache.valid
|
|
1226
|
-
|
|
1227
1267
|
this._cache.valid = false
|
|
1228
1268
|
this._cache.items = []
|
|
1229
1269
|
this._cache.total = 0
|
|
1230
1270
|
this._cache.loadedAt = null
|
|
1231
1271
|
this._cacheLoading = null
|
|
1272
|
+
}
|
|
1273
|
+
|
|
1274
|
+
/**
|
|
1275
|
+
* Invalidate the entire data layer (cache + storage state)
|
|
1276
|
+
*
|
|
1277
|
+
* Called when external context changes (auth, impersonation, etc.)
|
|
1278
|
+
* that may affect what data the storage returns.
|
|
1279
|
+
*
|
|
1280
|
+
* - Clears EntityManager cache
|
|
1281
|
+
* - Calls storage.reset() if available (for storages with internal state)
|
|
1282
|
+
*/
|
|
1283
|
+
invalidateDataLayer() {
|
|
1284
|
+
this.invalidateCache()
|
|
1232
1285
|
|
|
1233
|
-
//
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
this._signals.emit('cache:entity:invalidated', { entity: this.name })
|
|
1286
|
+
// Reset storage if it supports it (e.g., clear auth tokens, cached responses)
|
|
1287
|
+
if (typeof this.storage?.reset === 'function') {
|
|
1288
|
+
this.storage.reset()
|
|
1237
1289
|
}
|
|
1238
1290
|
}
|
|
1239
1291
|
|
|
@@ -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
|
* })
|