@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.
- package/LICENSE +202 -0
- package/admin-core/src/access/access.ts +27 -0
- package/admin-core/src/api/client.ts +200 -0
- package/admin-core/src/api/problem.ts +67 -0
- package/admin-core/src/api/refresh.ts +122 -0
- package/admin-core/src/auth/stores.ts +121 -0
- package/admin-core/src/generated/api.d.ts +22106 -0
- package/admin-core/src/governance/audit.ts +56 -0
- package/admin-core/src/governance/catalog.ts +86 -0
- package/admin-core/src/governance/index.ts +26 -0
- package/admin-core/src/governance/menu.ts +63 -0
- package/admin-core/src/governance/roles.ts +144 -0
- package/admin-core/src/governance/types.ts +64 -0
- package/admin-core/src/index.ts +105 -0
- package/admin-core/src/lifecycle/tenant.ts +59 -0
- package/admin-core/src/module/contribution.ts +124 -0
- package/admin-core/src/runtime/config.ts +55 -0
- package/admin-core/src/runtime/errors.ts +81 -0
- package/admin-core/src/runtime/guard.ts +40 -0
- package/admin-core/src/runtime/navigation.ts +105 -0
- package/admin-core/src/runtime/overrides.ts +214 -0
- package/admin-core/src/targets/store.ts +153 -0
- package/admin-shell/src/config.ts +84 -0
- package/admin-shell/src/index.ts +40 -0
- package/admin-shell/src/layout.ts +332 -0
- package/admin-shell/src/overrides.ts +53 -0
- package/admin-shell/src/states.ts +93 -0
- package/admin-shell/src/targets.ts +128 -0
- package/admin-shell/src/theme.ts +15 -0
- package/client-core/src/index.ts +325 -0
- package/client-nuxt/src/index.ts +40 -0
- package/client-uniapp/src/index.ts +50 -0
- package/file-media/src/FileAssetSelector.vue +117 -0
- package/file-media/src/FileMediaPage.vue +158 -0
- package/file-media/src/contracts.ts +220 -0
- package/file-media/src/index.ts +19 -0
- package/file-media/src/runtime.ts +210 -0
- package/import-export/src/ImportExportPage.vue +155 -0
- package/import-export/src/contracts.ts +96 -0
- package/import-export/src/index.ts +3 -0
- package/import-export/src/runtime.ts +128 -0
- package/integration-security/src/IntegrationSecurityPage.vue +402 -0
- package/integration-security/src/contracts.ts +171 -0
- package/integration-security/src/index.ts +3 -0
- package/integration-security/src/runtime.ts +180 -0
- package/notification-sms/src/NotificationInboxPage.vue +266 -0
- package/notification-sms/src/contracts.ts +195 -0
- package/notification-sms/src/index.ts +4 -0
- package/notification-sms/src/runtime.ts +143 -0
- package/ops-console/src/OpsConsolePage.vue +337 -0
- package/ops-console/src/contracts.ts +169 -0
- package/ops-console/src/index.ts +3 -0
- package/ops-console/src/runtime.ts +199 -0
- package/package.json +108 -0
- package/reference-codes/src/ReferenceCodesPage.vue +942 -0
- package/reference-codes/src/contracts.ts +484 -0
- package/reference-codes/src/index.ts +53 -0
- package/reference-codes/src/runtime.ts +855 -0
- package/settings/src/SettingsPage.vue +536 -0
- package/settings/src/contracts.ts +331 -0
- package/settings/src/index.ts +45 -0
- package/settings/src/runtime.ts +545 -0
- package/task-job/src/TaskJobPage.vue +120 -0
- package/task-job/src/contracts.ts +117 -0
- package/task-job/src/index.ts +2 -0
- package/task-job/src/runtime.ts +105 -0
- 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>
|