qdadm 0.51.5 → 0.51.7
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/composables/useEntityItemFormPage.js +16 -13
- package/src/composables/useListPage.js +31 -2
- package/src/debug/EntitiesCollector.js +32 -1
- package/src/debug/components/panels/EntitiesPanel.vue +198 -64
- package/src/entity/EntityManager.js +65 -21
- package/src/entity/storage/ApiStorage.js +20 -31
- package/src/kernel/Kernel.js +14 -2
package/package.json
CHANGED
|
@@ -298,7 +298,11 @@ export function useEntityItemFormPage(config = {}) {
|
|
|
298
298
|
responseData = await manager.update(entityId.value, payload)
|
|
299
299
|
}
|
|
300
300
|
} else {
|
|
301
|
-
|
|
301
|
+
// Build context with parent chain for multi-storage routing
|
|
302
|
+
const context = parentConfig.value && parentId.value
|
|
303
|
+
? { parentChain: [{ entity: parentConfig.value.entity, id: parentId.value }] }
|
|
304
|
+
: undefined
|
|
305
|
+
responseData = await manager.create(payload, context)
|
|
302
306
|
}
|
|
303
307
|
|
|
304
308
|
toast.add({
|
|
@@ -323,18 +327,17 @@ export function useEntityItemFormPage(config = {}) {
|
|
|
323
327
|
const listRoute = findListRoute()
|
|
324
328
|
router.push(listRoute)
|
|
325
329
|
} else if (!isEdit.value) {
|
|
326
|
-
// "Create" without close:
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
})
|
|
330
|
+
// "Create" without close: navigate to edit route for the created entity
|
|
331
|
+
const createdId = responseData?.id || responseData?.uuid
|
|
332
|
+
if (createdId) {
|
|
333
|
+
// Build edit route: replace 'create' suffix with ':id/edit'
|
|
334
|
+
const currentRouteName = route.name || ''
|
|
335
|
+
const editRouteName = currentRouteName.replace(/(-create|-new)$/, '-edit')
|
|
336
|
+
router.push({
|
|
337
|
+
name: editRouteName,
|
|
338
|
+
params: { ...route.params, id: createdId }
|
|
339
|
+
})
|
|
340
|
+
}
|
|
338
341
|
}
|
|
339
342
|
// Edit mode without close: just stay on page (data already updated)
|
|
340
343
|
|
|
@@ -186,6 +186,34 @@ export function useListPage(config = {}) {
|
|
|
186
186
|
const parentLoading = computed(() => parentPage?.loading.value || false)
|
|
187
187
|
const parentChain = computed(() => parentPage?.parentChain.value || new Map())
|
|
188
188
|
|
|
189
|
+
/**
|
|
190
|
+
* Build entity context with parentChain array for multi-storage routing
|
|
191
|
+
* Format: { parentChain: [{ entity: 'grandparent', id: '1' }, { entity: 'parent', id: '42' }] }
|
|
192
|
+
* Array is ordered from root ancestor to immediate parent
|
|
193
|
+
*/
|
|
194
|
+
const entityContext = computed(() => {
|
|
195
|
+
if (!parentConfig.value || !parentId.value) {
|
|
196
|
+
return null
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Build chain from config (traversing from immediate parent to root)
|
|
200
|
+
const chain = []
|
|
201
|
+
let currentConfig = parentConfig.value
|
|
202
|
+
|
|
203
|
+
while (currentConfig) {
|
|
204
|
+
const entityId = route.params[currentConfig.param]
|
|
205
|
+
if (!entityId) break
|
|
206
|
+
|
|
207
|
+
chain.unshift({
|
|
208
|
+
entity: currentConfig.entity,
|
|
209
|
+
id: entityId
|
|
210
|
+
})
|
|
211
|
+
currentConfig = currentConfig.parent
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return chain.length > 0 ? { parentChain: chain } : null
|
|
215
|
+
})
|
|
216
|
+
|
|
189
217
|
// Read config from manager with option overrides
|
|
190
218
|
const entityName = config.entityName ?? manager.label
|
|
191
219
|
const entityNamePlural = config.entityNamePlural ?? manager.labelPlural
|
|
@@ -1124,9 +1152,10 @@ export function useListPage(config = {}) {
|
|
|
1124
1152
|
}
|
|
1125
1153
|
|
|
1126
1154
|
// Use manager.query() for automatic cache handling
|
|
1155
|
+
// Pass entityContext for multi-storage routing
|
|
1127
1156
|
const response = manager.query
|
|
1128
|
-
? await manager.query(params)
|
|
1129
|
-
: await manager.list(params)
|
|
1157
|
+
? await manager.query(params, { routingContext: entityContext.value })
|
|
1158
|
+
: await manager.list(params, entityContext.value)
|
|
1130
1159
|
|
|
1131
1160
|
// Track if response came from cache
|
|
1132
1161
|
fromCache.value = response.fromCache || false
|
|
@@ -345,7 +345,8 @@ export class EntitiesCollector extends Collector {
|
|
|
345
345
|
type: s.storage?.constructor?.storageName || s.storage?.constructor?.name || 'Unknown',
|
|
346
346
|
endpoint: s.storage?.endpoint || null,
|
|
347
347
|
hasNormalize: !!(s.storage?._normalize),
|
|
348
|
-
hasDenormalize: !!(s.storage?._denormalize)
|
|
348
|
+
hasDenormalize: !!(s.storage?._denormalize),
|
|
349
|
+
capabilities: s.storage?.capabilities || s.storage?.constructor?.capabilities || {}
|
|
349
350
|
}))
|
|
350
351
|
}
|
|
351
352
|
}
|
|
@@ -489,4 +490,34 @@ export class EntitiesCollector extends Collector {
|
|
|
489
490
|
}
|
|
490
491
|
}
|
|
491
492
|
}
|
|
493
|
+
|
|
494
|
+
/**
|
|
495
|
+
* Test fetch data from a specific storage of an entity
|
|
496
|
+
* @param {string} entityName - Entity name
|
|
497
|
+
* @param {string} storageName - Storage property name ('storage' for primary)
|
|
498
|
+
* @returns {Promise<{success: boolean, count?: number, error?: string, status?: number}>}
|
|
499
|
+
*/
|
|
500
|
+
async testStorageFetch(entityName, storageName) {
|
|
501
|
+
if (!this._orchestrator) {
|
|
502
|
+
return { success: false, error: 'No orchestrator' }
|
|
503
|
+
}
|
|
504
|
+
try {
|
|
505
|
+
const manager = this._orchestrator.get(entityName)
|
|
506
|
+
const storage = storageName === 'storage' ? manager.storage : manager[storageName]
|
|
507
|
+
if (!storage) {
|
|
508
|
+
return { success: false, error: `Storage '${storageName}' not found` }
|
|
509
|
+
}
|
|
510
|
+
const result = await storage.list({ page: 1, page_size: 1 })
|
|
511
|
+
return {
|
|
512
|
+
success: true,
|
|
513
|
+
count: result.total ?? result.items?.length ?? 0
|
|
514
|
+
}
|
|
515
|
+
} catch (e) {
|
|
516
|
+
return {
|
|
517
|
+
success: false,
|
|
518
|
+
error: e.message,
|
|
519
|
+
status: e.status
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
492
523
|
}
|
|
@@ -21,6 +21,8 @@ const expandedEntities = ref(new Set())
|
|
|
21
21
|
const loadingCache = ref(new Set())
|
|
22
22
|
const testingFetch = ref(new Set())
|
|
23
23
|
const testResults = ref(new Map()) // entityName -> { success, count, error, status }
|
|
24
|
+
const testingStorages = ref(new Set()) // "entityName:storageName"
|
|
25
|
+
const storageTestResults = ref(new Map()) // "entityName:storageName" -> { success, count, error, status }
|
|
24
26
|
|
|
25
27
|
function toggleExpand(name) {
|
|
26
28
|
if (expandedEntities.value.has(name)) {
|
|
@@ -82,6 +84,31 @@ async function testFetch(entityName) {
|
|
|
82
84
|
}
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
function isTestingStorage(entityName, storageName) {
|
|
88
|
+
return testingStorages.value.has(`${entityName}:${storageName}`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function getStorageTestResult(entityName, storageName) {
|
|
92
|
+
return storageTestResults.value.get(`${entityName}:${storageName}`)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function testStorageFetch(entityName, storageName) {
|
|
96
|
+
const key = `${entityName}:${storageName}`
|
|
97
|
+
if (testingStorages.value.has(key)) return
|
|
98
|
+
testingStorages.value.add(key)
|
|
99
|
+
testingStorages.value = new Set(testingStorages.value)
|
|
100
|
+
storageTestResults.value.delete(key)
|
|
101
|
+
storageTestResults.value = new Map(storageTestResults.value)
|
|
102
|
+
try {
|
|
103
|
+
const result = await props.collector.testStorageFetch(entityName, storageName)
|
|
104
|
+
storageTestResults.value.set(key, result)
|
|
105
|
+
storageTestResults.value = new Map(storageTestResults.value)
|
|
106
|
+
} finally {
|
|
107
|
+
testingStorages.value.delete(key)
|
|
108
|
+
testingStorages.value = new Set(testingStorages.value)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
85
112
|
function getCapabilityIcon(cap) {
|
|
86
113
|
const icons = {
|
|
87
114
|
supportsTotal: 'pi-hashtag',
|
|
@@ -164,41 +191,101 @@ function getCapabilityLabel(cap) {
|
|
|
164
191
|
|
|
165
192
|
<!-- Expanded details -->
|
|
166
193
|
<div v-else class="entity-details">
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
<
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
<
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
<i class="pi pi-sitemap" />
|
|
176
|
-
+{{ entity.multiStorage.storages.length }}
|
|
177
|
-
</span>
|
|
178
|
-
</div>
|
|
179
|
-
<!-- Multi-storage details -->
|
|
180
|
-
<div v-if="entity.multiStorage?.enabled && entity.multiStorage.storages.length > 0" class="entity-multi-storages">
|
|
181
|
-
<div v-for="s in entity.multiStorage.storages" :key="s.name" class="entity-multi-storage">
|
|
182
|
-
<span class="entity-storage-name">{{ s.name }}</span>
|
|
183
|
-
<span class="entity-storage-type">{{ s.type }}</span>
|
|
184
|
-
<span v-if="s.endpoint" class="entity-endpoint">{{ s.endpoint }}</span>
|
|
185
|
-
<span v-if="s.hasNormalize || s.hasDenormalize" class="entity-normalize-badge" title="Has normalize/denormalize">
|
|
186
|
-
<i class="pi pi-arrows-h" />
|
|
194
|
+
<!-- Storages section -->
|
|
195
|
+
<div class="entity-storages-section">
|
|
196
|
+
<div class="entity-storages-header">
|
|
197
|
+
<i class="pi pi-database" />
|
|
198
|
+
<span>Storages</span>
|
|
199
|
+
<span v-if="entity.multiStorage?.enabled" class="entity-multi-badge" title="Multi-storage routing">
|
|
200
|
+
<i class="pi pi-sitemap" />
|
|
201
|
+
{{ 1 + entity.multiStorage.storages.length }}
|
|
187
202
|
</span>
|
|
188
203
|
</div>
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
<
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
204
|
+
|
|
205
|
+
<!-- Primary storage -->
|
|
206
|
+
<div class="entity-storage-card">
|
|
207
|
+
<div class="entity-storage-header">
|
|
208
|
+
<span class="entity-storage-name">storage</span>
|
|
209
|
+
<span class="entity-storage-type">{{ entity.storage.type }}</span>
|
|
210
|
+
<span v-if="entity.storage.hasNormalize || entity.storage.hasDenormalize" class="entity-normalize-badge" title="Has normalize/denormalize">
|
|
211
|
+
<i class="pi pi-arrows-h" />
|
|
212
|
+
</span>
|
|
213
|
+
<button
|
|
214
|
+
class="entity-storage-fetch-btn"
|
|
215
|
+
:disabled="isTestingStorage(entity.name, 'storage')"
|
|
216
|
+
@click.stop="testStorageFetch(entity.name, 'storage')"
|
|
217
|
+
title="Test fetch on this storage"
|
|
218
|
+
>
|
|
219
|
+
<i :class="['pi', isTestingStorage(entity.name, 'storage') ? 'pi-spin pi-spinner' : 'pi-download']" />
|
|
220
|
+
</button>
|
|
221
|
+
</div>
|
|
222
|
+
<div v-if="entity.storage.endpoint" class="entity-storage-endpoint">
|
|
223
|
+
{{ entity.storage.endpoint }}
|
|
224
|
+
</div>
|
|
225
|
+
<div class="entity-storage-row">
|
|
226
|
+
<div v-if="entity.storage.capabilities && Object.keys(entity.storage.capabilities).length > 0" class="entity-storage-caps">
|
|
227
|
+
<span
|
|
228
|
+
v-for="(enabled, cap) in entity.storage.capabilities"
|
|
229
|
+
:key="cap"
|
|
230
|
+
class="entity-cap"
|
|
231
|
+
:class="[cap === 'requiresAuth' && enabled ? 'entity-cap-auth' : (enabled ? 'entity-cap-enabled' : 'entity-cap-disabled')]"
|
|
232
|
+
:title="getCapabilityLabel(cap) + (enabled ? ' ✓' : ' ✗')"
|
|
233
|
+
>
|
|
234
|
+
<i :class="['pi', getCapabilityIcon(cap)]" />
|
|
235
|
+
</span>
|
|
236
|
+
</div>
|
|
237
|
+
<span v-if="getStorageTestResult(entity.name, 'storage')" class="entity-storage-test-result" :class="getStorageTestResult(entity.name, 'storage').success ? 'test-success' : 'test-error'">
|
|
238
|
+
<template v-if="getStorageTestResult(entity.name, 'storage').success">
|
|
239
|
+
<i class="pi pi-check-circle" /> {{ getStorageTestResult(entity.name, 'storage').count }}
|
|
240
|
+
</template>
|
|
241
|
+
<template v-else>
|
|
242
|
+
<i class="pi pi-times-circle" /> {{ getStorageTestResult(entity.name, 'storage').status || 'ERR' }}
|
|
243
|
+
</template>
|
|
244
|
+
</span>
|
|
245
|
+
</div>
|
|
246
|
+
</div>
|
|
247
|
+
|
|
248
|
+
<!-- Additional storages -->
|
|
249
|
+
<div v-for="s in entity.multiStorage?.storages || []" :key="s.name" class="entity-storage-card entity-storage-alt">
|
|
250
|
+
<div class="entity-storage-header">
|
|
251
|
+
<span class="entity-storage-name">{{ s.name }}</span>
|
|
252
|
+
<span class="entity-storage-type">{{ s.type }}</span>
|
|
253
|
+
<span v-if="s.hasNormalize || s.hasDenormalize" class="entity-normalize-badge" title="Has normalize/denormalize">
|
|
254
|
+
<i class="pi pi-arrows-h" />
|
|
255
|
+
</span>
|
|
256
|
+
<button
|
|
257
|
+
class="entity-storage-fetch-btn"
|
|
258
|
+
:disabled="isTestingStorage(entity.name, s.name)"
|
|
259
|
+
@click.stop="testStorageFetch(entity.name, s.name)"
|
|
260
|
+
title="Test fetch on this storage"
|
|
261
|
+
>
|
|
262
|
+
<i :class="['pi', isTestingStorage(entity.name, s.name) ? 'pi-spin pi-spinner' : 'pi-download']" />
|
|
263
|
+
</button>
|
|
264
|
+
</div>
|
|
265
|
+
<div v-if="s.endpoint" class="entity-storage-endpoint">
|
|
266
|
+
{{ s.endpoint }}
|
|
267
|
+
</div>
|
|
268
|
+
<div class="entity-storage-row">
|
|
269
|
+
<div v-if="s.capabilities && Object.keys(s.capabilities).length > 0" class="entity-storage-caps">
|
|
270
|
+
<span
|
|
271
|
+
v-for="(enabled, cap) in s.capabilities"
|
|
272
|
+
:key="cap"
|
|
273
|
+
class="entity-cap"
|
|
274
|
+
:class="[cap === 'requiresAuth' && enabled ? 'entity-cap-auth' : (enabled ? 'entity-cap-enabled' : 'entity-cap-disabled')]"
|
|
275
|
+
:title="getCapabilityLabel(cap) + (enabled ? ' ✓' : ' ✗')"
|
|
276
|
+
>
|
|
277
|
+
<i :class="['pi', getCapabilityIcon(cap)]" />
|
|
278
|
+
</span>
|
|
279
|
+
</div>
|
|
280
|
+
<span v-if="getStorageTestResult(entity.name, s.name)" class="entity-storage-test-result" :class="getStorageTestResult(entity.name, s.name).success ? 'test-success' : 'test-error'">
|
|
281
|
+
<template v-if="getStorageTestResult(entity.name, s.name).success">
|
|
282
|
+
<i class="pi pi-check-circle" /> {{ getStorageTestResult(entity.name, s.name).count }}
|
|
283
|
+
</template>
|
|
284
|
+
<template v-else>
|
|
285
|
+
<i class="pi pi-times-circle" /> {{ getStorageTestResult(entity.name, s.name).status || 'ERR' }}
|
|
286
|
+
</template>
|
|
287
|
+
</span>
|
|
288
|
+
</div>
|
|
202
289
|
</div>
|
|
203
290
|
</div>
|
|
204
291
|
<div v-if="entity.cache.enabled" class="entity-row">
|
|
@@ -231,29 +318,6 @@ function getCapabilityLabel(cap) {
|
|
|
231
318
|
<span class="entity-key">Cache:</span>
|
|
232
319
|
<span class="entity-value entity-cache-na">Disabled</span>
|
|
233
320
|
</div>
|
|
234
|
-
<!-- Test Fetch row - always visible for testing auth protection -->
|
|
235
|
-
<div class="entity-row">
|
|
236
|
-
<span class="entity-key">Test:</span>
|
|
237
|
-
<span v-if="getTestResult(entity.name)" class="entity-test-result" :class="getTestResult(entity.name).success ? 'test-success' : 'test-error'">
|
|
238
|
-
<template v-if="getTestResult(entity.name).success">
|
|
239
|
-
<i class="pi pi-check-circle" />
|
|
240
|
-
{{ getTestResult(entity.name).count }} items
|
|
241
|
-
</template>
|
|
242
|
-
<template v-else>
|
|
243
|
-
<i class="pi pi-times-circle" />
|
|
244
|
-
{{ getTestResult(entity.name).status || 'ERR' }}: {{ getTestResult(entity.name).error }}
|
|
245
|
-
</template>
|
|
246
|
-
</span>
|
|
247
|
-
<span v-else class="entity-value entity-test-na">-</span>
|
|
248
|
-
<button
|
|
249
|
-
class="entity-test-btn"
|
|
250
|
-
:disabled="isTesting(entity.name)"
|
|
251
|
-
@click.stop="testFetch(entity.name)"
|
|
252
|
-
>
|
|
253
|
-
<i :class="['pi', isTesting(entity.name) ? 'pi-spin pi-spinner' : 'pi-download']" />
|
|
254
|
-
{{ isTesting(entity.name) ? 'Testing...' : 'Fetch' }}
|
|
255
|
-
</button>
|
|
256
|
-
</div>
|
|
257
321
|
<div class="entity-row">
|
|
258
322
|
<span class="entity-key">Fields:</span>
|
|
259
323
|
<span class="entity-value">{{ entity.fields.count }} fields</span>
|
|
@@ -620,27 +684,97 @@ function getCapabilityLabel(cap) {
|
|
|
620
684
|
font-size: 10px;
|
|
621
685
|
font-weight: 600;
|
|
622
686
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
margin-
|
|
687
|
+
/* Storages section */
|
|
688
|
+
.entity-storages-section {
|
|
689
|
+
margin-bottom: 8px;
|
|
690
|
+
}
|
|
691
|
+
.entity-storages-header {
|
|
692
|
+
display: flex;
|
|
693
|
+
align-items: center;
|
|
694
|
+
gap: 6px;
|
|
695
|
+
color: #a1a1aa;
|
|
696
|
+
font-size: 11px;
|
|
626
697
|
margin-bottom: 6px;
|
|
627
|
-
|
|
698
|
+
}
|
|
699
|
+
.entity-storages-header .pi {
|
|
700
|
+
font-size: 10px;
|
|
701
|
+
}
|
|
702
|
+
.entity-storage-card {
|
|
703
|
+
background: #1f1f23;
|
|
704
|
+
border-radius: 4px;
|
|
705
|
+
padding: 6px 8px;
|
|
706
|
+
margin-bottom: 4px;
|
|
628
707
|
border-left: 2px solid #3b82f6;
|
|
629
708
|
}
|
|
630
|
-
.entity-
|
|
709
|
+
.entity-storage-alt {
|
|
710
|
+
border-left-color: #8b5cf6;
|
|
711
|
+
}
|
|
712
|
+
.entity-storage-header {
|
|
631
713
|
display: flex;
|
|
632
714
|
align-items: center;
|
|
633
715
|
gap: 6px;
|
|
634
|
-
padding: 3px 0;
|
|
635
|
-
font-size: 10px;
|
|
636
716
|
}
|
|
637
717
|
.entity-storage-name {
|
|
638
718
|
color: #60a5fa;
|
|
639
719
|
font-weight: 500;
|
|
640
|
-
|
|
720
|
+
font-family: 'JetBrains Mono', monospace;
|
|
721
|
+
font-size: 11px;
|
|
722
|
+
}
|
|
723
|
+
.entity-storage-alt .entity-storage-name {
|
|
724
|
+
color: #a78bfa;
|
|
641
725
|
}
|
|
642
726
|
.entity-storage-type {
|
|
643
727
|
color: #a1a1aa;
|
|
728
|
+
font-size: 10px;
|
|
729
|
+
}
|
|
730
|
+
.entity-storage-endpoint {
|
|
731
|
+
color: #71717a;
|
|
732
|
+
font-family: 'JetBrains Mono', monospace;
|
|
733
|
+
font-size: 10px;
|
|
734
|
+
padding: 2px 0;
|
|
735
|
+
margin-top: 2px;
|
|
736
|
+
}
|
|
737
|
+
.entity-storage-row {
|
|
738
|
+
display: flex;
|
|
739
|
+
align-items: center;
|
|
740
|
+
gap: 8px;
|
|
741
|
+
margin-top: 4px;
|
|
742
|
+
}
|
|
743
|
+
.entity-storage-caps {
|
|
744
|
+
display: flex;
|
|
745
|
+
gap: 4px;
|
|
746
|
+
flex-wrap: wrap;
|
|
747
|
+
}
|
|
748
|
+
.entity-storage-fetch-btn {
|
|
749
|
+
display: inline-flex;
|
|
750
|
+
align-items: center;
|
|
751
|
+
justify-content: center;
|
|
752
|
+
width: 22px;
|
|
753
|
+
height: 22px;
|
|
754
|
+
padding: 0;
|
|
755
|
+
margin-left: auto;
|
|
756
|
+
background: #3b82f6;
|
|
757
|
+
border: none;
|
|
758
|
+
border-radius: 3px;
|
|
759
|
+
color: #fff;
|
|
760
|
+
cursor: pointer;
|
|
761
|
+
font-size: 10px;
|
|
762
|
+
}
|
|
763
|
+
.entity-storage-fetch-btn:hover {
|
|
764
|
+
background: #2563eb;
|
|
765
|
+
}
|
|
766
|
+
.entity-storage-fetch-btn:disabled {
|
|
767
|
+
opacity: 0.5;
|
|
768
|
+
cursor: not-allowed;
|
|
769
|
+
}
|
|
770
|
+
.entity-storage-test-result {
|
|
771
|
+
display: inline-flex;
|
|
772
|
+
align-items: center;
|
|
773
|
+
gap: 3px;
|
|
774
|
+
padding: 2px 6px;
|
|
775
|
+
border-radius: 3px;
|
|
776
|
+
font-size: 10px;
|
|
777
|
+
margin-left: auto;
|
|
644
778
|
}
|
|
645
779
|
.entity-capabilities {
|
|
646
780
|
display: flex;
|
|
@@ -916,7 +916,7 @@ export class EntityManager {
|
|
|
916
916
|
* @returns {Promise<{ items: Array, total: number, fromCache: boolean }>}
|
|
917
917
|
*/
|
|
918
918
|
async list(params = {}, context) {
|
|
919
|
-
const { storage } = this.resolveStorage('list', context)
|
|
919
|
+
const { storage, path } = this.resolveStorage('list', context)
|
|
920
920
|
if (!storage) {
|
|
921
921
|
throw new Error(`[EntityManager:${this.name}] list() not implemented`)
|
|
922
922
|
}
|
|
@@ -949,8 +949,22 @@ export class EntityManager {
|
|
|
949
949
|
|
|
950
950
|
if (!_internal) this._stats.cacheMisses++
|
|
951
951
|
|
|
952
|
-
// 2. Fetch from API
|
|
953
|
-
|
|
952
|
+
// 2. Fetch from API
|
|
953
|
+
let response
|
|
954
|
+
if (path && storage.request) {
|
|
955
|
+
// Use request() with path for multi-storage routing
|
|
956
|
+
// Build query params for GET request
|
|
957
|
+
const apiResponse = await storage.request('GET', path, { params: queryParams })
|
|
958
|
+
// Normalize response: handle both { data: [...], pagination: {...} } and { items, total }
|
|
959
|
+
const data = apiResponse.data ?? apiResponse
|
|
960
|
+
response = {
|
|
961
|
+
items: Array.isArray(data) ? data : (data.items || data.data || []),
|
|
962
|
+
total: data.total ?? data.pagination?.total ?? (Array.isArray(data) ? data.length : 0)
|
|
963
|
+
}
|
|
964
|
+
} else {
|
|
965
|
+
// Standard storage.list() (normalizes response to { items, total })
|
|
966
|
+
response = await storage.list(queryParams)
|
|
967
|
+
}
|
|
954
968
|
const items = response.items || []
|
|
955
969
|
const total = response.total ?? items.length
|
|
956
970
|
|
|
@@ -998,7 +1012,7 @@ export class EntityManager {
|
|
|
998
1012
|
* @returns {Promise<object>}
|
|
999
1013
|
*/
|
|
1000
1014
|
async get(id, context) {
|
|
1001
|
-
const { storage } = this.resolveStorage('get', context)
|
|
1015
|
+
const { storage, path } = this.resolveStorage('get', context)
|
|
1002
1016
|
this._stats.get++
|
|
1003
1017
|
|
|
1004
1018
|
// Try cache first if valid and complete
|
|
@@ -1014,6 +1028,11 @@ export class EntityManager {
|
|
|
1014
1028
|
// Fallback to storage
|
|
1015
1029
|
this._stats.cacheMisses++
|
|
1016
1030
|
if (storage) {
|
|
1031
|
+
// Use request() with path for multi-storage routing, otherwise use get()
|
|
1032
|
+
if (path && storage.request) {
|
|
1033
|
+
const response = await storage.request('GET', `${path}/${id}`)
|
|
1034
|
+
return response.data ?? response
|
|
1035
|
+
}
|
|
1017
1036
|
return storage.get(id)
|
|
1018
1037
|
}
|
|
1019
1038
|
throw new Error(`[EntityManager:${this.name}] get() not implemented`)
|
|
@@ -1069,15 +1088,21 @@ export class EntityManager {
|
|
|
1069
1088
|
* @returns {Promise<object>} - The created entity
|
|
1070
1089
|
*/
|
|
1071
1090
|
async create(data, context) {
|
|
1072
|
-
const { storage } = this.resolveStorage('create', context)
|
|
1091
|
+
const { storage, path } = this.resolveStorage('create', context)
|
|
1073
1092
|
this._stats.create++
|
|
1074
1093
|
if (storage) {
|
|
1075
1094
|
// Invoke presave hooks (can modify data or throw to abort)
|
|
1076
1095
|
const presaveContext = this._buildPresaveContext(data, true)
|
|
1077
1096
|
await this._invokeHook('presave', presaveContext)
|
|
1078
1097
|
|
|
1079
|
-
// Use
|
|
1080
|
-
|
|
1098
|
+
// Use request() with path for multi-storage routing, otherwise use create()
|
|
1099
|
+
let result
|
|
1100
|
+
if (path && storage.request) {
|
|
1101
|
+
const response = await storage.request('POST', path, { data: presaveContext.record })
|
|
1102
|
+
result = response.data ?? response
|
|
1103
|
+
} else {
|
|
1104
|
+
result = await storage.create(presaveContext.record)
|
|
1105
|
+
}
|
|
1081
1106
|
this.invalidateCache()
|
|
1082
1107
|
|
|
1083
1108
|
// Invoke postsave hooks (for side effects)
|
|
@@ -1108,15 +1133,21 @@ export class EntityManager {
|
|
|
1108
1133
|
* @returns {Promise<object>}
|
|
1109
1134
|
*/
|
|
1110
1135
|
async update(id, data, context) {
|
|
1111
|
-
const { storage } = this.resolveStorage('update', context)
|
|
1136
|
+
const { storage, path } = this.resolveStorage('update', context)
|
|
1112
1137
|
this._stats.update++
|
|
1113
1138
|
if (storage) {
|
|
1114
1139
|
// Invoke presave hooks (can modify data or throw to abort)
|
|
1115
1140
|
const presaveContext = this._buildPresaveContext(data, false, id)
|
|
1116
1141
|
await this._invokeHook('presave', presaveContext)
|
|
1117
1142
|
|
|
1118
|
-
// Use
|
|
1119
|
-
|
|
1143
|
+
// Use request() with path for multi-storage routing, otherwise use update()
|
|
1144
|
+
let result
|
|
1145
|
+
if (path && storage.request) {
|
|
1146
|
+
const response = await storage.request('PUT', `${path}/${id}`, { data: presaveContext.record })
|
|
1147
|
+
result = response.data ?? response
|
|
1148
|
+
} else {
|
|
1149
|
+
result = await storage.update(id, presaveContext.record)
|
|
1150
|
+
}
|
|
1120
1151
|
this.invalidateCache()
|
|
1121
1152
|
|
|
1122
1153
|
// Invoke postsave hooks (for side effects)
|
|
@@ -1143,17 +1174,25 @@ export class EntityManager {
|
|
|
1143
1174
|
*
|
|
1144
1175
|
* @param {string|number} id
|
|
1145
1176
|
* @param {object} data
|
|
1177
|
+
* @param {object} [context] - Routing context for multi-storage
|
|
1146
1178
|
* @returns {Promise<object>}
|
|
1147
1179
|
*/
|
|
1148
|
-
async patch(id, data) {
|
|
1180
|
+
async patch(id, data, context) {
|
|
1181
|
+
const { storage, path } = this.resolveStorage('patch', context)
|
|
1149
1182
|
this._stats.update++ // patch counts as update
|
|
1150
|
-
if (
|
|
1183
|
+
if (storage) {
|
|
1151
1184
|
// Invoke presave hooks (can modify data or throw to abort)
|
|
1152
1185
|
const presaveContext = this._buildPresaveContext(data, false, id)
|
|
1153
1186
|
await this._invokeHook('presave', presaveContext)
|
|
1154
1187
|
|
|
1155
|
-
// Use
|
|
1156
|
-
|
|
1188
|
+
// Use request() with path for multi-storage routing, otherwise use patch()
|
|
1189
|
+
let result
|
|
1190
|
+
if (path && storage.request) {
|
|
1191
|
+
const response = await storage.request('PATCH', `${path}/${id}`, { data: presaveContext.record })
|
|
1192
|
+
result = response.data ?? response
|
|
1193
|
+
} else {
|
|
1194
|
+
result = await storage.patch(id, presaveContext.record)
|
|
1195
|
+
}
|
|
1157
1196
|
this.invalidateCache()
|
|
1158
1197
|
|
|
1159
1198
|
// Invoke postsave hooks (for side effects)
|
|
@@ -1182,14 +1221,20 @@ export class EntityManager {
|
|
|
1182
1221
|
* @returns {Promise<void>}
|
|
1183
1222
|
*/
|
|
1184
1223
|
async delete(id, context) {
|
|
1185
|
-
const { storage } = this.resolveStorage('delete', context)
|
|
1224
|
+
const { storage, path } = this.resolveStorage('delete', context)
|
|
1186
1225
|
this._stats.delete++
|
|
1187
1226
|
if (storage) {
|
|
1188
1227
|
// Invoke predelete hooks (can throw to abort, e.g., for cascade checks)
|
|
1189
1228
|
const predeleteContext = this._buildPredeleteContext(id)
|
|
1190
1229
|
await this._invokeHook('predelete', predeleteContext)
|
|
1191
1230
|
|
|
1192
|
-
|
|
1231
|
+
// Use request() with path for multi-storage routing, otherwise use delete()
|
|
1232
|
+
let result
|
|
1233
|
+
if (path && storage.request) {
|
|
1234
|
+
result = await storage.request('DELETE', `${path}/${id}`)
|
|
1235
|
+
} else {
|
|
1236
|
+
result = await storage.delete(id)
|
|
1237
|
+
}
|
|
1193
1238
|
this.invalidateCache()
|
|
1194
1239
|
this._emitSignal('deleted', {
|
|
1195
1240
|
manager: this.name,
|
|
@@ -1638,25 +1683,24 @@ export class EntityManager {
|
|
|
1638
1683
|
* @param {object} params - Query params (search, filters, sort_by, sort_order, page, page_size)
|
|
1639
1684
|
* @param {object} [options] - Query options
|
|
1640
1685
|
* @param {object} [options.context] - Context info (module, form, field, scope, bypassPermissions, reason)
|
|
1686
|
+
* @param {object} [options.routingContext] - Multi-storage routing context with parentChain
|
|
1641
1687
|
* @returns {Promise<{ items: Array, total: number, fromCache: boolean }>}
|
|
1642
1688
|
*/
|
|
1643
1689
|
async query(params = {}, options = {}) {
|
|
1644
|
-
const { context = {} } = options
|
|
1690
|
+
const { context = {}, routingContext = null } = options
|
|
1645
1691
|
|
|
1646
1692
|
// Ensure cache is filled (via list)
|
|
1647
1693
|
if (!this._cache.valid && this.isCacheEnabled) {
|
|
1648
|
-
await this.list({ page_size: this.effectiveThreshold })
|
|
1694
|
+
await this.list({ page_size: this.effectiveThreshold }, routingContext)
|
|
1649
1695
|
}
|
|
1650
1696
|
|
|
1651
1697
|
let result
|
|
1652
1698
|
|
|
1653
1699
|
// If overflow or cache disabled, use API for accurate filtered results
|
|
1654
1700
|
if (this.overflow || !this.isCacheEnabled) {
|
|
1655
|
-
|
|
1656
|
-
result = await this.list(params)
|
|
1701
|
+
result = await this.list(params, routingContext)
|
|
1657
1702
|
} else {
|
|
1658
1703
|
// Full cache available - filter locally
|
|
1659
|
-
console.log('[cache] Using local cache for entity:', this.name)
|
|
1660
1704
|
const filtered = this._filterLocally(this._cache.items, params)
|
|
1661
1705
|
result = { ...filtered, fromCache: true }
|
|
1662
1706
|
}
|
|
@@ -21,16 +21,6 @@ import { IStorage } from './IStorage.js'
|
|
|
21
21
|
* getClient: () => inject('apiClient')
|
|
22
22
|
* })
|
|
23
23
|
*
|
|
24
|
-
* // With parameter mapping (transform filter names for API)
|
|
25
|
-
* const storage = new ApiStorage({
|
|
26
|
-
* endpoint: '/commands',
|
|
27
|
-
* client: apiClient,
|
|
28
|
-
* paramMapping: {
|
|
29
|
-
* bot_uuid: 'botUuid', // filters.bot_uuid → ?botUuid=xxx
|
|
30
|
-
* page_size: 'limit' // page_size → ?limit=xxx
|
|
31
|
-
* }
|
|
32
|
-
* })
|
|
33
|
-
*
|
|
34
24
|
* // With data normalization (different API format)
|
|
35
25
|
* const storage = new ApiStorage({
|
|
36
26
|
* endpoint: '/api/projects/:id/tasks',
|
|
@@ -84,8 +74,8 @@ export class ApiStorage extends IStorage {
|
|
|
84
74
|
// Response format configuration
|
|
85
75
|
responseItemsKey = 'items',
|
|
86
76
|
responseTotalKey = 'total',
|
|
87
|
-
// Parameter mapping
|
|
88
|
-
// Transforms filter
|
|
77
|
+
// WIP: Parameter mapping for filters { clientName: apiName }
|
|
78
|
+
// Transforms filter param names before sending to API
|
|
89
79
|
paramMapping = {},
|
|
90
80
|
// Data normalization
|
|
91
81
|
// normalize: (apiData) => internalData - transform API response
|
|
@@ -104,6 +94,23 @@ export class ApiStorage extends IStorage {
|
|
|
104
94
|
this._denormalize = denormalize
|
|
105
95
|
}
|
|
106
96
|
|
|
97
|
+
/**
|
|
98
|
+
* WIP: Apply parameter mapping to transform filter names
|
|
99
|
+
* @param {object} params - Original params
|
|
100
|
+
* @returns {object} - Params with mapped names
|
|
101
|
+
*/
|
|
102
|
+
_applyParamMapping(params) {
|
|
103
|
+
if (!this.paramMapping || Object.keys(this.paramMapping).length === 0) {
|
|
104
|
+
return params
|
|
105
|
+
}
|
|
106
|
+
const mapped = {}
|
|
107
|
+
for (const [key, value] of Object.entries(params)) {
|
|
108
|
+
const mappedKey = this.paramMapping[key] || key
|
|
109
|
+
mapped[mappedKey] = value
|
|
110
|
+
}
|
|
111
|
+
return mapped
|
|
112
|
+
}
|
|
113
|
+
|
|
107
114
|
/**
|
|
108
115
|
* Normalize API response data to internal format
|
|
109
116
|
* @param {object|Array} data - API response data
|
|
@@ -127,24 +134,6 @@ export class ApiStorage extends IStorage {
|
|
|
127
134
|
return this._denormalize(data)
|
|
128
135
|
}
|
|
129
136
|
|
|
130
|
-
/**
|
|
131
|
-
* Apply parameter mapping to transform names
|
|
132
|
-
* @param {object} params - Original params
|
|
133
|
-
* @returns {object} - Params with mapped names
|
|
134
|
-
*/
|
|
135
|
-
_applyParamMapping(params) {
|
|
136
|
-
if (!this.paramMapping || Object.keys(this.paramMapping).length === 0) {
|
|
137
|
-
return params
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
const mapped = {}
|
|
141
|
-
for (const [key, value] of Object.entries(params)) {
|
|
142
|
-
const mappedKey = this.paramMapping[key] || key
|
|
143
|
-
mapped[mappedKey] = value
|
|
144
|
-
}
|
|
145
|
-
return mapped
|
|
146
|
-
}
|
|
147
|
-
|
|
148
137
|
get client() {
|
|
149
138
|
if (this._getClient) {
|
|
150
139
|
return this._getClient()
|
|
@@ -169,7 +158,7 @@ export class ApiStorage extends IStorage {
|
|
|
169
158
|
async list(params = {}) {
|
|
170
159
|
const { page = 1, page_size = 20, sort_by, sort_order, filters = {} } = params
|
|
171
160
|
|
|
172
|
-
// Apply param mapping to filters
|
|
161
|
+
// WIP: Apply param mapping to filters
|
|
173
162
|
const mappedFilters = this._applyParamMapping(filters)
|
|
174
163
|
|
|
175
164
|
const response = await this.client.get(this.endpoint, {
|
package/src/kernel/Kernel.js
CHANGED
|
@@ -48,6 +48,7 @@ import { createQdadm } from '../plugin.js'
|
|
|
48
48
|
import { initModules, getRoutes, setSectionOrder, alterMenuSections, registry } from '../module/moduleRegistry.js'
|
|
49
49
|
import { createModuleLoader } from './ModuleLoader.js'
|
|
50
50
|
import { createKernelContext } from './KernelContext.js'
|
|
51
|
+
import { ToastBridgeModule } from '../toast/ToastBridgeModule.js'
|
|
51
52
|
import { Orchestrator } from '../orchestrator/Orchestrator.js'
|
|
52
53
|
import { createSignalBus } from './SignalBus.js'
|
|
53
54
|
import { createZoneRegistry } from '../zones/ZoneRegistry.js'
|
|
@@ -98,6 +99,7 @@ export class Kernel {
|
|
|
98
99
|
* @param {object} options.eventRouter - EventRouter config { 'source:signal': ['target:signal', ...] }
|
|
99
100
|
* @param {object} options.sse - SSEBridge config { url, reconnectDelay, signalPrefix, autoConnect, events }
|
|
100
101
|
* @param {object} options.debugBar - Debug bar config { module: DebugModule, component: QdadmDebugBar, ...options }
|
|
102
|
+
* @param {boolean} options.toast - Enable ToastBridgeModule (default: true). Set to false to disable.
|
|
101
103
|
*/
|
|
102
104
|
constructor(options) {
|
|
103
105
|
// Auto-inject DebugModule if debugBar.module is provided
|
|
@@ -122,6 +124,15 @@ export class Kernel {
|
|
|
122
124
|
options.debug = true
|
|
123
125
|
}
|
|
124
126
|
}
|
|
127
|
+
|
|
128
|
+
// Auto-inject ToastBridgeModule unless toast: false
|
|
129
|
+
// Handles toast:* signals from useSignalToast() composable
|
|
130
|
+
if (options.toast !== false) {
|
|
131
|
+
options.moduleDefs = options.moduleDefs || []
|
|
132
|
+
// Add at beginning for high priority (loads early)
|
|
133
|
+
options.moduleDefs.unshift(new ToastBridgeModule())
|
|
134
|
+
}
|
|
135
|
+
|
|
125
136
|
this.options = options
|
|
126
137
|
this.vueApp = null
|
|
127
138
|
this.router = null
|
|
@@ -569,11 +580,12 @@ export class Kernel {
|
|
|
569
580
|
}
|
|
570
581
|
|
|
571
582
|
// Build home route
|
|
583
|
+
// Note: Must have a name to avoid Vue Router warning when parent (_layout) has a name
|
|
572
584
|
let homeRouteConfig
|
|
573
585
|
if (typeof homeRoute === 'object' && homeRoute.component) {
|
|
574
|
-
homeRouteConfig = { path: '', ...homeRoute }
|
|
586
|
+
homeRouteConfig = { path: '', name: homeRoute.name || '_home', ...homeRoute }
|
|
575
587
|
} else {
|
|
576
|
-
homeRouteConfig = { path: '', redirect: { name: homeRoute || 'home' } }
|
|
588
|
+
homeRouteConfig = { path: '', name: '_home', redirect: { name: homeRoute || 'home' } }
|
|
577
589
|
}
|
|
578
590
|
|
|
579
591
|
// Collect all module routes
|