qdadm 0.31.0 → 0.32.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/composables/useAuth.js +2 -1
- package/src/debug/AuthCollector.js +104 -18
- package/src/debug/EntitiesCollector.js +28 -1
- package/src/debug/components/panels/AuthPanel.vue +81 -10
- package/src/debug/components/panels/EntitiesPanel.vue +100 -4
- package/src/entity/auth/CompositeAuthAdapter.js +212 -0
- package/src/entity/auth/factory.js +207 -0
- package/src/entity/auth/factory.test.js +257 -0
- package/src/entity/auth/index.js +14 -0
- package/src/entity/storage/MockApiStorage.js +34 -1
- package/src/kernel/Kernel.js +32 -0
package/package.json
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* For route guards or services, use authAdapter directly.
|
|
9
9
|
*
|
|
10
10
|
* Reactivity:
|
|
11
|
-
* - Listens to auth:login, auth:logout, auth:impersonate signals
|
|
11
|
+
* - Listens to auth:login, auth:logout, auth:impersonate, auth:impersonate:stop signals
|
|
12
12
|
* - User computed re-evaluates when these signals fire
|
|
13
13
|
* - No polling or manual refresh needed
|
|
14
14
|
*
|
|
@@ -54,6 +54,7 @@ export function useAuth() {
|
|
|
54
54
|
cleanups.push(signals.on('auth:login', () => { authTick.value++ }))
|
|
55
55
|
cleanups.push(signals.on('auth:logout', () => { authTick.value++ }))
|
|
56
56
|
cleanups.push(signals.on('auth:impersonate', () => { authTick.value++ }))
|
|
57
|
+
cleanups.push(signals.on('auth:impersonate:stop', () => { authTick.value++ }))
|
|
57
58
|
}
|
|
58
59
|
|
|
59
60
|
// Cleanup signal subscriptions on unmount
|
|
@@ -39,8 +39,11 @@ export class AuthCollector extends Collector {
|
|
|
39
39
|
this._ctx = null
|
|
40
40
|
this._signalCleanups = []
|
|
41
41
|
// Activity tracking for login/logout events
|
|
42
|
-
|
|
43
|
-
this.
|
|
42
|
+
// Keep recent events to show stacked (last N events)
|
|
43
|
+
this._recentEvents = [] // Array of { type: 'login'|'logout', timestamp: Date, seen: boolean }
|
|
44
|
+
this._maxEvents = 5
|
|
45
|
+
this._eventTtl = options.eventTtl ?? 60000 // Events expire after 60s by default
|
|
46
|
+
this._expiryTimer = null
|
|
44
47
|
}
|
|
45
48
|
|
|
46
49
|
/**
|
|
@@ -70,19 +73,81 @@ export class AuthCollector extends Collector {
|
|
|
70
73
|
}
|
|
71
74
|
|
|
72
75
|
// Listen to auth events and track activity
|
|
73
|
-
const loginCleanup = signals.on('auth:login', () => {
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
this.
|
|
76
|
+
const loginCleanup = signals.on('auth:login', (event) => {
|
|
77
|
+
// QuarKernel wraps payload in KernelEvent - extract data from event.data
|
|
78
|
+
const data = event?.data || event
|
|
79
|
+
const user = data?.user || this._authAdapter?.getUser?.()
|
|
80
|
+
this._addEvent('login', { user })
|
|
77
81
|
})
|
|
78
82
|
this._signalCleanups.push(loginCleanup)
|
|
79
83
|
|
|
80
84
|
const logoutCleanup = signals.on('auth:logout', () => {
|
|
81
|
-
this.
|
|
82
|
-
this._lastEvent = 'logout'
|
|
83
|
-
this.notifyChange()
|
|
85
|
+
this._addEvent('logout')
|
|
84
86
|
})
|
|
85
87
|
this._signalCleanups.push(logoutCleanup)
|
|
88
|
+
|
|
89
|
+
const impersonateCleanup = signals.on('auth:impersonate', (payload) => {
|
|
90
|
+
this._addEvent('impersonate', payload)
|
|
91
|
+
})
|
|
92
|
+
this._signalCleanups.push(impersonateCleanup)
|
|
93
|
+
|
|
94
|
+
const impersonateStopCleanup = signals.on('auth:impersonate:stop', (payload) => {
|
|
95
|
+
this._addEvent('impersonate-stop', payload)
|
|
96
|
+
})
|
|
97
|
+
this._signalCleanups.push(impersonateStopCleanup)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Add an auth event to the recent events list
|
|
102
|
+
* @param {string} type - 'login' | 'logout' | 'impersonate' | 'impersonate-stop'
|
|
103
|
+
* @param {object} [data] - Optional event data (e.g., { user } for impersonate)
|
|
104
|
+
* @private
|
|
105
|
+
*/
|
|
106
|
+
_addEvent(type, data = null) {
|
|
107
|
+
this._recentEvents.unshift({
|
|
108
|
+
type,
|
|
109
|
+
timestamp: new Date(),
|
|
110
|
+
id: Date.now(), // Unique ID for Vue key
|
|
111
|
+
seen: false,
|
|
112
|
+
data
|
|
113
|
+
})
|
|
114
|
+
// Keep only last N events
|
|
115
|
+
if (this._recentEvents.length > this._maxEvents) {
|
|
116
|
+
this._recentEvents.pop()
|
|
117
|
+
}
|
|
118
|
+
this._scheduleExpiry()
|
|
119
|
+
this.notifyChange()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Schedule event expiry check
|
|
124
|
+
* @private
|
|
125
|
+
*/
|
|
126
|
+
_scheduleExpiry() {
|
|
127
|
+
if (this._expiryTimer) return // Already scheduled
|
|
128
|
+
this._expiryTimer = setTimeout(() => {
|
|
129
|
+
this._expireOldEvents()
|
|
130
|
+
}, this._eventTtl)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Remove expired events
|
|
135
|
+
* @private
|
|
136
|
+
*/
|
|
137
|
+
_expireOldEvents() {
|
|
138
|
+
this._expiryTimer = null
|
|
139
|
+
const now = Date.now()
|
|
140
|
+
const before = this._recentEvents.length
|
|
141
|
+
this._recentEvents = this._recentEvents.filter(
|
|
142
|
+
e => (now - e.timestamp.getTime()) < this._eventTtl
|
|
143
|
+
)
|
|
144
|
+
if (this._recentEvents.length < before) {
|
|
145
|
+
this.notifyChange()
|
|
146
|
+
}
|
|
147
|
+
// Reschedule if still have events
|
|
148
|
+
if (this._recentEvents.length > 0) {
|
|
149
|
+
this._scheduleExpiry()
|
|
150
|
+
}
|
|
86
151
|
}
|
|
87
152
|
|
|
88
153
|
/**
|
|
@@ -90,6 +155,10 @@ export class AuthCollector extends Collector {
|
|
|
90
155
|
* @protected
|
|
91
156
|
*/
|
|
92
157
|
_doUninstall() {
|
|
158
|
+
if (this._expiryTimer) {
|
|
159
|
+
clearTimeout(this._expiryTimer)
|
|
160
|
+
this._expiryTimer = null
|
|
161
|
+
}
|
|
93
162
|
for (const cleanup of this._signalCleanups) {
|
|
94
163
|
if (typeof cleanup === 'function') cleanup()
|
|
95
164
|
}
|
|
@@ -99,11 +168,11 @@ export class AuthCollector extends Collector {
|
|
|
99
168
|
}
|
|
100
169
|
|
|
101
170
|
/**
|
|
102
|
-
* Get badge - show
|
|
103
|
-
* @returns {number}
|
|
171
|
+
* Get badge - show count of unseen auth events
|
|
172
|
+
* @returns {number} Number of unseen events
|
|
104
173
|
*/
|
|
105
174
|
getBadge() {
|
|
106
|
-
return this.
|
|
175
|
+
return this._recentEvents.filter(e => !e.seen).length
|
|
107
176
|
}
|
|
108
177
|
|
|
109
178
|
/**
|
|
@@ -111,24 +180,41 @@ export class AuthCollector extends Collector {
|
|
|
111
180
|
* @returns {boolean}
|
|
112
181
|
*/
|
|
113
182
|
hasActivity() {
|
|
114
|
-
return this.
|
|
183
|
+
return this._recentEvents.some(e => !e.seen)
|
|
115
184
|
}
|
|
116
185
|
|
|
117
186
|
/**
|
|
118
|
-
* Get
|
|
187
|
+
* Get all recent auth events (newest first)
|
|
188
|
+
* @returns {Array<{type: string, timestamp: Date, id: number, seen: boolean}>}
|
|
189
|
+
*/
|
|
190
|
+
getRecentEvents() {
|
|
191
|
+
return this._recentEvents
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Get the last auth event type (for backward compatibility)
|
|
119
196
|
* @returns {string|null} 'login' | 'logout' | null
|
|
120
197
|
*/
|
|
121
198
|
getLastEvent() {
|
|
122
|
-
return this.
|
|
199
|
+
return this._recentEvents[0]?.type || null
|
|
123
200
|
}
|
|
124
201
|
|
|
125
202
|
/**
|
|
126
|
-
* Mark
|
|
203
|
+
* Mark all events as seen (badge resets but events stay visible)
|
|
127
204
|
* Note: Does not call notifyChange() to avoid re-render loop
|
|
128
205
|
*/
|
|
129
206
|
markSeen() {
|
|
130
|
-
this.
|
|
131
|
-
|
|
207
|
+
for (const event of this._recentEvents) {
|
|
208
|
+
event.seen = true
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Clear all events (for explicit dismissal)
|
|
214
|
+
*/
|
|
215
|
+
clearEvents() {
|
|
216
|
+
this._recentEvents = []
|
|
217
|
+
this.notifyChange()
|
|
132
218
|
}
|
|
133
219
|
|
|
134
220
|
/**
|
|
@@ -219,10 +219,11 @@ export class EntitiesCollector extends Collector {
|
|
|
219
219
|
idField: manager.idField,
|
|
220
220
|
|
|
221
221
|
// Storage info
|
|
222
|
+
// Prefer instance capabilities (may include requiresAuth) over static ones
|
|
222
223
|
storage: {
|
|
223
224
|
type: storage?.constructor?.name || 'None',
|
|
224
225
|
endpoint: storage?.endpoint || storage?._endpoint || null,
|
|
225
|
-
capabilities: storage?.constructor?.capabilities || {}
|
|
226
|
+
capabilities: storage?.capabilities || storage?.constructor?.capabilities || {}
|
|
226
227
|
},
|
|
227
228
|
|
|
228
229
|
// Cache info
|
|
@@ -373,4 +374,30 @@ export class EntitiesCollector extends Collector {
|
|
|
373
374
|
markEntitySeen(entityName) {
|
|
374
375
|
this._activeEntities.delete(entityName)
|
|
375
376
|
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* Test fetch data from a specific entity
|
|
380
|
+
* Used to test auth protection - will throw 401 if not authenticated
|
|
381
|
+
* @param {string} entityName - Entity name
|
|
382
|
+
* @returns {Promise<{success: boolean, count?: number, error?: string, status?: number}>}
|
|
383
|
+
*/
|
|
384
|
+
async testFetch(entityName) {
|
|
385
|
+
if (!this._orchestrator) {
|
|
386
|
+
return { success: false, error: 'No orchestrator' }
|
|
387
|
+
}
|
|
388
|
+
try {
|
|
389
|
+
const manager = this._orchestrator.get(entityName)
|
|
390
|
+
const result = await manager.storage.list({ page: 1, page_size: 1 })
|
|
391
|
+
return {
|
|
392
|
+
success: true,
|
|
393
|
+
count: result.total ?? result.items?.length ?? 0
|
|
394
|
+
}
|
|
395
|
+
} catch (e) {
|
|
396
|
+
return {
|
|
397
|
+
success: false,
|
|
398
|
+
error: e.message,
|
|
399
|
+
status: e.status
|
|
400
|
+
}
|
|
401
|
+
}
|
|
402
|
+
}
|
|
376
403
|
}
|
|
@@ -10,14 +10,13 @@ const props = defineProps({
|
|
|
10
10
|
entries: { type: Array, required: true }
|
|
11
11
|
})
|
|
12
12
|
|
|
13
|
-
// Mark
|
|
13
|
+
// Mark events as seen when panel is viewed (badge resets but events stay visible)
|
|
14
14
|
onMounted(() => {
|
|
15
15
|
props.collector.markSeen?.()
|
|
16
16
|
})
|
|
17
17
|
|
|
18
|
-
//
|
|
19
|
-
const
|
|
20
|
-
const lastEvent = computed(() => props.collector.getLastEvent?.())
|
|
18
|
+
// Get all recent events (stacked display)
|
|
19
|
+
const recentEvents = computed(() => props.collector.getRecentEvents?.() || [])
|
|
21
20
|
|
|
22
21
|
function getIcon(type) {
|
|
23
22
|
const icons = {
|
|
@@ -28,16 +27,60 @@ function getIcon(type) {
|
|
|
28
27
|
}
|
|
29
28
|
return icons[type] || 'pi-info-circle'
|
|
30
29
|
}
|
|
30
|
+
|
|
31
|
+
function formatTime(date) {
|
|
32
|
+
return date.toLocaleTimeString('en-US', { hour12: false })
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getEventIcon(type) {
|
|
36
|
+
const icons = {
|
|
37
|
+
login: 'pi-sign-in',
|
|
38
|
+
logout: 'pi-sign-out',
|
|
39
|
+
impersonate: 'pi-user-edit',
|
|
40
|
+
'impersonate-stop': 'pi-user'
|
|
41
|
+
}
|
|
42
|
+
return icons[type] || 'pi-info-circle'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getEventLabel(event) {
|
|
46
|
+
if (event.type === 'login' && event.data) {
|
|
47
|
+
const user = event.data.user
|
|
48
|
+
const username = user?.username || user?.name || user?.email
|
|
49
|
+
if (username) {
|
|
50
|
+
return `User ${username} logged in`
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
if (event.type === 'impersonate' && event.data) {
|
|
54
|
+
// Payload structure: { target: { username }, original: { username } }
|
|
55
|
+
// Or signal object: { data: { target: { username } } }
|
|
56
|
+
const data = event.data.data || event.data
|
|
57
|
+
const username = data.target?.username
|
|
58
|
+
|| data.username
|
|
59
|
+
|| (typeof data === 'string' ? data : null)
|
|
60
|
+
if (username) {
|
|
61
|
+
return `Impersonating user ${username}`
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (event.type === 'impersonate-stop') {
|
|
65
|
+
const data = event.data?.data || event.data
|
|
66
|
+
const username = data?.original?.username
|
|
67
|
+
if (username) {
|
|
68
|
+
return `Back to ${username}`
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
const labels = {
|
|
72
|
+
login: 'User logged in',
|
|
73
|
+
logout: 'User logged out',
|
|
74
|
+
impersonate: 'Impersonating user',
|
|
75
|
+
'impersonate-stop': 'Stopped impersonation'
|
|
76
|
+
}
|
|
77
|
+
return labels[event.type] || event.type
|
|
78
|
+
}
|
|
31
79
|
</script>
|
|
32
80
|
|
|
33
81
|
<template>
|
|
34
82
|
<div class="auth-panel">
|
|
35
|
-
<!--
|
|
36
|
-
<div v-if="hasActivity" class="auth-activity" :class="lastEvent">
|
|
37
|
-
<i :class="['pi', lastEvent === 'login' ? 'pi-sign-in' : 'pi-sign-out']" />
|
|
38
|
-
<span>{{ lastEvent === 'login' ? 'User logged in' : 'User logged out' }}</span>
|
|
39
|
-
</div>
|
|
40
|
-
|
|
83
|
+
<!-- Invariant entries (user, token, permissions, adapter) -->
|
|
41
84
|
<div v-for="(entry, idx) in entries" :key="idx" class="auth-item">
|
|
42
85
|
<div class="auth-header">
|
|
43
86
|
<i :class="['pi', getIcon(entry.type)]" />
|
|
@@ -46,6 +89,18 @@ function getIcon(type) {
|
|
|
46
89
|
<div v-if="entry.message" class="auth-message">{{ entry.message }}</div>
|
|
47
90
|
<ObjectTree v-else-if="entry.data" :data="entry.data" :maxDepth="4" />
|
|
48
91
|
</div>
|
|
92
|
+
|
|
93
|
+
<!-- Recent auth events (stacked below, newest first) -->
|
|
94
|
+
<div
|
|
95
|
+
v-for="event in recentEvents"
|
|
96
|
+
:key="event.id"
|
|
97
|
+
class="auth-activity"
|
|
98
|
+
:class="event.type"
|
|
99
|
+
>
|
|
100
|
+
<i :class="['pi', getEventIcon(event.type)]" />
|
|
101
|
+
<span>{{ getEventLabel(event) }}</span>
|
|
102
|
+
<span class="auth-time">{{ formatTime(event.timestamp) }}</span>
|
|
103
|
+
</div>
|
|
49
104
|
</div>
|
|
50
105
|
</template>
|
|
51
106
|
|
|
@@ -77,6 +132,22 @@ function getIcon(type) {
|
|
|
77
132
|
border-left: 3px solid #ef4444;
|
|
78
133
|
color: #ef4444;
|
|
79
134
|
}
|
|
135
|
+
.auth-activity.impersonate {
|
|
136
|
+
background: linear-gradient(90deg, rgba(249, 115, 22, 0.2) 0%, rgba(249, 115, 22, 0.05) 100%);
|
|
137
|
+
border-left: 3px solid #f97316;
|
|
138
|
+
color: #f97316;
|
|
139
|
+
}
|
|
140
|
+
.auth-activity.impersonate-stop {
|
|
141
|
+
background: linear-gradient(90deg, rgba(161, 161, 170, 0.2) 0%, rgba(161, 161, 170, 0.05) 100%);
|
|
142
|
+
border-left: 3px solid #a1a1aa;
|
|
143
|
+
color: #a1a1aa;
|
|
144
|
+
}
|
|
145
|
+
.auth-time {
|
|
146
|
+
margin-left: auto;
|
|
147
|
+
font-size: 10px;
|
|
148
|
+
opacity: 0.6;
|
|
149
|
+
font-weight: 400;
|
|
150
|
+
}
|
|
80
151
|
@keyframes pulse {
|
|
81
152
|
0%, 100% { opacity: 1; }
|
|
82
153
|
50% { opacity: 0.7; }
|
|
@@ -19,6 +19,8 @@ onMounted(() => {
|
|
|
19
19
|
|
|
20
20
|
const expandedEntities = ref(new Set())
|
|
21
21
|
const loadingCache = ref(new Set())
|
|
22
|
+
const testingFetch = ref(new Set())
|
|
23
|
+
const testResults = ref(new Map()) // entityName -> { success, count, error, status }
|
|
22
24
|
|
|
23
25
|
function toggleExpand(name) {
|
|
24
26
|
if (expandedEntities.value.has(name)) {
|
|
@@ -56,12 +58,37 @@ function isLoading(name) {
|
|
|
56
58
|
return loadingCache.value.has(name)
|
|
57
59
|
}
|
|
58
60
|
|
|
61
|
+
function isTesting(name) {
|
|
62
|
+
return testingFetch.value.has(name)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function getTestResult(name) {
|
|
66
|
+
return testResults.value.get(name)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async function testFetch(entityName) {
|
|
70
|
+
if (testingFetch.value.has(entityName)) return
|
|
71
|
+
testingFetch.value.add(entityName)
|
|
72
|
+
testingFetch.value = new Set(testingFetch.value)
|
|
73
|
+
testResults.value.delete(entityName)
|
|
74
|
+
testResults.value = new Map(testResults.value)
|
|
75
|
+
try {
|
|
76
|
+
const result = await props.collector.testFetch(entityName)
|
|
77
|
+
testResults.value.set(entityName, result)
|
|
78
|
+
testResults.value = new Map(testResults.value)
|
|
79
|
+
} finally {
|
|
80
|
+
testingFetch.value.delete(entityName)
|
|
81
|
+
testingFetch.value = new Set(testingFetch.value)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
59
85
|
function getCapabilityIcon(cap) {
|
|
60
86
|
const icons = {
|
|
61
87
|
supportsTotal: 'pi-hashtag',
|
|
62
88
|
supportsFilters: 'pi-filter',
|
|
63
89
|
supportsPagination: 'pi-ellipsis-h',
|
|
64
|
-
supportsCaching: 'pi-database'
|
|
90
|
+
supportsCaching: 'pi-database',
|
|
91
|
+
requiresAuth: 'pi-lock'
|
|
65
92
|
}
|
|
66
93
|
return icons[cap] || 'pi-question-circle'
|
|
67
94
|
}
|
|
@@ -71,7 +98,8 @@ function getCapabilityLabel(cap) {
|
|
|
71
98
|
supportsTotal: 'Total count',
|
|
72
99
|
supportsFilters: 'Filters',
|
|
73
100
|
supportsPagination: 'Pagination',
|
|
74
|
-
supportsCaching: 'Caching'
|
|
101
|
+
supportsCaching: 'Caching',
|
|
102
|
+
requiresAuth: 'Requires authentication'
|
|
75
103
|
}
|
|
76
104
|
return labels[cap] || cap
|
|
77
105
|
}
|
|
@@ -107,7 +135,7 @@ function getCapabilityLabel(cap) {
|
|
|
107
135
|
/>
|
|
108
136
|
<i
|
|
109
137
|
v-if="entity.permissions.readOnly"
|
|
110
|
-
class="pi pi-
|
|
138
|
+
class="pi pi-eye perm-icon-readonly"
|
|
111
139
|
title="Read-only entity"
|
|
112
140
|
/>
|
|
113
141
|
</div>
|
|
@@ -143,7 +171,7 @@ function getCapabilityLabel(cap) {
|
|
|
143
171
|
v-for="(enabled, cap) in entity.storage.capabilities"
|
|
144
172
|
:key="cap"
|
|
145
173
|
class="entity-cap"
|
|
146
|
-
:class="[enabled ? 'entity-cap-enabled' : 'entity-cap-disabled']"
|
|
174
|
+
:class="[cap === 'requiresAuth' && enabled ? 'entity-cap-auth' : (enabled ? 'entity-cap-enabled' : 'entity-cap-disabled')]"
|
|
147
175
|
:title="getCapabilityLabel(cap) + (enabled ? ' ✓' : ' ✗')"
|
|
148
176
|
>
|
|
149
177
|
<i :class="['pi', getCapabilityIcon(cap)]" />
|
|
@@ -180,6 +208,29 @@ function getCapabilityLabel(cap) {
|
|
|
180
208
|
<span class="entity-key">Cache:</span>
|
|
181
209
|
<span class="entity-value entity-cache-na">Disabled</span>
|
|
182
210
|
</div>
|
|
211
|
+
<!-- Test Fetch row - always visible for testing auth protection -->
|
|
212
|
+
<div class="entity-row">
|
|
213
|
+
<span class="entity-key">Test:</span>
|
|
214
|
+
<span v-if="getTestResult(entity.name)" class="entity-test-result" :class="getTestResult(entity.name).success ? 'test-success' : 'test-error'">
|
|
215
|
+
<template v-if="getTestResult(entity.name).success">
|
|
216
|
+
<i class="pi pi-check-circle" />
|
|
217
|
+
{{ getTestResult(entity.name).count }} items
|
|
218
|
+
</template>
|
|
219
|
+
<template v-else>
|
|
220
|
+
<i class="pi pi-times-circle" />
|
|
221
|
+
{{ getTestResult(entity.name).status || 'ERR' }}: {{ getTestResult(entity.name).error }}
|
|
222
|
+
</template>
|
|
223
|
+
</span>
|
|
224
|
+
<span v-else class="entity-value entity-test-na">-</span>
|
|
225
|
+
<button
|
|
226
|
+
class="entity-test-btn"
|
|
227
|
+
:disabled="isTesting(entity.name)"
|
|
228
|
+
@click.stop="testFetch(entity.name)"
|
|
229
|
+
>
|
|
230
|
+
<i :class="['pi', isTesting(entity.name) ? 'pi-spin pi-spinner' : 'pi-download']" />
|
|
231
|
+
{{ isTesting(entity.name) ? 'Testing...' : 'Fetch' }}
|
|
232
|
+
</button>
|
|
233
|
+
</div>
|
|
183
234
|
<div class="entity-row">
|
|
184
235
|
<span class="entity-key">Fields:</span>
|
|
185
236
|
<span class="entity-value">{{ entity.fields.count }} fields</span>
|
|
@@ -413,6 +464,47 @@ function getCapabilityLabel(cap) {
|
|
|
413
464
|
background: rgba(239, 68, 68, 0.2);
|
|
414
465
|
color: #ef4444;
|
|
415
466
|
}
|
|
467
|
+
.entity-test-btn {
|
|
468
|
+
display: inline-flex;
|
|
469
|
+
align-items: center;
|
|
470
|
+
gap: 4px;
|
|
471
|
+
padding: 2px 8px;
|
|
472
|
+
background: #3b82f6;
|
|
473
|
+
border: none;
|
|
474
|
+
border-radius: 3px;
|
|
475
|
+
color: #fff;
|
|
476
|
+
cursor: pointer;
|
|
477
|
+
font-size: 10px;
|
|
478
|
+
margin-left: auto;
|
|
479
|
+
}
|
|
480
|
+
.entity-test-btn:hover {
|
|
481
|
+
background: #2563eb;
|
|
482
|
+
}
|
|
483
|
+
.entity-test-btn:disabled {
|
|
484
|
+
opacity: 0.5;
|
|
485
|
+
cursor: not-allowed;
|
|
486
|
+
}
|
|
487
|
+
.entity-test-result {
|
|
488
|
+
display: inline-flex;
|
|
489
|
+
align-items: center;
|
|
490
|
+
gap: 4px;
|
|
491
|
+
padding: 2px 6px;
|
|
492
|
+
border-radius: 3px;
|
|
493
|
+
font-size: 10px;
|
|
494
|
+
margin-left: 8px;
|
|
495
|
+
}
|
|
496
|
+
.test-success {
|
|
497
|
+
background: rgba(34, 197, 94, 0.2);
|
|
498
|
+
color: #22c55e;
|
|
499
|
+
}
|
|
500
|
+
.test-error {
|
|
501
|
+
background: rgba(239, 68, 68, 0.2);
|
|
502
|
+
color: #ef4444;
|
|
503
|
+
}
|
|
504
|
+
.entity-test-na {
|
|
505
|
+
color: #52525b;
|
|
506
|
+
font-style: italic;
|
|
507
|
+
}
|
|
416
508
|
|
|
417
509
|
/* Collapsed summary */
|
|
418
510
|
.entity-summary {
|
|
@@ -485,6 +577,10 @@ function getCapabilityLabel(cap) {
|
|
|
485
577
|
background: rgba(239, 68, 68, 0.15);
|
|
486
578
|
color: #71717a;
|
|
487
579
|
}
|
|
580
|
+
.entity-cap-auth {
|
|
581
|
+
background: rgba(245, 158, 11, 0.2);
|
|
582
|
+
color: #f59e0b;
|
|
583
|
+
}
|
|
488
584
|
.entity-required {
|
|
489
585
|
color: #f59e0b;
|
|
490
586
|
font-size: 10px;
|