qdadm 0.29.0 → 0.31.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 +11 -2
- package/src/components/forms/FormPage.vue +1 -1
- package/src/components/index.js +5 -3
- package/src/components/layout/AppLayout.vue +13 -1
- package/src/components/layout/Zone.vue +40 -23
- package/src/composables/index.js +2 -1
- package/src/composables/useAuth.js +43 -4
- package/src/composables/useCurrentEntity.js +44 -0
- package/src/composables/useFormPageBuilder.js +3 -3
- package/src/composables/useNavContext.js +24 -8
- package/src/composables/useSSEBridge.js +118 -0
- package/src/debug/AuthCollector.js +254 -0
- package/src/debug/Collector.js +235 -0
- package/src/debug/DebugBridge.js +163 -0
- package/src/debug/DebugModule.js +215 -0
- package/src/debug/EntitiesCollector.js +376 -0
- package/src/debug/ErrorCollector.js +66 -0
- package/src/debug/LocalStorageAdapter.js +150 -0
- package/src/debug/SignalCollector.js +87 -0
- package/src/debug/ToastCollector.js +82 -0
- package/src/debug/ZonesCollector.js +300 -0
- package/src/debug/components/DebugBar.vue +1232 -0
- package/src/debug/components/ObjectTree.vue +194 -0
- package/src/debug/components/index.js +8 -0
- package/src/debug/components/panels/AuthPanel.vue +103 -0
- package/src/debug/components/panels/EntitiesPanel.vue +616 -0
- package/src/debug/components/panels/EntriesPanel.vue +188 -0
- package/src/debug/components/panels/ToastsPanel.vue +112 -0
- package/src/debug/components/panels/ZonesPanel.vue +232 -0
- package/src/debug/components/panels/index.js +8 -0
- package/src/debug/index.js +31 -0
- package/src/editors/index.js +12 -0
- package/src/entity/EntityManager.js +142 -20
- package/src/entity/storage/MockApiStorage.js +17 -1
- package/src/entity/storage/index.js +9 -2
- package/src/gen/index.js +3 -0
- package/src/index.js +7 -0
- package/src/kernel/Kernel.js +661 -48
- package/src/kernel/KernelContext.js +385 -0
- package/src/kernel/Module.js +111 -0
- package/src/kernel/ModuleLoader.js +573 -0
- package/src/kernel/SSEBridge.js +354 -0
- package/src/kernel/SignalBus.js +10 -7
- package/src/kernel/index.js +19 -0
- package/src/toast/ToastBridgeModule.js +70 -0
- package/src/toast/ToastListener.vue +47 -0
- package/src/toast/index.js +15 -0
- package/src/toast/useSignalToast.js +113 -0
- package/src/composables/useSSE.js +0 -212
package/src/kernel/Kernel.js
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
* ```
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
-
import { createApp } from 'vue'
|
|
40
|
+
import { createApp, h } from 'vue'
|
|
41
41
|
import { createPinia } from 'pinia'
|
|
42
42
|
import { createRouter, createWebHistory, createWebHashHistory } from 'vue-router'
|
|
43
43
|
import ToastService from 'primevue/toastservice'
|
|
@@ -45,7 +45,9 @@ import ConfirmationService from 'primevue/confirmationservice'
|
|
|
45
45
|
import Tooltip from 'primevue/tooltip'
|
|
46
46
|
|
|
47
47
|
import { createQdadm } from '../plugin.js'
|
|
48
|
-
import { initModules, getRoutes, setSectionOrder, alterMenuSections } from '../module/moduleRegistry.js'
|
|
48
|
+
import { initModules, getRoutes, setSectionOrder, alterMenuSections, registry } from '../module/moduleRegistry.js'
|
|
49
|
+
import { createModuleLoader } from './ModuleLoader.js'
|
|
50
|
+
import { createKernelContext } from './KernelContext.js'
|
|
49
51
|
import { Orchestrator } from '../orchestrator/Orchestrator.js'
|
|
50
52
|
import { createSignalBus } from './SignalBus.js'
|
|
51
53
|
import { createZoneRegistry } from '../zones/ZoneRegistry.js'
|
|
@@ -56,13 +58,20 @@ import { createManagers } from '../entity/factory.js'
|
|
|
56
58
|
import { defaultStorageResolver } from '../entity/storage/factory.js'
|
|
57
59
|
import { createDeferredRegistry } from '../deferred/DeferredRegistry.js'
|
|
58
60
|
import { createEventRouter } from './EventRouter.js'
|
|
61
|
+
import { createSSEBridge } from './SSEBridge.js'
|
|
62
|
+
|
|
63
|
+
// Debug imports are dynamic to enable tree-shaking in production
|
|
64
|
+
// When debugBar: false/undefined, no debug code is bundled
|
|
65
|
+
let DebugModule = null
|
|
66
|
+
let QdadmDebugBar = null
|
|
59
67
|
|
|
60
68
|
export class Kernel {
|
|
61
69
|
/**
|
|
62
70
|
* @param {object} options
|
|
63
71
|
* @param {object} options.root - Root Vue component
|
|
64
|
-
* @param {object} options.modules - Result of import.meta.glob for module init files
|
|
72
|
+
* @param {object} options.modules - Result of import.meta.glob for module init files (legacy)
|
|
65
73
|
* @param {object} options.modulesOptions - Options for initModules (e.g., { coreNavItems })
|
|
74
|
+
* @param {Array} options.moduleDefs - New-style module definitions (Module classes/objects/functions)
|
|
66
75
|
* @param {string[]} options.sectionOrder - Navigation section order
|
|
67
76
|
* @param {object} options.managers - Entity managers { name: config } - can be instances, strings, or config objects
|
|
68
77
|
* @param {object} options.managerRegistry - Registry of manager classes from qdadm-gen { name: ManagerClass }
|
|
@@ -70,7 +79,9 @@ export class Kernel {
|
|
|
70
79
|
* @param {function} options.managerResolver - Custom manager resolver (config, entityName, context) => Manager
|
|
71
80
|
* @param {object} options.authAdapter - Auth adapter for login/logout (app-level authentication)
|
|
72
81
|
* @param {object} options.entityAuthAdapter - Auth adapter for entity permissions (scope/silo checks)
|
|
73
|
-
* @param {object} options.pages - Page components {
|
|
82
|
+
* @param {object} options.pages - Page components { layout, shell? }
|
|
83
|
+
* @param {object} options.pages.layout - Main layout component (required)
|
|
84
|
+
* @param {object} options.pages.shell - Optional app shell (enables unified routing with zones everywhere)
|
|
74
85
|
* @param {string} options.homeRoute - Route name for home redirect (or object { name, component })
|
|
75
86
|
* @param {Array} options.coreRoutes - Additional routes as layout children (before module routes)
|
|
76
87
|
* @param {string} options.basePath - Base path for router (e.g., '/dashboard/')
|
|
@@ -82,8 +93,32 @@ export class Kernel {
|
|
|
82
93
|
* @param {object} options.security - Security config { role_hierarchy, role_permissions, entity_permissions }
|
|
83
94
|
* @param {boolean} options.warmup - Enable warmup at boot (default: true)
|
|
84
95
|
* @param {object} options.eventRouter - EventRouter config { 'source:signal': ['target:signal', ...] }
|
|
96
|
+
* @param {object} options.sse - SSEBridge config { url, reconnectDelay, signalPrefix, autoConnect, events }
|
|
97
|
+
* @param {object} options.debugBar - Debug bar config { module: DebugModule, component: QdadmDebugBar, ...options }
|
|
85
98
|
*/
|
|
86
99
|
constructor(options) {
|
|
100
|
+
// Auto-inject DebugModule if debugBar.module is provided
|
|
101
|
+
// User must import DebugModule separately for tree-shaking
|
|
102
|
+
if (options.debugBar?.module) {
|
|
103
|
+
const DebugModuleClass = options.debugBar.module
|
|
104
|
+
const { module: _, component: __, ...debugModuleOptions } = options.debugBar
|
|
105
|
+
// Enable by default when using debugBar shorthand
|
|
106
|
+
if (debugModuleOptions.enabled === undefined) {
|
|
107
|
+
debugModuleOptions.enabled = true
|
|
108
|
+
}
|
|
109
|
+
// Mark as kernel-managed to prevent zone block registration
|
|
110
|
+
// (Kernel handles rendering via root wrapper)
|
|
111
|
+
debugModuleOptions._kernelManaged = true
|
|
112
|
+
const debugModule = new DebugModuleClass(debugModuleOptions)
|
|
113
|
+
options.moduleDefs = options.moduleDefs || []
|
|
114
|
+
options.moduleDefs.push(debugModule)
|
|
115
|
+
// Store component for root wrapper
|
|
116
|
+
QdadmDebugBar = options.debugBar.component
|
|
117
|
+
// Enable debug mode
|
|
118
|
+
if (!options.debug) {
|
|
119
|
+
options.debug = true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
87
122
|
this.options = options
|
|
88
123
|
this.vueApp = null
|
|
89
124
|
this.router = null
|
|
@@ -93,12 +128,24 @@ export class Kernel {
|
|
|
93
128
|
this.hookRegistry = null
|
|
94
129
|
this.deferred = null
|
|
95
130
|
this.eventRouter = null
|
|
131
|
+
this.sseBridge = null
|
|
96
132
|
this.layoutComponents = null
|
|
97
133
|
this.securityChecker = null
|
|
134
|
+
/** @type {import('./ModuleLoader.js').ModuleLoader|null} */
|
|
135
|
+
this.moduleLoader = null
|
|
136
|
+
/** @type {Map<string|symbol, any>} Pending provides from modules (applied after vueApp creation) */
|
|
137
|
+
this._pendingProvides = new Map()
|
|
138
|
+
/** @type {Map<string, import('vue').Component>} Pending components from modules */
|
|
139
|
+
this._pendingComponents = new Map()
|
|
98
140
|
}
|
|
99
141
|
|
|
100
142
|
/**
|
|
101
143
|
* Create and configure the Vue app
|
|
144
|
+
*
|
|
145
|
+
* Note: This method is synchronous for backward compatibility.
|
|
146
|
+
* New-style modules (moduleDefs) are loaded synchronously via _loadModules().
|
|
147
|
+
* For async module loading, use createAppAsync() instead.
|
|
148
|
+
*
|
|
102
149
|
* @returns {App} Vue app instance ready to mount
|
|
103
150
|
*/
|
|
104
151
|
createApp() {
|
|
@@ -109,19 +156,72 @@ export class Kernel {
|
|
|
109
156
|
this._createDeferredRegistry()
|
|
110
157
|
// 2. Register auth:ready deferred (if auth configured)
|
|
111
158
|
this._registerAuthDeferred()
|
|
112
|
-
// 3. Initialize modules (can use all services, registers routes)
|
|
159
|
+
// 3. Initialize legacy modules (can use all services, registers routes)
|
|
113
160
|
this._initModules()
|
|
161
|
+
// 3.5. Load new-style modules (moduleDefs) - synchronous for backward compat
|
|
162
|
+
this._loadModulesSync()
|
|
114
163
|
// 4. Create router (needs routes from modules)
|
|
115
164
|
this._createRouter()
|
|
116
|
-
// 5.
|
|
165
|
+
// 4.5. Setup auth guard (if authAdapter provided)
|
|
166
|
+
this._setupAuthGuard()
|
|
167
|
+
// 5. Setup auth:expired handler (needs router + authAdapter)
|
|
168
|
+
this._setupAuthExpiredHandler()
|
|
169
|
+
// 6. Create orchestrator and remaining components
|
|
117
170
|
this._createOrchestrator()
|
|
118
|
-
//
|
|
171
|
+
// 7. Wire modules that need orchestrator (phase 2)
|
|
172
|
+
this._wireModules()
|
|
173
|
+
// 8. Create EventRouter (needs signals + orchestrator)
|
|
119
174
|
this._createEventRouter()
|
|
175
|
+
// 9. Create SSEBridge (needs signals + authAdapter for token)
|
|
176
|
+
this._createSSEBridge()
|
|
120
177
|
this._setupSecurity()
|
|
121
178
|
this._createLayoutComponents()
|
|
122
179
|
this._createVueApp()
|
|
123
180
|
this._installPlugins()
|
|
124
|
-
//
|
|
181
|
+
// 10. Fire warmups (fire-and-forget, pages await via DeferredRegistry)
|
|
182
|
+
this._fireWarmups()
|
|
183
|
+
return this.vueApp
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Create and configure the Vue app asynchronously
|
|
188
|
+
*
|
|
189
|
+
* Use this method when modules have async connect() methods.
|
|
190
|
+
* Supports the full async module loading flow.
|
|
191
|
+
*
|
|
192
|
+
* @returns {Promise<App>} Vue app instance ready to mount
|
|
193
|
+
*/
|
|
194
|
+
async createAppAsync() {
|
|
195
|
+
// 1. Create services first (modules need them)
|
|
196
|
+
this._createSignalBus()
|
|
197
|
+
this._createHookRegistry()
|
|
198
|
+
this._createZoneRegistry()
|
|
199
|
+
this._createDeferredRegistry()
|
|
200
|
+
// 2. Register auth:ready deferred (if auth configured)
|
|
201
|
+
this._registerAuthDeferred()
|
|
202
|
+
// 3. Initialize legacy modules (can use all services, registers routes)
|
|
203
|
+
this._initModules()
|
|
204
|
+
// 3.5. Load new-style modules (moduleDefs) - async version
|
|
205
|
+
await this._loadModules()
|
|
206
|
+
// 4. Create router (needs routes from modules)
|
|
207
|
+
this._createRouter()
|
|
208
|
+
// 4.5. Setup auth guard (if authAdapter provided)
|
|
209
|
+
this._setupAuthGuard()
|
|
210
|
+
// 5. Setup auth:expired handler (needs router + authAdapter)
|
|
211
|
+
this._setupAuthExpiredHandler()
|
|
212
|
+
// 6. Create orchestrator and remaining components
|
|
213
|
+
this._createOrchestrator()
|
|
214
|
+
// 7. Wire modules that need orchestrator (phase 2)
|
|
215
|
+
await this._wireModulesAsync()
|
|
216
|
+
// 8. Create EventRouter (needs signals + orchestrator)
|
|
217
|
+
this._createEventRouter()
|
|
218
|
+
// 9. Create SSEBridge (needs signals + authAdapter for token)
|
|
219
|
+
this._createSSEBridge()
|
|
220
|
+
this._setupSecurity()
|
|
221
|
+
this._createLayoutComponents()
|
|
222
|
+
this._createVueApp()
|
|
223
|
+
this._installPlugins()
|
|
224
|
+
// 10. Fire warmups (fire-and-forget, pages await via DeferredRegistry)
|
|
125
225
|
this._fireWarmups()
|
|
126
226
|
return this.vueApp
|
|
127
227
|
}
|
|
@@ -144,6 +244,58 @@ export class Kernel {
|
|
|
144
244
|
})
|
|
145
245
|
}
|
|
146
246
|
|
|
247
|
+
/**
|
|
248
|
+
* Setup handler for auth:expired signal
|
|
249
|
+
*
|
|
250
|
+
* When auth:expired is emitted (e.g., from API 401/403 response),
|
|
251
|
+
* this handler:
|
|
252
|
+
* 1. Calls authAdapter.logout() to clear tokens
|
|
253
|
+
* 2. Redirects to login page
|
|
254
|
+
* 3. Optionally calls onAuthExpired callback
|
|
255
|
+
*
|
|
256
|
+
* To emit auth:expired from your API client:
|
|
257
|
+
* ```js
|
|
258
|
+
* axios.interceptors.response.use(
|
|
259
|
+
* response => response,
|
|
260
|
+
* error => {
|
|
261
|
+
* if (error.response?.status === 401 || error.response?.status === 403) {
|
|
262
|
+
* signals.emit('auth:expired', { status: error.response.status })
|
|
263
|
+
* }
|
|
264
|
+
* return Promise.reject(error)
|
|
265
|
+
* }
|
|
266
|
+
* )
|
|
267
|
+
* ```
|
|
268
|
+
*/
|
|
269
|
+
_setupAuthExpiredHandler() {
|
|
270
|
+
const { authAdapter, onAuthExpired } = this.options
|
|
271
|
+
if (!authAdapter) return
|
|
272
|
+
|
|
273
|
+
this.signals.on('auth:expired', async (payload) => {
|
|
274
|
+
const debug = this.options.debug ?? false
|
|
275
|
+
if (debug) {
|
|
276
|
+
console.warn('[Kernel] auth:expired received:', payload)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// 1. Logout (clear tokens)
|
|
280
|
+
if (authAdapter.logout) {
|
|
281
|
+
authAdapter.logout()
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// 2. Emit auth:logout signal
|
|
285
|
+
await this.signals.emit('auth:logout', { reason: 'expired', ...payload })
|
|
286
|
+
|
|
287
|
+
// 3. Redirect to login (if not already there)
|
|
288
|
+
if (this.router.currentRoute.value.name !== 'login') {
|
|
289
|
+
this.router.push({ name: 'login', query: { expired: '1' } })
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// 4. Optional callback
|
|
293
|
+
if (onAuthExpired) {
|
|
294
|
+
onAuthExpired(payload)
|
|
295
|
+
}
|
|
296
|
+
})
|
|
297
|
+
}
|
|
298
|
+
|
|
147
299
|
/**
|
|
148
300
|
* Fire entity cache warmups
|
|
149
301
|
* Fire-and-forget: pages that need cache will await via DeferredRegistry.
|
|
@@ -158,7 +310,7 @@ export class Kernel {
|
|
|
158
310
|
}
|
|
159
311
|
|
|
160
312
|
/**
|
|
161
|
-
* Initialize modules from glob import
|
|
313
|
+
* Initialize legacy modules from glob import
|
|
162
314
|
* Passes services to modules for zone/signal/hook registration
|
|
163
315
|
*/
|
|
164
316
|
_initModules() {
|
|
@@ -177,15 +329,168 @@ export class Kernel {
|
|
|
177
329
|
}
|
|
178
330
|
|
|
179
331
|
/**
|
|
180
|
-
* Create
|
|
332
|
+
* Create KernelContext for module connection
|
|
333
|
+
*
|
|
334
|
+
* Creates a context object that provides modules access to kernel services
|
|
335
|
+
* and registration APIs.
|
|
336
|
+
*
|
|
337
|
+
* @param {import('./Module.js').Module} module - Module instance
|
|
338
|
+
* @returns {import('./KernelContext.js').KernelContext}
|
|
339
|
+
* @private
|
|
181
340
|
*/
|
|
182
|
-
|
|
183
|
-
|
|
341
|
+
_createModuleContext(module) {
|
|
342
|
+
return createKernelContext(this, module)
|
|
343
|
+
}
|
|
184
344
|
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
345
|
+
/**
|
|
346
|
+
* Load new-style modules synchronously
|
|
347
|
+
*
|
|
348
|
+
* Used by createApp() for backward compatibility. Modules with async
|
|
349
|
+
* connect() methods will have their promises ignored (fire-and-forget).
|
|
350
|
+
* For proper async support, use createAppAsync().
|
|
351
|
+
*
|
|
352
|
+
* @private
|
|
353
|
+
*/
|
|
354
|
+
_loadModulesSync() {
|
|
355
|
+
const { moduleDefs } = this.options
|
|
356
|
+
if (!moduleDefs?.length) return
|
|
357
|
+
|
|
358
|
+
this.moduleLoader = createModuleLoader()
|
|
359
|
+
|
|
360
|
+
// Add all modules
|
|
361
|
+
for (const mod of moduleDefs) {
|
|
362
|
+
this.moduleLoader.add(mod)
|
|
188
363
|
}
|
|
364
|
+
|
|
365
|
+
// Get sorted modules and load synchronously
|
|
366
|
+
const sorted = this.moduleLoader._topologicalSort()
|
|
367
|
+
|
|
368
|
+
for (const name of sorted) {
|
|
369
|
+
const module = this.moduleLoader._registered.get(name)
|
|
370
|
+
const ctx = this._createModuleContext(module)
|
|
371
|
+
|
|
372
|
+
// Check if enabled
|
|
373
|
+
if (!module.enabled(ctx)) {
|
|
374
|
+
continue
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Connect module (sync - async modules will be fire-and-forget)
|
|
378
|
+
const result = module.connect(ctx)
|
|
379
|
+
|
|
380
|
+
// If it's a promise, we can't await it in sync context
|
|
381
|
+
// The module should handle its own async initialization
|
|
382
|
+
if (result instanceof Promise) {
|
|
383
|
+
result.catch(err => {
|
|
384
|
+
console.error(`[Kernel] Async module '${name}' failed:`, err)
|
|
385
|
+
})
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
this.moduleLoader._loaded.set(name, module)
|
|
389
|
+
this.moduleLoader._loadOrder.push(name)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
/**
|
|
394
|
+
* Load new-style modules asynchronously
|
|
395
|
+
*
|
|
396
|
+
* Fully supports async connect() methods in modules.
|
|
397
|
+
* Used by createAppAsync().
|
|
398
|
+
*
|
|
399
|
+
* @returns {Promise<void>}
|
|
400
|
+
* @private
|
|
401
|
+
*/
|
|
402
|
+
async _loadModules() {
|
|
403
|
+
const { moduleDefs } = this.options
|
|
404
|
+
if (!moduleDefs?.length) return
|
|
405
|
+
|
|
406
|
+
this.moduleLoader = createModuleLoader()
|
|
407
|
+
|
|
408
|
+
// Add all modules
|
|
409
|
+
for (const mod of moduleDefs) {
|
|
410
|
+
this.moduleLoader.add(mod)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// Get sorted modules and load
|
|
414
|
+
const sorted = this.moduleLoader._topologicalSort()
|
|
415
|
+
|
|
416
|
+
for (const name of sorted) {
|
|
417
|
+
const module = this.moduleLoader._registered.get(name)
|
|
418
|
+
const ctx = this._createModuleContext(module)
|
|
419
|
+
|
|
420
|
+
// Check if enabled
|
|
421
|
+
if (!module.enabled(ctx)) {
|
|
422
|
+
continue
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Connect module (await async)
|
|
426
|
+
await module.connect(ctx)
|
|
427
|
+
|
|
428
|
+
this.moduleLoader._loaded.set(name, module)
|
|
429
|
+
this.moduleLoader._loadOrder.push(name)
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Wire modules that need orchestrator (phase 2)
|
|
435
|
+
*
|
|
436
|
+
* Some modules may need access to the orchestrator after it's created.
|
|
437
|
+
* This method emits 'kernel:ready' signal that modules can listen to.
|
|
438
|
+
*
|
|
439
|
+
* @private
|
|
440
|
+
*/
|
|
441
|
+
_wireModules() {
|
|
442
|
+
if (!this.moduleLoader) return
|
|
443
|
+
|
|
444
|
+
// Emit kernel:ready signal for modules that need orchestrator
|
|
445
|
+
const result = this.signals.emit('kernel:ready', {
|
|
446
|
+
orchestrator: this.orchestrator,
|
|
447
|
+
kernel: this
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
// If emit returns a promise (async handlers), we can't await it
|
|
451
|
+
if (result instanceof Promise) {
|
|
452
|
+
result.catch(err => {
|
|
453
|
+
console.error('[Kernel] kernel:ready handler failed:', err)
|
|
454
|
+
})
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/**
|
|
459
|
+
* Wire modules that need orchestrator (phase 2) - async version
|
|
460
|
+
*
|
|
461
|
+
* @returns {Promise<void>}
|
|
462
|
+
* @private
|
|
463
|
+
*/
|
|
464
|
+
async _wireModulesAsync() {
|
|
465
|
+
if (!this.moduleLoader) return
|
|
466
|
+
|
|
467
|
+
// Emit kernel:ready signal for modules that need orchestrator
|
|
468
|
+
await this.signals.emit('kernel:ready', {
|
|
469
|
+
orchestrator: this.orchestrator,
|
|
470
|
+
kernel: this
|
|
471
|
+
})
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* Create Vue Router
|
|
476
|
+
*
|
|
477
|
+
* Routes are built from:
|
|
478
|
+
* 1. Module routes (via ctx.routes()) - can be public or protected via meta
|
|
479
|
+
* 2. coreRoutes option - additional routes
|
|
480
|
+
* 3. homeRoute - redirect or component for /
|
|
481
|
+
*
|
|
482
|
+
* Auth is NOT hardcoded here - it's handled via:
|
|
483
|
+
* - Route meta: { public: true } or { requiresAuth: false }
|
|
484
|
+
* - Auth guard registered via _setupAuthGuard() if authAdapter provided
|
|
485
|
+
*
|
|
486
|
+
* Layout modes:
|
|
487
|
+
* - **Shell mode** (pages.shell): All routes inside shell, layout wraps protected area
|
|
488
|
+
* - **Layout-only mode**: Layout at root with all routes as children
|
|
489
|
+
*/
|
|
490
|
+
_createRouter() {
|
|
491
|
+
const { pages, homeRoute, coreRoutes, basePath, hashMode } = this.options
|
|
492
|
+
|
|
493
|
+
// Layout is required (shell is optional)
|
|
189
494
|
if (!pages?.layout) {
|
|
190
495
|
throw new Error('[Kernel] pages.layout is required')
|
|
191
496
|
}
|
|
@@ -193,50 +498,115 @@ export class Kernel {
|
|
|
193
498
|
// Build home route
|
|
194
499
|
let homeRouteConfig
|
|
195
500
|
if (typeof homeRoute === 'object' && homeRoute.component) {
|
|
196
|
-
// homeRoute is a route config with component
|
|
197
501
|
homeRouteConfig = { path: '', ...homeRoute }
|
|
198
502
|
} else {
|
|
199
|
-
// homeRoute is a route name for redirect
|
|
200
503
|
homeRouteConfig = { path: '', redirect: { name: homeRoute || 'home' } }
|
|
201
504
|
}
|
|
202
505
|
|
|
203
|
-
//
|
|
506
|
+
// Collect all module routes
|
|
507
|
+
const moduleRoutes = getRoutes()
|
|
508
|
+
|
|
509
|
+
// Separate public routes (meta.public or meta.requiresAuth === false)
|
|
510
|
+
const publicRoutes = moduleRoutes.filter(r => r.meta?.public || r.meta?.requiresAuth === false)
|
|
511
|
+
const protectedRoutes = moduleRoutes.filter(r => !r.meta?.public && r.meta?.requiresAuth !== false)
|
|
512
|
+
|
|
513
|
+
// Auto-create login route if pages.login is provided (backward compatibility)
|
|
514
|
+
// This creates a public route that modules don't need to define explicitly.
|
|
515
|
+
if (pages.login) {
|
|
516
|
+
publicRoutes.unshift({
|
|
517
|
+
path: '/login',
|
|
518
|
+
name: 'login',
|
|
519
|
+
component: pages.login,
|
|
520
|
+
meta: { public: true }
|
|
521
|
+
})
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
// Build layout children: home + coreRoutes + protected module routes
|
|
204
525
|
const layoutChildren = [
|
|
205
526
|
homeRouteConfig,
|
|
206
527
|
...(coreRoutes || []),
|
|
207
|
-
...
|
|
528
|
+
...protectedRoutes
|
|
208
529
|
]
|
|
209
530
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
531
|
+
let routes
|
|
532
|
+
|
|
533
|
+
if (pages.shell) {
|
|
534
|
+
// Shell mode: shell wraps everything, layout wraps protected area
|
|
535
|
+
routes = [
|
|
536
|
+
{
|
|
537
|
+
path: '/',
|
|
538
|
+
component: pages.shell,
|
|
539
|
+
children: [
|
|
540
|
+
// Public routes at shell level (login, register, etc.)
|
|
541
|
+
...publicRoutes,
|
|
542
|
+
// Protected area with layout
|
|
543
|
+
{
|
|
544
|
+
path: '',
|
|
545
|
+
component: pages.layout,
|
|
546
|
+
meta: { requiresAuth: true },
|
|
547
|
+
children: layoutChildren
|
|
548
|
+
}
|
|
549
|
+
]
|
|
550
|
+
}
|
|
551
|
+
]
|
|
552
|
+
} else {
|
|
553
|
+
// Layout-only mode: layout at root
|
|
554
|
+
// Public routes need to be handled differently (no shell)
|
|
555
|
+
routes = [
|
|
556
|
+
// Public routes standalone
|
|
557
|
+
...publicRoutes,
|
|
558
|
+
// Protected area with layout
|
|
559
|
+
{
|
|
560
|
+
path: '/',
|
|
561
|
+
component: pages.layout,
|
|
562
|
+
meta: { requiresAuth: true },
|
|
563
|
+
children: layoutChildren
|
|
564
|
+
}
|
|
565
|
+
]
|
|
566
|
+
}
|
|
224
567
|
|
|
225
568
|
this.router = createRouter({
|
|
226
569
|
history: hashMode ? createWebHashHistory(basePath) : createWebHistory(basePath),
|
|
227
570
|
routes
|
|
228
571
|
})
|
|
572
|
+
}
|
|
229
573
|
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
574
|
+
/**
|
|
575
|
+
* Setup auth guard on router
|
|
576
|
+
*
|
|
577
|
+
* Only installed if authAdapter is provided.
|
|
578
|
+
* Uses route meta to determine access:
|
|
579
|
+
* - meta.public: true → always accessible
|
|
580
|
+
* - meta.requiresAuth: false → always accessible
|
|
581
|
+
* - meta.requiresAuth: true → requires auth (default for layout children)
|
|
582
|
+
* - no meta → inherits from parent route
|
|
583
|
+
*/
|
|
584
|
+
_setupAuthGuard() {
|
|
585
|
+
const { authAdapter } = this.options
|
|
586
|
+
if (!authAdapter) return
|
|
587
|
+
|
|
588
|
+
this.router.beforeEach((to, from, next) => {
|
|
589
|
+
// Check if route or any parent is explicitly public
|
|
590
|
+
const isPublic = to.matched.some(record =>
|
|
591
|
+
record.meta.public === true || record.meta.requiresAuth === false
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
if (isPublic) {
|
|
595
|
+
next()
|
|
596
|
+
return
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// Check if route or any parent requires auth
|
|
600
|
+
const requiresAuth = to.matched.some(record => record.meta.requiresAuth === true)
|
|
601
|
+
|
|
602
|
+
if (requiresAuth && !authAdapter.isAuthenticated()) {
|
|
603
|
+
// Redirect to login (if exists) or emit signal
|
|
604
|
+
const loginRoute = this.router.hasRoute('login') ? { name: 'login' } : '/'
|
|
605
|
+
next(loginRoute)
|
|
606
|
+
} else {
|
|
607
|
+
next()
|
|
608
|
+
}
|
|
609
|
+
})
|
|
240
610
|
}
|
|
241
611
|
|
|
242
612
|
/**
|
|
@@ -331,12 +701,11 @@ export class Kernel {
|
|
|
331
701
|
|
|
332
702
|
/**
|
|
333
703
|
* Create zone registry for extensible UI composition
|
|
334
|
-
*
|
|
704
|
+
* Zones are created dynamically when used (no pre-registration).
|
|
335
705
|
*/
|
|
336
706
|
_createZoneRegistry() {
|
|
337
707
|
const debug = this.options.debug ?? false
|
|
338
708
|
this.zoneRegistry = createZoneRegistry({ debug })
|
|
339
|
-
registerStandardZones(this.zoneRegistry)
|
|
340
709
|
}
|
|
341
710
|
|
|
342
711
|
/**
|
|
@@ -368,6 +737,52 @@ export class Kernel {
|
|
|
368
737
|
})
|
|
369
738
|
}
|
|
370
739
|
|
|
740
|
+
/**
|
|
741
|
+
* Create SSEBridge for Server-Sent Events to SignalBus integration
|
|
742
|
+
*
|
|
743
|
+
* SSE config:
|
|
744
|
+
* ```js
|
|
745
|
+
* sse: {
|
|
746
|
+
* url: '/api/events', // SSE endpoint
|
|
747
|
+
* reconnectDelay: 5000, // Reconnect delay (ms), 0 to disable
|
|
748
|
+
* signalPrefix: 'sse', // Signal prefix (default: 'sse')
|
|
749
|
+
* autoConnect: false, // Connect immediately vs wait for auth:login
|
|
750
|
+
* events: ['task:completed', 'bot:status'] // Event names to register
|
|
751
|
+
* }
|
|
752
|
+
* ```
|
|
753
|
+
*/
|
|
754
|
+
_createSSEBridge() {
|
|
755
|
+
const { sse, authAdapter } = this.options
|
|
756
|
+
if (!sse?.url) return
|
|
757
|
+
|
|
758
|
+
const debug = this.options.debug ?? false
|
|
759
|
+
|
|
760
|
+
// Build getToken from authAdapter
|
|
761
|
+
const getToken = authAdapter?.getToken
|
|
762
|
+
? () => authAdapter.getToken()
|
|
763
|
+
: () => localStorage.getItem('auth_token')
|
|
764
|
+
|
|
765
|
+
this.sseBridge = createSSEBridge({
|
|
766
|
+
signals: this.signals,
|
|
767
|
+
url: sse.url,
|
|
768
|
+
reconnectDelay: sse.reconnectDelay ?? 5000,
|
|
769
|
+
signalPrefix: sse.signalPrefix ?? 'sse',
|
|
770
|
+
autoConnect: sse.autoConnect ?? false,
|
|
771
|
+
withCredentials: sse.withCredentials ?? false,
|
|
772
|
+
tokenParam: sse.tokenParam ?? 'token',
|
|
773
|
+
getToken,
|
|
774
|
+
debug
|
|
775
|
+
})
|
|
776
|
+
|
|
777
|
+
// Register known event names if provided
|
|
778
|
+
if (sse.events?.length) {
|
|
779
|
+
// Wait for connection before registering
|
|
780
|
+
this.signals.once('sse:connected').then(() => {
|
|
781
|
+
this.sseBridge.registerEvents(sse.events)
|
|
782
|
+
})
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
371
786
|
/**
|
|
372
787
|
* Create layout components map for useLayoutResolver
|
|
373
788
|
* Maps layout types to their Vue components.
|
|
@@ -384,12 +799,29 @@ export class Kernel {
|
|
|
384
799
|
|
|
385
800
|
/**
|
|
386
801
|
* Create Vue app instance
|
|
802
|
+
*
|
|
803
|
+
* When debugBar is enabled, wraps the root component to include QdadmDebugBar
|
|
804
|
+
* at the app level, ensuring it's visible on all pages (including login).
|
|
387
805
|
*/
|
|
388
806
|
_createVueApp() {
|
|
389
807
|
if (!this.options.root) {
|
|
390
808
|
throw new Error('[Kernel] root component is required')
|
|
391
809
|
}
|
|
392
|
-
|
|
810
|
+
|
|
811
|
+
// If debugBar is enabled and component provided, wrap root with DebugBar
|
|
812
|
+
if (this.options.debugBar?.component && QdadmDebugBar) {
|
|
813
|
+
const OriginalRoot = this.options.root
|
|
814
|
+
const DebugBarComponent = QdadmDebugBar
|
|
815
|
+
const WrappedRoot = {
|
|
816
|
+
name: 'QdadmRootWrapper',
|
|
817
|
+
render() {
|
|
818
|
+
return [h(OriginalRoot), h(DebugBarComponent)]
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
this.vueApp = createApp(WrappedRoot)
|
|
822
|
+
} else {
|
|
823
|
+
this.vueApp = createApp(this.options.root)
|
|
824
|
+
}
|
|
393
825
|
}
|
|
394
826
|
|
|
395
827
|
/**
|
|
@@ -422,6 +854,18 @@ export class Kernel {
|
|
|
422
854
|
// Router
|
|
423
855
|
app.use(this.router)
|
|
424
856
|
|
|
857
|
+
// Apply pending provides from modules (registered before vueApp existed)
|
|
858
|
+
for (const [key, value] of this._pendingProvides) {
|
|
859
|
+
app.provide(key, value)
|
|
860
|
+
}
|
|
861
|
+
this._pendingProvides.clear()
|
|
862
|
+
|
|
863
|
+
// Apply pending components from modules
|
|
864
|
+
for (const [name, component] of this._pendingComponents) {
|
|
865
|
+
app.component(name, component)
|
|
866
|
+
}
|
|
867
|
+
this._pendingComponents.clear()
|
|
868
|
+
|
|
425
869
|
// Extract home route name for breadcrumb
|
|
426
870
|
const { homeRoute } = this.options
|
|
427
871
|
const homeRouteName = typeof homeRoute === 'object' ? homeRoute.name : homeRoute
|
|
@@ -429,15 +873,34 @@ export class Kernel {
|
|
|
429
873
|
// Zone registry injection
|
|
430
874
|
app.provide('qdadmZoneRegistry', this.zoneRegistry)
|
|
431
875
|
|
|
432
|
-
// Dev mode: expose
|
|
876
|
+
// Dev mode: expose qdadm services on window for DevTools inspection
|
|
433
877
|
if (this.options.debug && typeof window !== 'undefined') {
|
|
878
|
+
window.__qdadm = {
|
|
879
|
+
kernel: this,
|
|
880
|
+
orchestrator: this.orchestrator,
|
|
881
|
+
signals: this.signals,
|
|
882
|
+
hooks: this.hookRegistry,
|
|
883
|
+
zones: this.zoneRegistry,
|
|
884
|
+
deferred: this.deferred,
|
|
885
|
+
router: this.router,
|
|
886
|
+
// Helper to get a manager quickly
|
|
887
|
+
get: (name) => this.orchestrator.get(name),
|
|
888
|
+
// List all managers
|
|
889
|
+
managers: () => this.orchestrator.getRegisteredNames()
|
|
890
|
+
}
|
|
891
|
+
// Legacy alias
|
|
434
892
|
window.__qdadmZones = this.zoneRegistry
|
|
435
|
-
console.debug('[qdadm]
|
|
893
|
+
console.debug('[qdadm] Debug mode: window.__qdadm exposed (orchestrator, signals, hooks, zones, deferred, router)')
|
|
436
894
|
}
|
|
437
895
|
|
|
438
896
|
// Signal bus injection
|
|
439
897
|
app.provide('qdadmSignals', this.signals)
|
|
440
898
|
|
|
899
|
+
// SSEBridge injection (if configured)
|
|
900
|
+
if (this.sseBridge) {
|
|
901
|
+
app.provide('qdadmSSEBridge', this.sseBridge)
|
|
902
|
+
}
|
|
903
|
+
|
|
441
904
|
// Hook registry injection
|
|
442
905
|
app.provide('qdadmHooks', this.hookRegistry)
|
|
443
906
|
|
|
@@ -538,6 +1001,22 @@ export class Kernel {
|
|
|
538
1001
|
return this.eventRouter
|
|
539
1002
|
}
|
|
540
1003
|
|
|
1004
|
+
/**
|
|
1005
|
+
* Get the SSEBridge instance
|
|
1006
|
+
* @returns {import('./SSEBridge.js').SSEBridge|null}
|
|
1007
|
+
*/
|
|
1008
|
+
getSSEBridge() {
|
|
1009
|
+
return this.sseBridge
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
/**
|
|
1013
|
+
* Shorthand accessor for SSE bridge
|
|
1014
|
+
* @returns {import('./SSEBridge.js').SSEBridge|null}
|
|
1015
|
+
*/
|
|
1016
|
+
get sse() {
|
|
1017
|
+
return this.sseBridge
|
|
1018
|
+
}
|
|
1019
|
+
|
|
541
1020
|
/**
|
|
542
1021
|
* Get the layout components map
|
|
543
1022
|
* @returns {object} Layout components by type
|
|
@@ -570,4 +1049,138 @@ export class Kernel {
|
|
|
570
1049
|
get security() {
|
|
571
1050
|
return this.securityChecker
|
|
572
1051
|
}
|
|
1052
|
+
|
|
1053
|
+
/**
|
|
1054
|
+
* Get the ModuleLoader instance
|
|
1055
|
+
* @returns {import('./ModuleLoader.js').ModuleLoader|null}
|
|
1056
|
+
*/
|
|
1057
|
+
getModuleLoader() {
|
|
1058
|
+
return this.moduleLoader
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
/**
|
|
1062
|
+
* Shorthand accessor for module loader
|
|
1063
|
+
* Allows `kernel.modules.getModules()` syntax
|
|
1064
|
+
* @returns {import('./ModuleLoader.js').ModuleLoader|null}
|
|
1065
|
+
*/
|
|
1066
|
+
get modules() {
|
|
1067
|
+
return this.moduleLoader
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* Get the DebugModule instance if loaded
|
|
1072
|
+
* @returns {import('../debug/DebugModule.js').DebugModule|null}
|
|
1073
|
+
*/
|
|
1074
|
+
getDebugModule() {
|
|
1075
|
+
return this.moduleLoader?._loaded?.get('debug') ?? null
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
/**
|
|
1079
|
+
* Shorthand accessor for debug bridge
|
|
1080
|
+
* Allows `kernel.debugBar.toggle()` syntax
|
|
1081
|
+
* @returns {import('../debug/DebugBridge.js').DebugBridge|null}
|
|
1082
|
+
*/
|
|
1083
|
+
get debugBar() {
|
|
1084
|
+
const debugModule = this.getDebugModule()
|
|
1085
|
+
return debugModule?.getBridge?.() ?? null
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
/**
|
|
1089
|
+
* Setup an axios client with automatic auth and error handling
|
|
1090
|
+
*
|
|
1091
|
+
* Adds interceptors that:
|
|
1092
|
+
* - Add Authorization header with token from authAdapter
|
|
1093
|
+
* - Emit auth:expired on 401/403 responses (triggers auto-logout)
|
|
1094
|
+
* - Emit api:error on all errors for centralized handling
|
|
1095
|
+
*
|
|
1096
|
+
* Usage:
|
|
1097
|
+
* ```js
|
|
1098
|
+
* import axios from 'axios'
|
|
1099
|
+
*
|
|
1100
|
+
* const apiClient = axios.create({ baseURL: '/api' })
|
|
1101
|
+
* kernel.setupApiClient(apiClient)
|
|
1102
|
+
*
|
|
1103
|
+
* // Now 401/403 errors automatically trigger logout
|
|
1104
|
+
* const storage = new ApiStorage({ endpoint: '/users', client: apiClient })
|
|
1105
|
+
* ```
|
|
1106
|
+
*
|
|
1107
|
+
* Or let Kernel create the client:
|
|
1108
|
+
* ```js
|
|
1109
|
+
* const kernel = new Kernel({
|
|
1110
|
+
* ...options,
|
|
1111
|
+
* apiClient: { baseURL: '/api' } // axios.create() options
|
|
1112
|
+
* })
|
|
1113
|
+
* const apiClient = kernel.getApiClient()
|
|
1114
|
+
* ```
|
|
1115
|
+
*
|
|
1116
|
+
* @param {object} client - Axios instance to configure
|
|
1117
|
+
* @returns {object} The configured axios instance
|
|
1118
|
+
*/
|
|
1119
|
+
setupApiClient(client) {
|
|
1120
|
+
const { authAdapter } = this.options
|
|
1121
|
+
const signals = this.signals
|
|
1122
|
+
const debug = this.options.debug ?? false
|
|
1123
|
+
|
|
1124
|
+
// Request interceptor: add Authorization header
|
|
1125
|
+
client.interceptors.request.use(
|
|
1126
|
+
(config) => {
|
|
1127
|
+
if (authAdapter?.getToken) {
|
|
1128
|
+
const token = authAdapter.getToken()
|
|
1129
|
+
if (token) {
|
|
1130
|
+
config.headers = config.headers || {}
|
|
1131
|
+
config.headers.Authorization = `Bearer ${token}`
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
return config
|
|
1135
|
+
},
|
|
1136
|
+
(error) => Promise.reject(error)
|
|
1137
|
+
)
|
|
1138
|
+
|
|
1139
|
+
// Response interceptor: handle auth errors
|
|
1140
|
+
client.interceptors.response.use(
|
|
1141
|
+
(response) => response,
|
|
1142
|
+
async (error) => {
|
|
1143
|
+
const status = error.response?.status
|
|
1144
|
+
const url = error.config?.url
|
|
1145
|
+
|
|
1146
|
+
// Emit api:error for all errors
|
|
1147
|
+
await signals.emit('api:error', {
|
|
1148
|
+
status,
|
|
1149
|
+
message: error.message,
|
|
1150
|
+
url,
|
|
1151
|
+
error
|
|
1152
|
+
})
|
|
1153
|
+
|
|
1154
|
+
// Emit auth:expired for 401/403
|
|
1155
|
+
if (status === 401 || status === 403) {
|
|
1156
|
+
if (debug) {
|
|
1157
|
+
console.warn(`[Kernel] API ${status} error on ${url}, emitting auth:expired`)
|
|
1158
|
+
}
|
|
1159
|
+
await signals.emit('auth:expired', { status, url })
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
return Promise.reject(error)
|
|
1163
|
+
}
|
|
1164
|
+
)
|
|
1165
|
+
|
|
1166
|
+
// Store reference
|
|
1167
|
+
this._apiClient = client
|
|
1168
|
+
return client
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
/**
|
|
1172
|
+
* Get the configured API client
|
|
1173
|
+
* @returns {object|null} Axios instance or null if not configured
|
|
1174
|
+
*/
|
|
1175
|
+
getApiClient() {
|
|
1176
|
+
return this._apiClient
|
|
1177
|
+
}
|
|
1178
|
+
|
|
1179
|
+
/**
|
|
1180
|
+
* Shorthand accessor for API client
|
|
1181
|
+
* @returns {object|null}
|
|
1182
|
+
*/
|
|
1183
|
+
get api() {
|
|
1184
|
+
return this._apiClient
|
|
1185
|
+
}
|
|
573
1186
|
}
|