@peanut-admin/admin 0.1.0-alpha.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.
Files changed (67) hide show
  1. package/LICENSE +202 -0
  2. package/admin-core/src/access/access.ts +27 -0
  3. package/admin-core/src/api/client.ts +200 -0
  4. package/admin-core/src/api/problem.ts +67 -0
  5. package/admin-core/src/api/refresh.ts +122 -0
  6. package/admin-core/src/auth/stores.ts +121 -0
  7. package/admin-core/src/generated/api.d.ts +22106 -0
  8. package/admin-core/src/governance/audit.ts +56 -0
  9. package/admin-core/src/governance/catalog.ts +86 -0
  10. package/admin-core/src/governance/index.ts +26 -0
  11. package/admin-core/src/governance/menu.ts +63 -0
  12. package/admin-core/src/governance/roles.ts +144 -0
  13. package/admin-core/src/governance/types.ts +64 -0
  14. package/admin-core/src/index.ts +105 -0
  15. package/admin-core/src/lifecycle/tenant.ts +59 -0
  16. package/admin-core/src/module/contribution.ts +124 -0
  17. package/admin-core/src/runtime/config.ts +55 -0
  18. package/admin-core/src/runtime/errors.ts +81 -0
  19. package/admin-core/src/runtime/guard.ts +40 -0
  20. package/admin-core/src/runtime/navigation.ts +105 -0
  21. package/admin-core/src/runtime/overrides.ts +214 -0
  22. package/admin-core/src/targets/store.ts +153 -0
  23. package/admin-shell/src/config.ts +84 -0
  24. package/admin-shell/src/index.ts +40 -0
  25. package/admin-shell/src/layout.ts +332 -0
  26. package/admin-shell/src/overrides.ts +53 -0
  27. package/admin-shell/src/states.ts +93 -0
  28. package/admin-shell/src/targets.ts +128 -0
  29. package/admin-shell/src/theme.ts +15 -0
  30. package/client-core/src/index.ts +325 -0
  31. package/client-nuxt/src/index.ts +40 -0
  32. package/client-uniapp/src/index.ts +50 -0
  33. package/file-media/src/FileAssetSelector.vue +117 -0
  34. package/file-media/src/FileMediaPage.vue +158 -0
  35. package/file-media/src/contracts.ts +220 -0
  36. package/file-media/src/index.ts +19 -0
  37. package/file-media/src/runtime.ts +210 -0
  38. package/import-export/src/ImportExportPage.vue +155 -0
  39. package/import-export/src/contracts.ts +96 -0
  40. package/import-export/src/index.ts +3 -0
  41. package/import-export/src/runtime.ts +128 -0
  42. package/integration-security/src/IntegrationSecurityPage.vue +402 -0
  43. package/integration-security/src/contracts.ts +171 -0
  44. package/integration-security/src/index.ts +3 -0
  45. package/integration-security/src/runtime.ts +180 -0
  46. package/notification-sms/src/NotificationInboxPage.vue +266 -0
  47. package/notification-sms/src/contracts.ts +195 -0
  48. package/notification-sms/src/index.ts +4 -0
  49. package/notification-sms/src/runtime.ts +143 -0
  50. package/ops-console/src/OpsConsolePage.vue +337 -0
  51. package/ops-console/src/contracts.ts +169 -0
  52. package/ops-console/src/index.ts +3 -0
  53. package/ops-console/src/runtime.ts +199 -0
  54. package/package.json +108 -0
  55. package/reference-codes/src/ReferenceCodesPage.vue +942 -0
  56. package/reference-codes/src/contracts.ts +484 -0
  57. package/reference-codes/src/index.ts +53 -0
  58. package/reference-codes/src/runtime.ts +855 -0
  59. package/settings/src/SettingsPage.vue +536 -0
  60. package/settings/src/contracts.ts +331 -0
  61. package/settings/src/index.ts +45 -0
  62. package/settings/src/runtime.ts +545 -0
  63. package/task-job/src/TaskJobPage.vue +120 -0
  64. package/task-job/src/contracts.ts +117 -0
  65. package/task-job/src/index.ts +2 -0
  66. package/task-job/src/runtime.ts +105 -0
  67. package/testing/src/index.ts +141 -0
