qdadm 1.1.0 → 1.1.2
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 -11
- package/src/auth/SessionAuthAdapter.ts +25 -3
- package/src/auth/index.ts +1 -0
- package/src/components/layout/AppLayout.vue +5 -81
- package/src/components/layout/defaults/DefaultMenu.vue +8 -28
- package/src/entity/EntityManager.ts +84 -0
- package/src/kernel/Kernel.ts +7 -0
- package/src/modules/debug/EntitiesCollector.ts +13 -1
- package/src/modules/debug/components/panels/EntitiesPanel.vue +38 -3
- package/src/orchestrator/Orchestrator.ts +4 -3
- package/src/styles/_sidebar.scss +126 -0
- package/src/types/index.ts +4 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "qdadm",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Vue 3 framework for admin dashboards with PrimeVue",
|
|
5
5
|
"author": "quazardous",
|
|
6
6
|
"license": "MIT",
|
|
@@ -13,7 +13,7 @@
|
|
|
13
13
|
"url": "https://github.com/quazardous/qdadm/issues"
|
|
14
14
|
},
|
|
15
15
|
"type": "module",
|
|
16
|
-
"main": "src/index.
|
|
16
|
+
"main": "src/index.ts",
|
|
17
17
|
"scripts": {
|
|
18
18
|
"test": "vitest run",
|
|
19
19
|
"test:watch": "vitest",
|
|
@@ -24,19 +24,19 @@
|
|
|
24
24
|
"build:types": "vue-tsc --declaration --emitDeclarationOnly --outDir dist/types"
|
|
25
25
|
},
|
|
26
26
|
"exports": {
|
|
27
|
-
".": "./src/index.
|
|
28
|
-
"./auth": "./src/auth/index.
|
|
27
|
+
".": "./src/index.ts",
|
|
28
|
+
"./auth": "./src/auth/index.ts",
|
|
29
29
|
"./security": "./src/security/index.ts",
|
|
30
|
-
"./composables": "./src/composables/index.
|
|
31
|
-
"./components": "./src/components/index.
|
|
32
|
-
"./editors": "./src/editors/index.
|
|
33
|
-
"./module": "./src/module/index.
|
|
30
|
+
"./composables": "./src/composables/index.ts",
|
|
31
|
+
"./components": "./src/components/index.ts",
|
|
32
|
+
"./editors": "./src/editors/index.ts",
|
|
33
|
+
"./module": "./src/module/index.ts",
|
|
34
34
|
"./utils": "./src/utils/index.ts",
|
|
35
|
-
"./modules/debug": "./src/modules/debug/index.
|
|
35
|
+
"./modules/debug": "./src/modules/debug/index.ts",
|
|
36
36
|
"./styles": "./src/styles/index.scss",
|
|
37
37
|
"./styles/variables": "./src/styles/_variables.scss",
|
|
38
|
-
"./gen": "./src/gen/index.
|
|
39
|
-
"./gen/vite-plugin": "./src/gen/vite-plugin.
|
|
38
|
+
"./gen": "./src/gen/index.ts",
|
|
39
|
+
"./gen/vite-plugin": "./src/gen/vite-plugin.ts"
|
|
40
40
|
},
|
|
41
41
|
"files": [
|
|
42
42
|
"src",
|
|
@@ -225,6 +225,14 @@ interface StoredSessionData<TUser = AuthUser> {
|
|
|
225
225
|
originalUser?: TUser
|
|
226
226
|
}
|
|
227
227
|
|
|
228
|
+
/**
|
|
229
|
+
* Configuration options for LocalStorageSessionAuthAdapter
|
|
230
|
+
*/
|
|
231
|
+
export interface LocalStorageAuthConfig {
|
|
232
|
+
/** localStorage key for session data (default: 'qdadm_auth') */
|
|
233
|
+
storageKey?: string
|
|
234
|
+
}
|
|
235
|
+
|
|
228
236
|
/**
|
|
229
237
|
* LocalStorage-based SessionAuthAdapter implementation
|
|
230
238
|
*
|
|
@@ -252,15 +260,29 @@ interface StoredSessionData<TUser = AuthUser> {
|
|
|
252
260
|
* ```
|
|
253
261
|
*/
|
|
254
262
|
export class LocalStorageSessionAuthAdapter<TUser extends AuthUser = AuthUser> extends SessionAuthAdapter<TUser> {
|
|
263
|
+
/**
|
|
264
|
+
* Default configuration. Override to change defaults globally:
|
|
265
|
+
* @example
|
|
266
|
+
* LocalStorageSessionAuthAdapter.defaults.storageKey = 'my_app_auth'
|
|
267
|
+
*/
|
|
268
|
+
static defaults: Required<LocalStorageAuthConfig> = {
|
|
269
|
+
storageKey: 'qdadm_auth'
|
|
270
|
+
}
|
|
271
|
+
|
|
255
272
|
protected _storageKey: string
|
|
256
273
|
protected _originalUser: TUser | null = null // Stores original user during impersonation
|
|
257
274
|
|
|
258
275
|
/**
|
|
259
|
-
* @param
|
|
276
|
+
* @param config - Configuration options or storage key string (for backward compatibility)
|
|
260
277
|
*/
|
|
261
|
-
constructor(
|
|
278
|
+
constructor(config?: LocalStorageAuthConfig | string) {
|
|
262
279
|
super()
|
|
263
|
-
|
|
280
|
+
// Support both string (legacy) and config object
|
|
281
|
+
if (typeof config === 'string') {
|
|
282
|
+
this._storageKey = config
|
|
283
|
+
} else {
|
|
284
|
+
this._storageKey = config?.storageKey ?? LocalStorageSessionAuthAdapter.defaults.storageKey
|
|
285
|
+
}
|
|
264
286
|
this._restore()
|
|
265
287
|
}
|
|
266
288
|
|
package/src/auth/index.ts
CHANGED
|
@@ -569,51 +569,8 @@ const showBreadcrumb = computed<boolean>(() => {
|
|
|
569
569
|
z-index: 10;
|
|
570
570
|
}
|
|
571
571
|
|
|
572
|
-
/* Nav items
|
|
573
|
-
.nav-item
|
|
574
|
-
display: flex;
|
|
575
|
-
align-items: center;
|
|
576
|
-
gap: 0.5rem;
|
|
577
|
-
padding: var(--fad-nav-item-padding-y) var(--fad-nav-item-padding-x);
|
|
578
|
-
padding-left: 1rem;
|
|
579
|
-
color: var(--p-surface-300, #cbd5e1);
|
|
580
|
-
text-decoration: none;
|
|
581
|
-
font-size: var(--fad-font-size-sm);
|
|
582
|
-
font-weight: var(--fad-font-weight-normal);
|
|
583
|
-
border-radius: 0;
|
|
584
|
-
transition: background var(--fad-transition-fast), color var(--fad-transition-fast);
|
|
585
|
-
}
|
|
586
|
-
|
|
587
|
-
.nav-item span {
|
|
588
|
-
white-space: nowrap;
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
.nav-item:hover {
|
|
592
|
-
background: var(--p-surface-700, #334155);
|
|
593
|
-
color: var(--p-surface-0, white);
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
.nav-item-active {
|
|
597
|
-
background: var(--p-surface-700, #334155);
|
|
598
|
-
color: var(--p-primary-300, #93c5fd);
|
|
599
|
-
border-left: 2px solid var(--p-primary-400, #60a5fa);
|
|
600
|
-
padding-left: calc(1rem - 2px);
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
.nav-item-active:hover {
|
|
604
|
-
background: var(--p-surface-600, #475569);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
.nav-item i {
|
|
608
|
-
font-size: var(--fad-nav-icon-size);
|
|
609
|
-
width: 1.125rem;
|
|
610
|
-
text-align: center;
|
|
611
|
-
opacity: 0.8;
|
|
612
|
-
}
|
|
613
|
-
|
|
614
|
-
.nav-item-active i {
|
|
615
|
-
opacity: 1;
|
|
616
|
-
}
|
|
572
|
+
/* Nav items are styled in _sidebar.scss (global styles)
|
|
573
|
+
See .sidebar-nav .nav-item for base styles */
|
|
617
574
|
|
|
618
575
|
/* -----------------------------------------------------------------------------
|
|
619
576
|
Main Content
|
|
@@ -866,25 +823,8 @@ const showBreadcrumb = computed<boolean>(() => {
|
|
|
866
823
|
opacity: 1 !important;
|
|
867
824
|
}
|
|
868
825
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
gap: 0;
|
|
872
|
-
border-left: none !important;
|
|
873
|
-
justify-content: center;
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
.sidebar--collapsed .nav-item-active {
|
|
877
|
-
border-left: none !important;
|
|
878
|
-
padding: 0.5rem 0;
|
|
879
|
-
}
|
|
880
|
-
|
|
881
|
-
.sidebar--collapsed .nav-item span {
|
|
882
|
-
display: none;
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
.sidebar--collapsed .nav-item i {
|
|
886
|
-
width: auto;
|
|
887
|
-
}
|
|
826
|
+
/* Nav item collapsed styles are in _sidebar.scss (global styles)
|
|
827
|
+
See .sidebar--collapsed .sidebar-nav .nav-item */
|
|
888
828
|
|
|
889
829
|
/* Powered-by collapsed: smaller height */
|
|
890
830
|
.sidebar--collapsed #powered-by {
|
|
@@ -995,23 +935,7 @@ const showBreadcrumb = computed<boolean>(() => {
|
|
|
995
935
|
pointer-events: auto;
|
|
996
936
|
}
|
|
997
937
|
|
|
998
|
-
|
|
999
|
-
justify-content: flex-start;
|
|
1000
|
-
padding: var(--fad-nav-item-padding-y) var(--fad-nav-item-padding-x);
|
|
1001
|
-
padding-left: 1rem;
|
|
1002
|
-
gap: 0.5rem;
|
|
1003
|
-
}
|
|
1004
|
-
|
|
1005
|
-
.sidebar.sidebar--collapsed .nav-item span {
|
|
1006
|
-
display: inline;
|
|
1007
|
-
opacity: 1;
|
|
1008
|
-
width: auto;
|
|
1009
|
-
}
|
|
1010
|
-
|
|
1011
|
-
.sidebar.sidebar--collapsed .nav-item i {
|
|
1012
|
-
font-size: var(--fad-nav-icon-size);
|
|
1013
|
-
width: 1.125rem;
|
|
1014
|
-
}
|
|
938
|
+
/* Nav item mobile collapsed styles are in _sidebar.scss (global styles) */
|
|
1015
939
|
|
|
1016
940
|
.sidebar.sidebar--collapsed #powered-by,
|
|
1017
941
|
.sidebar.sidebar--collapsed #user-zone {
|
|
@@ -95,7 +95,8 @@ watch(() => route?.path, () => {
|
|
|
95
95
|
</script>
|
|
96
96
|
|
|
97
97
|
<template>
|
|
98
|
-
|
|
98
|
+
<!-- Use sidebar-nav class to inherit global nav-item styles from _sidebar.scss -->
|
|
99
|
+
<nav class="sidebar-nav">
|
|
99
100
|
<div v-for="section in navSections" :key="section.title || section.label" class="nav-section">
|
|
100
101
|
<div
|
|
101
102
|
class="nav-section-title"
|
|
@@ -126,7 +127,10 @@ watch(() => route?.path, () => {
|
|
|
126
127
|
</template>
|
|
127
128
|
|
|
128
129
|
<style scoped>
|
|
129
|
-
|
|
130
|
+
/* DefaultMenu uses sidebar-nav class to inherit global nav-item styles from _sidebar.scss
|
|
131
|
+
Only component-specific styles remain here */
|
|
132
|
+
|
|
133
|
+
.sidebar-nav {
|
|
130
134
|
flex: 1;
|
|
131
135
|
overflow-y: auto;
|
|
132
136
|
padding: 1rem 0;
|
|
@@ -175,30 +179,6 @@ watch(() => route?.path, () => {
|
|
|
175
179
|
opacity: 0;
|
|
176
180
|
}
|
|
177
181
|
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
align-items: center;
|
|
181
|
-
gap: 0.75rem;
|
|
182
|
-
padding: 0.625rem 1.5rem;
|
|
183
|
-
font-size: 0.9375rem;
|
|
184
|
-
color: var(--p-surface-300, #cbd5e1);
|
|
185
|
-
text-decoration: none;
|
|
186
|
-
transition: all 0.15s;
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
.nav-item:hover {
|
|
190
|
-
background: var(--p-surface-700, #334155);
|
|
191
|
-
color: var(--p-surface-0, white);
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
.nav-item-active {
|
|
195
|
-
background: var(--p-primary-600, #2563eb);
|
|
196
|
-
color: var(--p-surface-0, white);
|
|
197
|
-
}
|
|
198
|
-
|
|
199
|
-
.nav-item i {
|
|
200
|
-
font-size: 1rem;
|
|
201
|
-
width: 1.25rem;
|
|
202
|
-
text-align: center;
|
|
203
|
-
}
|
|
182
|
+
/* Nav item styles are in _sidebar.scss (global styles)
|
|
183
|
+
See .sidebar-nav .nav-item for base styles */
|
|
204
184
|
</style>
|
|
@@ -142,6 +142,12 @@ export interface CacheInfo {
|
|
|
142
142
|
itemCount: number
|
|
143
143
|
total: number
|
|
144
144
|
loadedAt: number | null
|
|
145
|
+
/** Effective TTL in milliseconds (0 = no TTL) */
|
|
146
|
+
ttlMs: number
|
|
147
|
+
/** Timestamp when cache expires (null if no TTL) */
|
|
148
|
+
expiresAt: number | null
|
|
149
|
+
/** Whether the cache has expired based on TTL */
|
|
150
|
+
expired: boolean
|
|
145
151
|
}
|
|
146
152
|
|
|
147
153
|
/**
|
|
@@ -157,6 +163,8 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
|
|
|
157
163
|
labelField?: string | ((entity: T) => string)
|
|
158
164
|
fields?: Record<string, FieldConfig>
|
|
159
165
|
localFilterThreshold?: number | null
|
|
166
|
+
/** Cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Overrides global, overridden by storage. */
|
|
167
|
+
cacheTtlMs?: number | null
|
|
160
168
|
readOnly?: boolean
|
|
161
169
|
warmup?: boolean
|
|
162
170
|
authSensitive?: boolean
|
|
@@ -205,6 +213,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
205
213
|
protected _fields: Record<string, FieldConfig>
|
|
206
214
|
|
|
207
215
|
localFilterThreshold: number | null
|
|
216
|
+
protected _cacheTtlMs: number | null
|
|
208
217
|
protected _readOnly: boolean
|
|
209
218
|
protected _warmup: boolean
|
|
210
219
|
protected _authSensitive: boolean
|
|
@@ -265,6 +274,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
265
274
|
labelField = 'name',
|
|
266
275
|
fields = {},
|
|
267
276
|
localFilterThreshold = null,
|
|
277
|
+
cacheTtlMs = null,
|
|
268
278
|
readOnly = false,
|
|
269
279
|
warmup = true,
|
|
270
280
|
authSensitive,
|
|
@@ -290,6 +300,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
290
300
|
this._fields = fields
|
|
291
301
|
|
|
292
302
|
this.localFilterThreshold = localFilterThreshold
|
|
303
|
+
this._cacheTtlMs = cacheTtlMs
|
|
293
304
|
this._readOnly = readOnly
|
|
294
305
|
this._warmup = warmup
|
|
295
306
|
this._authSensitive = authSensitive ?? this._getStorageRequiresAuth()
|
|
@@ -970,6 +981,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
970
981
|
Object.keys(mergedParams.filters || {}).length > 0
|
|
971
982
|
const canUseCache = !hasFilters || cacheSafe
|
|
972
983
|
|
|
984
|
+
// Check TTL expiration before using cache
|
|
985
|
+
if (this._isCacheExpired()) {
|
|
986
|
+
this.invalidateCache()
|
|
987
|
+
}
|
|
988
|
+
|
|
973
989
|
// 1. Cache valid + cacheable -> use cache with local filtering
|
|
974
990
|
if (this._cache.valid && canUseCache) {
|
|
975
991
|
if (!_internal) this._stats.cacheHits++
|
|
@@ -1058,6 +1074,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
1058
1074
|
)
|
|
1059
1075
|
this._stats.get++
|
|
1060
1076
|
|
|
1077
|
+
// Check TTL expiration before using cache
|
|
1078
|
+
if (this._isCacheExpired()) {
|
|
1079
|
+
this.invalidateCache()
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1061
1082
|
// Try cache first if valid and complete
|
|
1062
1083
|
if (this._cache.valid && !this.overflow) {
|
|
1063
1084
|
const idStr = String(id)
|
|
@@ -1100,6 +1121,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
1100
1121
|
): Promise<T[]> {
|
|
1101
1122
|
if (!ids || ids.length === 0) return []
|
|
1102
1123
|
|
|
1124
|
+
// Check TTL expiration before using cache
|
|
1125
|
+
if (this._isCacheExpired()) {
|
|
1126
|
+
this.invalidateCache()
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1103
1129
|
// Try cache first if valid and complete
|
|
1104
1130
|
if (this._cache.valid && !this.overflow) {
|
|
1105
1131
|
const idStrs = new Set(ids.map(String))
|
|
@@ -1334,6 +1360,57 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
1334
1360
|
return this.localFilterThreshold ?? 100
|
|
1335
1361
|
}
|
|
1336
1362
|
|
|
1363
|
+
/**
|
|
1364
|
+
* Get effective cache TTL in milliseconds
|
|
1365
|
+
* Priority: storage.cacheTtlMs > entity.cacheTtlMs > kernel.defaultEntityCacheTtlMs > -1
|
|
1366
|
+
*
|
|
1367
|
+
* Values:
|
|
1368
|
+
* - 0: disable caching
|
|
1369
|
+
* - -1: infinite (no expiration, cache until invalidation)
|
|
1370
|
+
* - > 0: TTL in milliseconds
|
|
1371
|
+
*/
|
|
1372
|
+
get effectiveCacheTtlMs(): number {
|
|
1373
|
+
// 1. Storage capabilities (can be set dynamically from API headers)
|
|
1374
|
+
const storageCaps =
|
|
1375
|
+
(this.storage as unknown as { capabilities?: StorageCapabilities })?.capabilities ||
|
|
1376
|
+
(this.storage?.constructor as { capabilities?: StorageCapabilities })?.capabilities
|
|
1377
|
+
if (storageCaps?.cacheTtlMs !== undefined) {
|
|
1378
|
+
return storageCaps.cacheTtlMs
|
|
1379
|
+
}
|
|
1380
|
+
|
|
1381
|
+
// 2. Entity-level config
|
|
1382
|
+
if (this._cacheTtlMs !== null) {
|
|
1383
|
+
return this._cacheTtlMs
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// 3. Global default from kernel options
|
|
1387
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
1388
|
+
const globalTtl = (this._orchestrator as any)?.kernelOptions?.defaultEntityCacheTtlMs
|
|
1389
|
+
if (globalTtl !== undefined) {
|
|
1390
|
+
return globalTtl
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// 4. Default: infinite (no expiration)
|
|
1394
|
+
return -1
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/**
|
|
1398
|
+
* Check if caching is disabled by TTL=0
|
|
1399
|
+
*/
|
|
1400
|
+
get isCacheTtlDisabled(): boolean {
|
|
1401
|
+
return this.effectiveCacheTtlMs === 0
|
|
1402
|
+
}
|
|
1403
|
+
|
|
1404
|
+
/**
|
|
1405
|
+
* Check if cache has expired based on TTL
|
|
1406
|
+
*/
|
|
1407
|
+
protected _isCacheExpired(): boolean {
|
|
1408
|
+
if (!this._cache.valid || !this._cache.loadedAt) return false
|
|
1409
|
+
const ttl = this.effectiveCacheTtlMs
|
|
1410
|
+
if (ttl <= 0) return false // 0 = disabled (handled elsewhere), -1 = infinite
|
|
1411
|
+
return Date.now() - this._cache.loadedAt > ttl
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1337
1414
|
/**
|
|
1338
1415
|
* Check if storage adapter supports returning total count
|
|
1339
1416
|
*/
|
|
@@ -1564,6 +1641,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
1564
1641
|
*/
|
|
1565
1642
|
get isCacheEnabled(): boolean {
|
|
1566
1643
|
if (this.effectiveThreshold <= 0) return false
|
|
1644
|
+
if (this.isCacheTtlDisabled) return false // TTL=0 disables cache
|
|
1567
1645
|
// Check capabilities (instance getter or static)
|
|
1568
1646
|
const caps =
|
|
1569
1647
|
(this.storage as unknown as { capabilities?: StorageCapabilities })?.capabilities ||
|
|
@@ -1870,6 +1948,7 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
1870
1948
|
* Get cache info (for debugging)
|
|
1871
1949
|
*/
|
|
1872
1950
|
getCacheInfo(): CacheInfo {
|
|
1951
|
+
const ttlMs = this.effectiveCacheTtlMs
|
|
1873
1952
|
return {
|
|
1874
1953
|
enabled: this.isCacheEnabled,
|
|
1875
1954
|
storageSupportsTotal: this.storageSupportsTotal,
|
|
@@ -1879,6 +1958,11 @@ export class EntityManager<T extends EntityRecord = EntityRecord> {
|
|
|
1879
1958
|
itemCount: this._cache.items.length,
|
|
1880
1959
|
total: this._cache.total,
|
|
1881
1960
|
loadedAt: this._cache.loadedAt,
|
|
1961
|
+
ttlMs,
|
|
1962
|
+
expiresAt: this._cache.loadedAt && ttlMs > 0
|
|
1963
|
+
? this._cache.loadedAt + ttlMs
|
|
1964
|
+
: null,
|
|
1965
|
+
expired: this._isCacheExpired(),
|
|
1882
1966
|
}
|
|
1883
1967
|
}
|
|
1884
1968
|
|
package/src/kernel/Kernel.ts
CHANGED
|
@@ -210,6 +210,8 @@ export interface KernelOptions {
|
|
|
210
210
|
layouts?: LayoutComponents
|
|
211
211
|
security?: SecurityConfig
|
|
212
212
|
warmup?: boolean
|
|
213
|
+
/** Default entity cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Default: -1 (infinite). */
|
|
214
|
+
defaultEntityCacheTtlMs?: number
|
|
213
215
|
eventRouter?: RoutesConfig
|
|
214
216
|
sse?: SSEConfig
|
|
215
217
|
debugBar?: DebugBarConfig
|
|
@@ -951,6 +953,11 @@ export class Kernel {
|
|
|
951
953
|
entityAuthAdapter: (this.options.entityAuthAdapter as EntityAuthAdapter) || null,
|
|
952
954
|
})
|
|
953
955
|
|
|
956
|
+
// Pass kernel options to orchestrator (avoid circular reference for Vue reactivity)
|
|
957
|
+
this.orchestrator.kernelOptions = {
|
|
958
|
+
defaultEntityCacheTtlMs: this.options.defaultEntityCacheTtlMs,
|
|
959
|
+
}
|
|
960
|
+
|
|
954
961
|
if (this.options.toast) {
|
|
955
962
|
this.orchestrator.setToastConfig(this.options.toast)
|
|
956
963
|
}
|
|
@@ -57,6 +57,12 @@ export interface CacheInfo {
|
|
|
57
57
|
overflow: boolean
|
|
58
58
|
loadedAt: string | null
|
|
59
59
|
items: unknown[]
|
|
60
|
+
/** TTL in milliseconds (0=disabled, -1=infinite, >0=TTL) */
|
|
61
|
+
ttlMs: number
|
|
62
|
+
/** Timestamp when cache expires (null if no TTL or infinite) */
|
|
63
|
+
expiresAt: number | null
|
|
64
|
+
/** Whether the cache has expired based on TTL */
|
|
65
|
+
expired: boolean
|
|
60
66
|
}
|
|
61
67
|
|
|
62
68
|
/**
|
|
@@ -350,6 +356,9 @@ export class EntitiesCollector extends Collector<EntityEntry> {
|
|
|
350
356
|
threshold?: number
|
|
351
357
|
overflow?: boolean
|
|
352
358
|
loadedAt?: number
|
|
359
|
+
ttlMs?: number
|
|
360
|
+
expiresAt?: number | null
|
|
361
|
+
expired?: boolean
|
|
353
362
|
}
|
|
354
363
|
getStats?: () => StatsInfo
|
|
355
364
|
getRequiredFields?: () => string[]
|
|
@@ -414,7 +423,10 @@ export class EntitiesCollector extends Collector<EntityEntry> {
|
|
|
414
423
|
threshold: cache.threshold ?? 0,
|
|
415
424
|
overflow: cache.overflow ?? false,
|
|
416
425
|
loadedAt: cache.loadedAt ? new Date(cache.loadedAt).toLocaleTimeString() : null,
|
|
417
|
-
items: this._getCacheItems(extManager, 50)
|
|
426
|
+
items: this._getCacheItems(extManager, 50),
|
|
427
|
+
ttlMs: cache.ttlMs ?? -1,
|
|
428
|
+
expiresAt: cache.expiresAt ?? null,
|
|
429
|
+
expired: cache.expired ?? false
|
|
418
430
|
},
|
|
419
431
|
|
|
420
432
|
permissions: {
|
|
@@ -41,6 +41,12 @@ interface CacheInfo {
|
|
|
41
41
|
total?: number
|
|
42
42
|
threshold?: number
|
|
43
43
|
items?: Record<string, unknown>[]
|
|
44
|
+
/** TTL in milliseconds (0=disabled, -1=infinite, >0=TTL) */
|
|
45
|
+
ttlMs?: number
|
|
46
|
+
/** Timestamp when cache expires (null if no TTL) */
|
|
47
|
+
expiresAt?: number | null
|
|
48
|
+
/** Whether the cache has expired based on TTL */
|
|
49
|
+
expired?: boolean
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
interface RelationInfo {
|
|
@@ -212,6 +218,26 @@ function getCapabilityLabel(cap: string): string {
|
|
|
212
218
|
}
|
|
213
219
|
return labels[cap] || cap
|
|
214
220
|
}
|
|
221
|
+
|
|
222
|
+
function formatTtl(ttlMs: number | undefined): string {
|
|
223
|
+
if (ttlMs === undefined) return '?'
|
|
224
|
+
if (ttlMs === -1) return '∞'
|
|
225
|
+
if (ttlMs === 0) return 'off'
|
|
226
|
+
if (ttlMs < 1000) return `${ttlMs}ms`
|
|
227
|
+
if (ttlMs < 60000) return `${Math.round(ttlMs / 1000)}s`
|
|
228
|
+
if (ttlMs < 3600000) return `${Math.round(ttlMs / 60000)}min`
|
|
229
|
+
return `${Math.round(ttlMs / 3600000)}h`
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function formatExpiresIn(expiresAt: number | null | undefined): string {
|
|
233
|
+
if (!expiresAt) return ''
|
|
234
|
+
const diff = expiresAt - Date.now()
|
|
235
|
+
if (diff <= 0) return 'expired'
|
|
236
|
+
if (diff < 1000) return `${diff}ms`
|
|
237
|
+
if (diff < 60000) return `${Math.round(diff / 1000)}s`
|
|
238
|
+
if (diff < 3600000) return `${Math.round(diff / 60000)}min`
|
|
239
|
+
return `${Math.round(diff / 3600000)}h`
|
|
240
|
+
}
|
|
215
241
|
</script>
|
|
216
242
|
|
|
217
243
|
<template>
|
|
@@ -377,18 +403,27 @@ function getCapabilityLabel(cap: string): string {
|
|
|
377
403
|
<template v-if="entity.cache.valid">
|
|
378
404
|
({{ entity.cache.itemCount }} items, threshold {{ entity.cache.threshold }})
|
|
379
405
|
</template>
|
|
406
|
+
<span class="entity-cache-ttl" :class="{ 'ttl-expired': entity.cache.expired }">
|
|
407
|
+
TTL: {{ formatTtl(entity.cache.ttlMs) }}
|
|
408
|
+
<template v-if="entity.cache.valid && entity.cache.expiresAt && !entity.cache.expired">
|
|
409
|
+
({{ formatExpiresIn(entity.cache.expiresAt) }} left)
|
|
410
|
+
</template>
|
|
411
|
+
<template v-if="entity.cache.expired">
|
|
412
|
+
<i class="pi pi-exclamation-triangle" title="Cache expired" />
|
|
413
|
+
</template>
|
|
414
|
+
</span>
|
|
380
415
|
</span>
|
|
381
416
|
<button
|
|
382
|
-
v-if="!entity.cache.valid"
|
|
417
|
+
v-if="!entity.cache.valid || entity.cache.expired"
|
|
383
418
|
class="entity-load-btn"
|
|
384
419
|
:disabled="isLoading(entity.name)"
|
|
385
420
|
@click.stop="loadCache(entity.name)"
|
|
386
421
|
>
|
|
387
422
|
<i :class="['pi', isLoading(entity.name) ? 'pi-spin pi-spinner' : 'pi-download']" />
|
|
388
|
-
{{ isLoading(entity.name) ? 'Loading...' : 'Load' }}
|
|
423
|
+
{{ isLoading(entity.name) ? 'Loading...' : (entity.cache.expired ? 'Reload' : 'Load') }}
|
|
389
424
|
</button>
|
|
390
425
|
<button
|
|
391
|
-
v-
|
|
426
|
+
v-if="entity.cache.valid && !entity.cache.expired"
|
|
392
427
|
class="entity-invalidate-btn"
|
|
393
428
|
title="Invalidate cache"
|
|
394
429
|
@click.stop="invalidateCache(entity.name)"
|
|
@@ -102,9 +102,10 @@ export class Orchestrator {
|
|
|
102
102
|
private _toastConfig: ToastConfig = {}
|
|
103
103
|
private _toastHelper: ToastHelper | null = null
|
|
104
104
|
|
|
105
|
-
//
|
|
106
|
-
|
|
107
|
-
|
|
105
|
+
// Kernel options (set by Kernel after construction, avoids circular reference)
|
|
106
|
+
kernelOptions?: {
|
|
107
|
+
defaultEntityCacheTtlMs?: number
|
|
108
|
+
}
|
|
108
109
|
|
|
109
110
|
constructor(options: OrchestratorOptions = {}) {
|
|
110
111
|
const {
|
package/src/styles/_sidebar.scss
CHANGED
|
@@ -252,3 +252,129 @@ $sidebar-box-content-duration: 0.1s;
|
|
|
252
252
|
opacity: 0;
|
|
253
253
|
transition: opacity 0s;
|
|
254
254
|
}
|
|
255
|
+
|
|
256
|
+
/* =============================================================================
|
|
257
|
+
Nav Item - Sidebar navigation items
|
|
258
|
+
|
|
259
|
+
Same behavior as SidebarBox:
|
|
260
|
+
- Icon stays at fixed left position (never moves horizontally)
|
|
261
|
+
- On close: text disappears immediately
|
|
262
|
+
- On open: text appears with delayed fade after sidebar is fully open
|
|
263
|
+
============================================================================= */
|
|
264
|
+
|
|
265
|
+
// Nav item icon width
|
|
266
|
+
$nav-item-icon-width: 1.125rem;
|
|
267
|
+
|
|
268
|
+
// Nav item content left = icon-left + icon-width + gap
|
|
269
|
+
// Icon is centered in collapsed sidebar: (collapsed-width - icon-width) / 2
|
|
270
|
+
// Content starts after icon: icon-left + icon-width + gap = (collapsed-width + icon-width) / 2 + gap
|
|
271
|
+
$nav-item-content-left: calc((var(--fad-sidebar-width-collapsed) + #{$nav-item-icon-width}) / 2 + 0.375rem);
|
|
272
|
+
|
|
273
|
+
.sidebar-nav .nav-item {
|
|
274
|
+
position: relative;
|
|
275
|
+
display: flex;
|
|
276
|
+
align-items: center;
|
|
277
|
+
padding: var(--fad-nav-item-padding-y) var(--fad-nav-item-padding-x);
|
|
278
|
+
padding-left: $nav-item-content-left;
|
|
279
|
+
color: var(--p-surface-300, #cbd5e1);
|
|
280
|
+
text-decoration: none;
|
|
281
|
+
font-size: var(--fad-font-size-sm);
|
|
282
|
+
font-weight: var(--fad-font-weight-normal);
|
|
283
|
+
border-radius: 0;
|
|
284
|
+
transition: background var(--fad-transition-fast), color var(--fad-transition-fast), padding var(--fad-transition-slow);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Icon: FIXED position at left, centered in collapsed sidebar width
|
|
288
|
+
// Position = (collapsed-width - icon-width) / 2 so icon is centered when collapsed
|
|
289
|
+
.sidebar-nav .nav-item i {
|
|
290
|
+
position: absolute;
|
|
291
|
+
left: calc((var(--fad-sidebar-width-collapsed) - #{$nav-item-icon-width}) / 2);
|
|
292
|
+
top: 50%;
|
|
293
|
+
transform: translateY(-50%);
|
|
294
|
+
font-size: var(--fad-nav-icon-size);
|
|
295
|
+
width: $nav-item-icon-width;
|
|
296
|
+
text-align: center;
|
|
297
|
+
opacity: 0.8;
|
|
298
|
+
// No horizontal transition - icon never moves
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// Text: delayed fade-in when sidebar opens
|
|
302
|
+
.sidebar-nav .nav-item span {
|
|
303
|
+
white-space: nowrap;
|
|
304
|
+
opacity: 1;
|
|
305
|
+
transition: opacity $sidebar-box-content-duration ease $sidebar-box-content-delay;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
.sidebar-nav .nav-item:hover {
|
|
309
|
+
background: var(--p-surface-700, #334155);
|
|
310
|
+
color: var(--p-surface-0, white);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
.sidebar-nav .nav-item-active {
|
|
314
|
+
background: var(--p-surface-700, #334155);
|
|
315
|
+
color: var(--p-primary-300, #93c5fd);
|
|
316
|
+
border-left: 2px solid var(--p-primary-400, #60a5fa);
|
|
317
|
+
padding-left: calc(#{$nav-item-content-left} - 2px);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
.sidebar-nav .nav-item-active:hover {
|
|
321
|
+
background: var(--p-surface-600, #475569);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
.sidebar-nav .nav-item-active i {
|
|
325
|
+
opacity: 1;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/* =============================================================================
|
|
329
|
+
Nav Item - Collapsed state
|
|
330
|
+
============================================================================= */
|
|
331
|
+
|
|
332
|
+
.sidebar--collapsed .sidebar-nav .nav-item {
|
|
333
|
+
padding: 0.5rem 0;
|
|
334
|
+
padding-left: $nav-item-content-left; // Keep same padding - icon position doesn't change
|
|
335
|
+
border-left: none !important;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.sidebar--collapsed .sidebar-nav .nav-item-active {
|
|
339
|
+
border-left: none !important;
|
|
340
|
+
padding-left: $nav-item-content-left;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
// Text: disappears IMMEDIATELY when closing (no transition)
|
|
344
|
+
.sidebar--collapsed .sidebar-nav .nav-item span {
|
|
345
|
+
opacity: 0;
|
|
346
|
+
visibility: hidden;
|
|
347
|
+
transition: opacity 0s, visibility 0s; // Instant hide
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Icon: stays at EXACT SAME left position - no movement at all
|
|
351
|
+
// (inherits left: $sidebar-box-icon-left, no change needed)
|
|
352
|
+
|
|
353
|
+
/* =============================================================================
|
|
354
|
+
Nav Item - Mobile responsive
|
|
355
|
+
On mobile, sidebar becomes fullscreen and collapsed state is ignored
|
|
356
|
+
============================================================================= */
|
|
357
|
+
|
|
358
|
+
@media (max-width: 767px) {
|
|
359
|
+
.sidebar.sidebar--collapsed .sidebar-nav .nav-item {
|
|
360
|
+
justify-content: flex-start;
|
|
361
|
+
padding: var(--fad-nav-item-padding-y) var(--fad-nav-item-padding-x);
|
|
362
|
+
padding-left: $nav-item-content-left; // Keep same padding for absolute icon
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
.sidebar.sidebar--collapsed .sidebar-nav .nav-item span {
|
|
366
|
+
display: inline;
|
|
367
|
+
opacity: 1;
|
|
368
|
+
visibility: visible;
|
|
369
|
+
width: auto;
|
|
370
|
+
transition: opacity $sidebar-box-content-duration ease $sidebar-box-content-delay; // Restore delayed fade-in
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
.sidebar.sidebar--collapsed .sidebar-nav .nav-item i {
|
|
374
|
+
// Keep absolute positioning on mobile too - same centered position
|
|
375
|
+
position: absolute;
|
|
376
|
+
left: calc((var(--fad-sidebar-width-collapsed) - #{$nav-item-icon-width}) / 2);
|
|
377
|
+
font-size: var(--fad-nav-icon-size);
|
|
378
|
+
width: $nav-item-icon-width;
|
|
379
|
+
}
|
|
380
|
+
}
|
package/src/types/index.ts
CHANGED
|
@@ -47,6 +47,8 @@ export interface StorageCapabilities {
|
|
|
47
47
|
supportsCaching: boolean
|
|
48
48
|
requiresAuth?: boolean
|
|
49
49
|
searchFields?: string[]
|
|
50
|
+
/** Cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Can be set dynamically from API headers. */
|
|
51
|
+
cacheTtlMs?: number
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
// ============ FIELD TYPES ============
|
|
@@ -143,6 +145,8 @@ export interface EntityManagerOptions<T extends EntityRecord = EntityRecord> {
|
|
|
143
145
|
labelField?: string | ((entity: T) => string)
|
|
144
146
|
fields?: Record<string, FieldConfig>
|
|
145
147
|
localFilterThreshold?: number | null
|
|
148
|
+
/** Cache TTL in milliseconds (0=disabled, -1=infinite, >0=TTL). Overrides global, overridden by storage. */
|
|
149
|
+
cacheTtlMs?: number | null
|
|
146
150
|
readOnly?: boolean
|
|
147
151
|
warmup?: boolean
|
|
148
152
|
authSensitive?: boolean
|