@@ -0,0 +1,545 @@
1
+ import { defineAdminModule } from '@peanut-admin/admin/core'
2
+ import { inject, reactive } from 'vue'
3
+ import type { AdminModuleContribution } from '@peanut-admin/admin/core'
4
+ import type { InjectionKey } from 'vue'
5
+
6
+ import {
7
+ groupSettingRecords,
8
+ parseSettingResponse,
9
+ parseSettingsList,
10
+ settingEditorKind,
11
+ } from './contracts'
12
+ import type {
13
+ SettingGroup,
14
+ SettingRecord,
15
+ SettingsTransport,
16
+ SettingsTransportResult,
17
+ } from './contracts'
18
+
19
+ export type { SettingsTransport, SettingsTransportResult } from './contracts'
20
+
21
+ export const SETTINGS_MODULE_KEY = 'peanut.settings' as const
22
+ export const SETTINGS_ROUTE_NAME = 'peanut.settings.list' as const
23
+ export const SETTINGS_ROUTE_PATH = '/app/settings' as const
24
+ export const SETTINGS_READ_PERMISSION = 'peanut.settings.read' as const
25
+ export const SETTINGS_MANAGE_PERMISSION = 'peanut.settings.manage' as const
26
+ export const SETTINGS_STORE_KEY = 'peanut.settings.runtime' as const
27
+
28
+ export interface SettingFormState {
29
+ value: unknown
30
+ dirty: boolean
31
+ editRevision: number
32
+ }
33
+
34
+ export interface SettingRequestError {
35
+ kind: 'http' | 'protocol' | 'transport' | 'validation'
36
+ message: string
37
+ requestId: string | null
38
+ status: number | null
39
+ }
40
+
41
+ export interface SettingConflictState {
42
+ message: string
43
+ requestId: string | null
44
+ }
45
+
46
+ export interface SettingsRuntimeState {
47
+ records: SettingRecord[]
48
+ groups: SettingGroup[]
49
+ forms: Record<string, SettingFormState>
50
+ errors: Record<string, SettingRequestError>
51
+ etags: Record<string, string>
52
+ collectionEtag: string | null
53
+ conflicts: Record<string, SettingConflictState>
54
+ pendingResources: Set<string>
55
+ pendingVisibility: Set<string>
56
+ requests: Set<string>
57
+ loading: boolean
58
+ }
59
+
60
+ export interface SettingsRuntime {
61
+ readonly state: SettingsRuntimeState
62
+ canManage: () => boolean
63
+ load: () => Promise<void>
64
+ reload: (resourceKey: string) => Promise<void>
65
+ save: (resourceKey: string) => Promise<void>
66
+ unset: (resourceKey: string) => Promise<void>
67
+ updateForm: (resourceKey: string, value: unknown) => void
68
+ setSecretVisible: (resourceKey: string, visible: boolean) => void
69
+ isSecretVisible: (resourceKey: string) => boolean
70
+ isPending: (resourceKey: string) => boolean
71
+ dispose: () => void
72
+ }
73
+
74
+ export interface SettingsRuntimeOptions {
75
+ readonly transport: SettingsTransport
76
+ readonly canRead: () => boolean
77
+ readonly canManage: () => boolean
78
+ readonly createIdempotencyKey?: () => string
79
+ }
80
+
81
+ const resourceKey = (record: Pick<SettingRecord, 'moduleKey' | 'settingKey'>): string => (
82
+ `${record.moduleKey}/${record.settingKey}`
83
+ )
84
+
85
+ const idempotencyKey = (): string => {
86
+ if (typeof globalThis.crypto?.randomUUID === 'function') {
87
+ return `idem_${globalThis.crypto.randomUUID().replaceAll('-', '')}`
88
+ }
89
+ return `idem_${Date.now().toString(36)}_${Math.random().toString(36).slice(2)}`
90
+ }
91
+
92
+ const strongEtag = (value: string | null): string | null => (
93
+ value !== null && /^"[^"\r\n]+"$/.test(value) ? value : null
94
+ )
95
+
96
+ const responseRequestId = (result: SettingsTransportResult): string | null => {
97
+ if (typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body)) {
98
+ const requestId = (result.body as Record<string, unknown>).request_id
99
+ if (typeof requestId === 'string' && requestId !== '') return requestId
100
+ }
101
+ const requestId = result.headers.get('X-Request-Id')
102
+ return requestId === null || requestId === '' ? null : requestId
103
+ }
104
+
105
+ const requestError = (result: SettingsTransportResult): SettingRequestError => {
106
+ if (typeof result.body !== 'object' || result.body === null || Array.isArray(result.body)) {
107
+ return {
108
+ kind: 'http',
109
+ message: `Settings request failed (${result.status}).`,
110
+ requestId: responseRequestId(result),
111
+ status: result.status,
112
+ }
113
+ }
114
+ const body = result.body as Record<string, unknown>
115
+ return {
116
+ kind: 'http',
117
+ message: typeof body.detail === 'string' && body.detail !== ''
118
+ ? body.detail
119
+ : `Settings request failed (${result.status}).`,
120
+ requestId: responseRequestId(result),
121
+ status: result.status,
122
+ }
123
+ }
124
+
125
+ const protocolError = (result: SettingsTransportResult): SettingRequestError => ({
126
+ kind: 'protocol',
127
+ message: 'The settings response could not be validated.',
128
+ requestId: responseRequestId(result),
129
+ status: result.status,
130
+ })
131
+
132
+ const transportError = (): SettingRequestError => ({
133
+ kind: 'transport',
134
+ message: 'The settings service could not be reached.',
135
+ requestId: null,
136
+ status: null,
137
+ })
138
+
139
+ const validationError = (message: string): SettingRequestError => ({
140
+ kind: 'validation',
141
+ message,
142
+ requestId: null,
143
+ status: null,
144
+ })
145
+
146
+ const isAbortError = (error: unknown): boolean => (
147
+ typeof error === 'object'
148
+ && error !== null
149
+ && 'name' in error
150
+ && error.name === 'AbortError'
151
+ )
152
+
153
+ const initialValue = (record: SettingRecord): unknown => record.secret ? '' : record.value
154
+
155
+ const initialFormState = (record: SettingRecord): SettingFormState => ({
156
+ value: initialValue(record),
157
+ dirty: false,
158
+ editRevision: 0,
159
+ })
160
+
161
+ const formState = (records: readonly SettingRecord[]): Record<string, SettingFormState> => Object.fromEntries(
162
+ records.map(record => [resourceKey(record), initialFormState(record)]),
163
+ )
164
+
165
+ const etagState = (records: readonly SettingRecord[]): Record<string, string> => Object.fromEntries(
166
+ records.flatMap(record => record.etag === null ? [] : [[resourceKey(record), record.etag]]),
167
+ )
168
+
169
+ const assertSuccessful = (result: SettingsTransportResult): void => {
170
+ if (result.status < 200 || result.status >= 300) {
171
+ throw new Error(`SETTINGS_REQUEST_FAILED_${result.status}`)
172
+ }
173
+ }
174
+
175
+ export const createSettingsRuntime = (options: SettingsRuntimeOptions): SettingsRuntime => {
176
+ const state = reactive<SettingsRuntimeState>({
177
+ records: [],
178
+ groups: [],
179
+ forms: {},
180
+ errors: {},
181
+ etags: {},
182
+ collectionEtag: null,
183
+ conflicts: {},
184
+ pendingResources: new Set<string>(),
185
+ pendingVisibility: new Set<string>(),
186
+ requests: new Set<string>(),
187
+ loading: false,
188
+ })
189
+ const controllers = new Map<string, AbortController>()
190
+ const resourceRequests = new Map<string, { controller: AbortController; generation: number; id: string }>()
191
+ const createIdempotencyKey = options.createIdempotencyKey ?? idempotencyKey
192
+ let listRequest: { controller: AbortController; generation: number; id: string } | null = null
193
+ let generation = 0
194
+ let requestSequence = 0
195
+
196
+ const beginRequest = (kind: string): { controller: AbortController; generation: number; id: string } => {
197
+ const controller = new AbortController()
198
+ requestSequence += 1
199
+ const id = `${kind}:${requestSequence}`
200
+ controllers.set(id, controller)
201
+ state.requests.add(id)
202
+ return { controller, generation, id }
203
+ }
204
+
205
+ const finishRequest = (id: string): void => {
206
+ controllers.delete(id)
207
+ state.requests.delete(id)
208
+ }
209
+
210
+ const cancelListRequest = (): void => {
211
+ if (listRequest === null) return
212
+ const request = listRequest
213
+ listRequest = null
214
+ request.controller.abort()
215
+ finishRequest(request.id)
216
+ state.loading = false
217
+ }
218
+
219
+ const beginListRequest = (): { controller: AbortController; generation: number; id: string } => {
220
+ if (resourceRequests.size > 0) throw new Error('SETTINGS_MUTATION_PENDING')
221
+ cancelListRequest()
222
+ const request = beginRequest('list')
223
+ listRequest = request
224
+ state.loading = true
225
+ return request
226
+ }
227
+
228
+ const beginResourceRequest = (
229
+ kind: 'save' | 'unset',
230
+ key: string,
231
+ ): { controller: AbortController; generation: number; id: string } => {
232
+ if (resourceRequests.has(key)) throw new Error('SETTINGS_RESOURCE_REQUEST_PENDING')
233
+ cancelListRequest()
234
+ const request = beginRequest(`${kind}:${key}`)
235
+ resourceRequests.set(key, request)
236
+ state.pendingResources.add(key)
237
+ return request
238
+ }
239
+
240
+ const isCurrentListRequest = (request: { generation: number; id: string }): boolean => (
241
+ request.generation === generation && listRequest?.id === request.id
242
+ )
243
+
244
+ const isCurrentResourceRequest = (
245
+ key: string,
246
+ request: { generation: number; id: string },
247
+ ): boolean => request.generation === generation && resourceRequests.get(key)?.id === request.id
248
+
249
+ const finishListRequest = (request: { id: string }): void => {
250
+ if (listRequest?.id === request.id) {
251
+ listRequest = null
252
+ state.loading = false
253
+ }
254
+ finishRequest(request.id)
255
+ }
256
+
257
+ const finishResourceRequest = (key: string, request: { id: string }): void => {
258
+ if (resourceRequests.get(key)?.id === request.id) {
259
+ resourceRequests.delete(key)
260
+ state.pendingResources.delete(key)
261
+ }
262
+ finishRequest(request.id)
263
+ }
264
+
265
+ const applyRecords = (records: SettingRecord[], collectionEtag: string): void => {
266
+ state.records = records
267
+ state.groups = groupSettingRecords(records)
268
+ state.forms = formState(records)
269
+ state.etags = etagState(records)
270
+ state.collectionEtag = collectionEtag
271
+ state.errors = {}
272
+ state.conflicts = {}
273
+ state.pendingResources.clear()
274
+ state.pendingVisibility.clear()
275
+ }
276
+
277
+ const applyPageError = (error: SettingRequestError): void => {
278
+ state.records = []
279
+ state.groups = []
280
+ state.forms = {}
281
+ state.etags = {}
282
+ state.collectionEtag = null
283
+ state.conflicts = {}
284
+ state.pendingResources.clear()
285
+ state.pendingVisibility.clear()
286
+ state.errors = { page: error }
287
+ }
288
+
289
+ const currentRecord = (key: string): SettingRecord => {
290
+ const record = state.records.find(candidate => resourceKey(candidate) === key)
291
+ if (record === undefined) throw new Error('SETTINGS_RECORD_UNKNOWN')
292
+ return record
293
+ }
294
+
295
+ const applyResourceValidationError = (key: string, message: string): void => {
296
+ state.errors[key] = validationError(message)
297
+ delete state.conflicts[key]
298
+ }
299
+
300
+ const applyRecord = (record: SettingRecord, preservedForm?: SettingFormState): void => {
301
+ const key = resourceKey(record)
302
+ const index = state.records.findIndex(candidate => resourceKey(candidate) === key)
303
+ if (index === -1) {
304
+ state.records = [...state.records, record]
305
+ } else {
306
+ state.records = state.records.map((candidate, candidateIndex) => candidateIndex === index ? record : candidate)
307
+ }
308
+ state.groups = groupSettingRecords(state.records)
309
+ state.forms[key] = preservedForm ?? initialFormState(record)
310
+ if (record.etag === null) delete state.etags[key]
311
+ else state.etags[key] = record.etag
312
+ delete state.errors[key]
313
+ delete state.conflicts[key]
314
+ state.pendingVisibility.delete(key)
315
+ }
316
+
317
+ const updatedRecord = (result: SettingsTransportResult): SettingRecord => {
318
+ assertSuccessful(result)
319
+ const responseEtag = strongEtag(result.headers.get('ETag'))
320
+ if (responseEtag === null) throw new Error('SETTINGS_RESPONSE_ETAG_INVALID')
321
+ return parseSettingResponse(result.body, responseEtag)
322
+ }
323
+
324
+ const load = async (): Promise<void> => {
325
+ if (!options.canRead()) throw new Error('SETTINGS_READ_FORBIDDEN')
326
+ const request = beginListRequest()
327
+ try {
328
+ let result: SettingsTransportResult
329
+ try {
330
+ result = await options.transport.list(request.controller.signal)
331
+ } catch (error) {
332
+ if (!isCurrentListRequest(request) || request.controller.signal.aborted || isAbortError(error)) return
333
+ applyPageError(transportError())
334
+ return
335
+ }
336
+ if (!isCurrentListRequest(request) || request.controller.signal.aborted) return
337
+ if (result.status < 200 || result.status >= 300) {
338
+ applyPageError(requestError(result))
339
+ return
340
+ }
341
+ try {
342
+ const collectionEtag = strongEtag(result.headers.get('ETag'))
343
+ if (collectionEtag === null) throw new Error('SETTINGS_RESPONSE_ETAG_INVALID')
344
+ applyRecords(parseSettingsList(result.body), collectionEtag)
345
+ } catch {
346
+ applyPageError(protocolError(result))
347
+ }
348
+ } finally {
349
+ finishListRequest(request)
350
+ }
351
+ }
352
+
353
+ const save = async (key: string): Promise<void> => {
354
+ const record = currentRecord(key)
355
+ if (!options.canManage()) {
356
+ applyResourceValidationError(key, 'You do not have permission to manage this setting.')
357
+ return
358
+ }
359
+ if (settingEditorKind(record) === 'unsupported') {
360
+ applyResourceValidationError(key, 'This setting type is read-only.')
361
+ return
362
+ }
363
+ const form = state.forms[key]
364
+ if (form === undefined) {
365
+ applyResourceValidationError(key, 'The setting form is unavailable. Reload settings and try again.')
366
+ return
367
+ }
368
+ if (!form.dirty) {
369
+ applyResourceValidationError(key, 'Change the setting value before saving.')
370
+ return
371
+ }
372
+ if (record.secret && (typeof form.value !== 'string' || form.value === '')) {
373
+ applyResourceValidationError(key, 'Enter a non-empty secret value before saving.')
374
+ return
375
+ }
376
+
377
+ const submittedValue = form.value
378
+ const submittedEditRevision = form.editRevision
379
+ const request = beginResourceRequest('save', key)
380
+ delete state.errors[key]
381
+ delete state.conflicts[key]
382
+ try {
383
+ const etag = state.etags[key]
384
+ let result: SettingsTransportResult
385
+ try {
386
+ result = await options.transport.replace(record.moduleKey, record.settingKey, {
387
+ value: submittedValue,
388
+ idempotencyKey: createIdempotencyKey(),
389
+ precondition: etag === undefined ? { kind: 'create' } : { kind: 'replace', etag },
390
+ signal: request.controller.signal,
391
+ })
392
+ } catch (error) {
393
+ if (!isCurrentResourceRequest(key, request) || request.controller.signal.aborted || isAbortError(error)) return
394
+ state.errors[key] = transportError()
395
+ return
396
+ }
397
+ if (!isCurrentResourceRequest(key, request) || request.controller.signal.aborted) return
398
+ if (result.status === 412) {
399
+ state.conflicts[key] = requestError(result)
400
+ return
401
+ }
402
+ if (result.status < 200 || result.status >= 300) {
403
+ state.errors[key] = requestError(result)
404
+ return
405
+ }
406
+ let parsedRecord: SettingRecord
407
+ try {
408
+ parsedRecord = updatedRecord(result)
409
+ } catch {
410
+ state.errors[key] = protocolError(result)
411
+ return
412
+ }
413
+ const currentForm = state.forms[key]
414
+ const preservedForm = currentForm !== undefined && currentForm.editRevision !== submittedEditRevision
415
+ ? { ...currentForm }
416
+ : undefined
417
+ applyRecord(parsedRecord, preservedForm)
418
+ } finally {
419
+ finishResourceRequest(key, request)
420
+ }
421
+ }
422
+
423
+ const unset = async (key: string): Promise<void> => {
424
+ const record = currentRecord(key)
425
+ if (!options.canManage()) {
426
+ applyResourceValidationError(key, 'You do not have permission to manage this setting.')
427
+ return
428
+ }
429
+ if (settingEditorKind(record) === 'unsupported') {
430
+ applyResourceValidationError(key, 'This setting type is read-only.')
431
+ return
432
+ }
433
+ const etag = state.etags[key]
434
+ if (etag === undefined) {
435
+ applyResourceValidationError(key, 'Reload this setting before unsetting it.')
436
+ return
437
+ }
438
+ const request = beginResourceRequest('unset', key)
439
+ delete state.errors[key]
440
+ delete state.conflicts[key]
441
+ try {
442
+ let result: SettingsTransportResult
443
+ try {
444
+ result = await options.transport.unset(record.moduleKey, record.settingKey, {
445
+ idempotencyKey: createIdempotencyKey(),
446
+ etag,
447
+ signal: request.controller.signal,
448
+ })
449
+ } catch (error) {
450
+ if (!isCurrentResourceRequest(key, request) || request.controller.signal.aborted || isAbortError(error)) return
451
+ state.errors[key] = transportError()
452
+ return
453
+ }
454
+ if (!isCurrentResourceRequest(key, request) || request.controller.signal.aborted) return
455
+ if (result.status === 412) {
456
+ state.conflicts[key] = requestError(result)
457
+ return
458
+ }
459
+ if (result.status < 200 || result.status >= 300) {
460
+ state.errors[key] = requestError(result)
461
+ return
462
+ }
463
+ try {
464
+ applyRecord(updatedRecord(result))
465
+ } catch {
466
+ state.errors[key] = protocolError(result)
467
+ }
468
+ } finally {
469
+ finishResourceRequest(key, request)
470
+ }
471
+ }
472
+
473
+ const runtime: SettingsRuntime = {
474
+ state,
475
+ canManage: options.canManage,
476
+ load,
477
+ async reload(key) {
478
+ currentRecord(key)
479
+ await load()
480
+ },
481
+ save,
482
+ unset,
483
+ updateForm(key, value) {
484
+ const form = state.forms[key]
485
+ if (form === undefined) throw new Error('SETTINGS_FORM_MISSING')
486
+ form.value = value
487
+ form.dirty = true
488
+ form.editRevision += 1
489
+ delete state.errors[key]
490
+ },
491
+ setSecretVisible(key, visible) {
492
+ currentRecord(key)
493
+ if (visible) state.pendingVisibility.add(key)
494
+ else state.pendingVisibility.delete(key)
495
+ },
496
+ isSecretVisible: key => state.pendingVisibility.has(key),
497
+ isPending: key => state.pendingResources.has(key),
498
+ dispose() {
499
+ generation += 1
500
+ listRequest = null
501
+ resourceRequests.clear()
502
+ for (const controller of controllers.values()) controller.abort()
503
+ controllers.clear()
504
+ state.records = []
505
+ state.groups = []
506
+ state.forms = {}
507
+ state.errors = {}
508
+ state.etags = {}
509
+ state.collectionEtag = null
510
+ state.conflicts = {}
511
+ state.pendingResources.clear()
512
+ state.pendingVisibility.clear()
513
+ state.requests.clear()
514
+ state.loading = false
515
+ },
516
+ }
517
+
518
+ return runtime
519
+ }
520
+
521
+ export const settingsRuntimeKey: InjectionKey<SettingsRuntime> = Symbol(SETTINGS_STORE_KEY)
522
+
523
+ export const useSettingsRuntime = (): SettingsRuntime => {
524
+ const runtime = inject(settingsRuntimeKey, null)
525
+ if (runtime === null) throw new Error('SETTINGS_RUNTIME_NOT_INSTALLED')
526
+ return runtime
527
+ }
528
+
529
+ export const createSettingsModuleContribution = (runtime: SettingsRuntime): AdminModuleContribution => defineAdminModule({
530
+ key: SETTINGS_MODULE_KEY,
531
+ routes: [{
532
+ name: SETTINGS_ROUTE_NAME,
533
+ path: SETTINGS_ROUTE_PATH,
534
+ component: () => import('./SettingsPage.vue'),
535
+ access: {
536
+ moduleKey: SETTINGS_MODULE_KEY,
537
+ permissionKeys: [SETTINGS_READ_PERMISSION],
538
+ },
539
+ }],
540
+ disposeOnTenantChange: true,
541
+ stores: [{
542
+ key: SETTINGS_STORE_KEY,
543
+ dispose: () => runtime.dispose(),
544
+ }],
545
+ })
@@ -0,0 +1,120 @@
1
+ <script setup lang="ts">
2
+ import { EmptyState, ForbiddenState, ModuleUnavailableState, PageContent, PageHeader, PageToolbar, SessionExpiredState } from '@peanut-admin/admin/shell'
3
+ import { ElButton } from 'element-plus'
4
+ import { computed, onMounted } from 'vue'
5
+ import { useTaskJobRuntime } from './runtime'
6
+
7
+ const runtime = useTaskJobRuntime()
8
+ const state = runtime.state
9
+ const canManage = computed(runtime.canManage)
10
+ const statuses = ['queued', 'running', 'succeeded', 'dead', 'cancelled'] as const
11
+
12
+ onMounted(runtime.load)
13
+ </script>
14
+
15
+ <template>
16
+ <PageContent class="task-job-page">
17
+ <PageHeader>
18
+ Tasks
19
+ <template #actions>
20
+ <ElButton
21
+ :loading="state.loading"
22
+ :disabled="state.mutating"
23
+ @click="runtime.load"
24
+ >
25
+ Reload
26
+ </ElButton>
27
+ </template>
28
+ </PageHeader>
29
+ <PageToolbar label="Task status">
30
+ <ElButton
31
+ v-for="status in statuses"
32
+ :key="status"
33
+ :type="state.status === status ? 'primary' : 'default'"
34
+ @click="runtime.setStatus(status)"
35
+ >
36
+ {{ status }}
37
+ </ElButton>
38
+ </PageToolbar>
39
+
40
+ <SessionExpiredState
41
+ v-if="state.error?.status === 401"
42
+ :message="state.error.message"
43
+ />
44
+ <ForbiddenState
45
+ v-else-if="state.error?.status === 403"
46
+ :message="state.error.message"
47
+ />
48
+ <ModuleUnavailableState
49
+ v-else-if="state.error?.status === 503"
50
+ :message="state.error.message"
51
+ @action="runtime.load"
52
+ />
53
+ <section
54
+ v-else-if="state.error"
55
+ role="alert"
56
+ class="task-state"
57
+ >
58
+ <h2>Unable to complete the task request</h2>
59
+ <p>{{ state.error.message }}</p>
60
+ <p v-if="state.error.requestId">
61
+ Request ID: {{ state.error.requestId }}
62
+ </p>
63
+ </section>
64
+ <div
65
+ v-else-if="state.loading"
66
+ class="task-state"
67
+ role="status"
68
+ >
69
+ Loading tasks...
70
+ </div>
71
+ <EmptyState
72
+ v-else-if="state.items.length === 0"
73
+ title="No tasks"
74
+ message="No tasks match this status."
75
+ />
76
+ <div
77
+ v-else
78
+ class="task-table-wrap"
79
+ >
80
+ <table class="task-table">
81
+ <thead><tr><th>Type</th><th>Status</th><th>Attempts</th><th>Error</th><th>Updated</th><th>Actions</th></tr></thead>
82
+ <tbody>
83
+ <tr
84
+ v-for="job in state.items"
85
+ :key="job.jobKey"
86
+ >
87
+ <td>{{ job.taskType }}</td><td>{{ job.status }}</td>
88
+ <td>{{ job.attemptCount }} / {{ job.maxAttempts }}</td>
89
+ <td>{{ job.lastErrorCode ?? '-' }}</td><td>{{ job.updatedAt }}</td>
90
+ <td>
91
+ <ElButton
92
+ v-if="job.status === 'queued'"
93
+ text
94
+ :disabled="!canManage || state.mutating"
95
+ @click="runtime.cancel(job)"
96
+ >
97
+ Cancel
98
+ </ElButton>
99
+ <ElButton
100
+ v-if="job.status === 'dead'"
101
+ text
102
+ :disabled="!canManage || state.mutating"
103
+ @click="runtime.retry(job)"
104
+ >
105
+ Retry
106
+ </ElButton>
107
+ </td>
108
+ </tr>
109
+ </tbody>
110
+ </table>
111
+ </div>
112
+ </PageContent>
113
+ </template>
114
+
115
+ <style scoped>
116
+ .task-state { padding: 24px 0; }
117
+ .task-table-wrap { overflow-x: auto; }
118
+ .task-table { width: 100%; border-collapse: collapse; }
119
+ .task-table th, .task-table td { padding: 10px 8px; border-bottom: 1px solid var(--el-border-color); text-align: left; }
120
+ </style>