@peanut-admin/admin 0.1.0-alpha.11

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 (73) hide show
  1. package/LICENSE +202 -0
  2. package/admin-core/src/access/access.ts +27 -0
  3. package/admin-core/src/access/permission-policy.ts +37 -0
  4. package/admin-core/src/api/client.ts +200 -0
  5. package/admin-core/src/api/problem.ts +67 -0
  6. package/admin-core/src/api/refresh.ts +122 -0
  7. package/admin-core/src/auth/stores.ts +121 -0
  8. package/admin-core/src/auth/tenant-session.ts +32 -0
  9. package/admin-core/src/generated/api.d.ts +22106 -0
  10. package/admin-core/src/governance/audit.ts +56 -0
  11. package/admin-core/src/governance/catalog.ts +86 -0
  12. package/admin-core/src/governance/index.ts +26 -0
  13. package/admin-core/src/governance/menu.ts +63 -0
  14. package/admin-core/src/governance/roles.ts +144 -0
  15. package/admin-core/src/governance/types.ts +64 -0
  16. package/admin-core/src/index.ts +122 -0
  17. package/admin-core/src/lifecycle/tenant.ts +59 -0
  18. package/admin-core/src/module/contribution.ts +124 -0
  19. package/admin-core/src/module/plugin-contribution-policy.ts +51 -0
  20. package/admin-core/src/module/tenant-modules.ts +20 -0
  21. package/admin-core/src/runtime/config.ts +55 -0
  22. package/admin-core/src/runtime/errors.ts +81 -0
  23. package/admin-core/src/runtime/guard.ts +40 -0
  24. package/admin-core/src/runtime/navigation.ts +105 -0
  25. package/admin-core/src/runtime/overrides.ts +214 -0
  26. package/admin-core/src/targets/store.ts +153 -0
  27. package/admin-shell/src/config.ts +84 -0
  28. package/admin-shell/src/deployment-mode.ts +36 -0
  29. package/admin-shell/src/index.ts +44 -0
  30. package/admin-shell/src/layout.ts +332 -0
  31. package/admin-shell/src/overrides.ts +53 -0
  32. package/admin-shell/src/states.ts +93 -0
  33. package/admin-shell/src/tabs.ts +31 -0
  34. package/admin-shell/src/targets.ts +128 -0
  35. package/admin-shell/src/theme.ts +15 -0
  36. package/client-core/src/index.ts +415 -0
  37. package/client-nuxt/src/index.ts +48 -0
  38. package/client-uniapp/src/index.ts +50 -0
  39. package/file-media/src/FileAssetSelector.vue +117 -0
  40. package/file-media/src/FileMediaPage.vue +158 -0
  41. package/file-media/src/contracts.ts +220 -0
  42. package/file-media/src/index.ts +19 -0
  43. package/file-media/src/runtime.ts +210 -0
  44. package/import-export/src/ImportExportPage.vue +155 -0
  45. package/import-export/src/contracts.ts +96 -0
  46. package/import-export/src/index.ts +3 -0
  47. package/import-export/src/runtime.ts +128 -0
  48. package/integration-security/src/IntegrationSecurityPage.vue +402 -0
  49. package/integration-security/src/contracts.ts +171 -0
  50. package/integration-security/src/index.ts +3 -0
  51. package/integration-security/src/runtime.ts +180 -0
  52. package/notification-sms/src/NotificationInboxPage.vue +266 -0
  53. package/notification-sms/src/contracts.ts +195 -0
  54. package/notification-sms/src/index.ts +4 -0
  55. package/notification-sms/src/runtime.ts +143 -0
  56. package/ops-console/src/OpsConsolePage.vue +337 -0
  57. package/ops-console/src/contracts.ts +169 -0
  58. package/ops-console/src/index.ts +3 -0
  59. package/ops-console/src/runtime.ts +199 -0
  60. package/package.json +139 -0
  61. package/reference-codes/src/ReferenceCodesPage.vue +942 -0
  62. package/reference-codes/src/contracts.ts +484 -0
  63. package/reference-codes/src/index.ts +53 -0
  64. package/reference-codes/src/runtime.ts +855 -0
  65. package/settings/src/SettingsPage.vue +536 -0
  66. package/settings/src/contracts.ts +331 -0
  67. package/settings/src/index.ts +45 -0
  68. package/settings/src/runtime.ts +545 -0
  69. package/task-job/src/TaskJobPage.vue +120 -0
  70. package/task-job/src/contracts.ts +117 -0
  71. package/task-job/src/index.ts +2 -0
  72. package/task-job/src/runtime.ts +105 -0
  73. package/testing/src/index.ts +141 -0
@@ -0,0 +1,143 @@
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 { parseBulkResult, parseNotificationList, parseNotificationResponse } from './contracts'
7
+ import type {
8
+ NotificationBulkAction,
9
+ NotificationFilter,
10
+ NotificationMessage,
11
+ NotificationTransport,
12
+ NotificationTransportResult,
13
+ } from './contracts'
14
+
15
+ export const NOTIFICATION_SMS_MODULE_KEY = 'peanut.notification-sms' as const
16
+ export const NOTIFICATION_SMS_ROUTE_NAME = 'peanut.notification-sms.inbox' as const
17
+ export const NOTIFICATION_SMS_ROUTE_PATH = '/app/notifications' as const
18
+ export const NOTIFICATION_SMS_READ_PERMISSION = 'peanut.notification-sms.read' as const
19
+ export const NOTIFICATION_SMS_MANAGE_PERMISSION = 'peanut.notification-sms.manage' as const
20
+ export const NOTIFICATION_SMS_STORE_KEY = 'peanut.notification-sms.runtime' as const
21
+
22
+ export interface NotificationError { readonly message: string; readonly requestId: string | null; readonly status: number | null }
23
+ export interface NotificationState {
24
+ items: NotificationMessage[]
25
+ status: NotificationFilter
26
+ page: number
27
+ pageSize: number
28
+ total: number
29
+ selected: Set<string>
30
+ loading: boolean
31
+ mutating: boolean
32
+ error: NotificationError | null
33
+ }
34
+ export interface NotificationRuntime {
35
+ readonly state: NotificationState
36
+ load: () => Promise<void>
37
+ setStatus: (status: NotificationFilter) => Promise<void>
38
+ toggle: (messageKey: string) => void
39
+ markRead: (message: NotificationMessage) => Promise<void>
40
+ bulk: (action: NotificationBulkAction) => Promise<void>
41
+ dispose: () => void
42
+ }
43
+ export interface NotificationRuntimeOptions {
44
+ readonly transport: NotificationTransport
45
+ readonly canRead: () => boolean
46
+ }
47
+
48
+ const failure = (result: NotificationTransportResult): NotificationError => {
49
+ const body = typeof result.body === 'object' && result.body !== null && !Array.isArray(result.body)
50
+ ? result.body as Record<string, unknown> : {}
51
+ const requestId = body.request_id ?? result.headers.get('X-Request-Id')
52
+ return {
53
+ message: typeof body.detail === 'string' && body.detail !== '' ? body.detail : `Notification request failed (${result.status}).`,
54
+ requestId: typeof requestId === 'string' && requestId !== '' ? requestId : null,
55
+ status: result.status,
56
+ }
57
+ }
58
+
59
+ export const createNotificationRuntime = (options: NotificationRuntimeOptions): NotificationRuntime => {
60
+ const state = reactive<NotificationState>({
61
+ items: [], status: 'all', page: 1, pageSize: 20, total: 0, selected: new Set(),
62
+ loading: false, mutating: false, error: null,
63
+ })
64
+ const controllers = new Set<AbortController>()
65
+ let generation = 0
66
+ const run = async <T>(operation: (signal: AbortSignal) => Promise<T>): Promise<T> => {
67
+ const controller = new AbortController(); controllers.add(controller)
68
+ try { return await operation(controller.signal) } finally { controllers.delete(controller) }
69
+ }
70
+ const load = async (): Promise<void> => {
71
+ const current = ++generation; state.loading = true; state.error = null
72
+ try {
73
+ if (!options.canRead()) throw new Error('NOTIFICATION_PERMISSION_DENIED')
74
+ const result = await run(signal => options.transport.list(state.status, state.page, state.pageSize, signal))
75
+ if (current !== generation) return
76
+ if (result.status !== 200) { state.error = failure(result); return }
77
+ const list = parseNotificationList(result.body)
78
+ state.items = [...list.items]; state.page = list.page; state.pageSize = list.pageSize; state.total = list.total
79
+ state.selected.clear()
80
+ } catch {
81
+ if (current === generation) state.error = { message: 'The notification service could not be reached.', requestId: null, status: null }
82
+ } finally { if (current === generation) state.loading = false }
83
+ }
84
+ const mutate = async (operation: (signal: AbortSignal) => Promise<NotificationTransportResult>, parser: (body: unknown) => unknown): Promise<void> => {
85
+ if (state.mutating || !options.canRead()) return
86
+ const current = generation
87
+ state.mutating = true; state.error = null
88
+ try {
89
+ const result = await run(operation)
90
+ if (current !== generation) return
91
+ if (result.status !== 200) { state.error = failure(result); return }
92
+ parser(result.body)
93
+ if (current !== generation) return
94
+ state.mutating = false
95
+ await load()
96
+ } catch {
97
+ if (current === generation) state.error = { message: 'The notification action could not be completed.', requestId: null, status: null }
98
+ } finally { if (current === generation) state.mutating = false }
99
+ }
100
+ return {
101
+ state,
102
+ load,
103
+ async setStatus(status) { state.status = status; state.page = 1; await load() },
104
+ toggle(messageKey) {
105
+ if (state.selected.has(messageKey)) state.selected.delete(messageKey)
106
+ else if (state.selected.size < 100) state.selected.add(messageKey)
107
+ },
108
+ markRead(message) {
109
+ if (message.status !== 'unread') return Promise.resolve()
110
+ return mutate(signal => options.transport.markRead(message.messageKey, message.revision, signal), parseNotificationResponse)
111
+ },
112
+ bulk(action) {
113
+ const keys = [...state.selected]
114
+ if (keys.length === 0) return Promise.resolve()
115
+ return mutate(signal => options.transport.bulk(keys, action, signal), parseBulkResult)
116
+ },
117
+ dispose() {
118
+ generation += 1
119
+ for (const controller of controllers) controller.abort()
120
+ controllers.clear()
121
+ state.items = []; state.status = 'all'; state.page = 1; state.pageSize = 20; state.total = 0
122
+ state.selected.clear(); state.loading = false; state.mutating = false; state.error = null
123
+ },
124
+ }
125
+ }
126
+
127
+ export const notificationRuntimeKey: InjectionKey<NotificationRuntime> = Symbol(NOTIFICATION_SMS_STORE_KEY)
128
+ export const useNotificationRuntime = (): NotificationRuntime => {
129
+ const runtime = inject(notificationRuntimeKey)
130
+ if (runtime === undefined) throw new Error('NOTIFICATION_RUNTIME_MISSING')
131
+ return runtime
132
+ }
133
+ export const createNotificationModuleContribution = (runtime: NotificationRuntime): AdminModuleContribution => defineAdminModule({
134
+ key: NOTIFICATION_SMS_MODULE_KEY,
135
+ routes: [{
136
+ name: NOTIFICATION_SMS_ROUTE_NAME,
137
+ path: NOTIFICATION_SMS_ROUTE_PATH,
138
+ component: async () => ({ default: (await import('./NotificationInboxPage.vue')).default }),
139
+ access: { moduleKey: NOTIFICATION_SMS_MODULE_KEY, permissionKeys: [NOTIFICATION_SMS_READ_PERMISSION] },
140
+ }],
141
+ disposeOnTenantChange: true,
142
+ stores: [{ key: NOTIFICATION_SMS_STORE_KEY, dispose: runtime.dispose }],
143
+ })
@@ -0,0 +1,337 @@
1
+ <script setup lang="ts">
2
+ import { EmptyState, ForbiddenState, ModuleUnavailableState, PageContent, PageHeader, SessionExpiredState } from '@peanut-admin/admin/shell'
3
+ import { ElButton, ElDatePicker, ElInput, ElTabPane, ElTabs } from 'element-plus'
4
+ import { computed, onMounted, ref } from 'vue'
5
+ import { LOG_SEVERITIES } from './contracts'
6
+ import { useOpsConsoleRuntime } from './runtime'
7
+
8
+ const runtime = useOpsConsoleRuntime(); const state = runtime.state
9
+ const providerKey = ref(runtime.providers[0]?.key ?? '')
10
+ const backupReferenceKey = ref(''); const restoreTargetKey = ref('')
11
+ const reasonKey = ref(runtime.maintenanceReasons[0] ?? ''); const startsAt = ref(''); const endsAt = ref('')
12
+ const draftLogSource = ref(state.logSource); const draftLogSeverity = ref(state.logSeverity)
13
+ const targets = computed(() => runtime.providers.find(provider => provider.key === providerKey.value)?.restoreTargets ?? [])
14
+ const activeMaintenance = computed(() => state.maintenance !== null && state.maintenance.state !== 'closed')
15
+ const chooseProvider = (): void => { restoreTargetKey.value = targets.value[0] ?? '' }
16
+ const schedule = (): Promise<void> => runtime.scheduleMaintenance({ reasonKey: reasonKey.value, startsAt: startsAt.value, endsAt: endsAt.value })
17
+ const applyLogFilter = (): Promise<void> => runtime.setLogFilter(draftLogSource.value, draftLogSeverity.value)
18
+
19
+ onMounted(async () => {
20
+ chooseProvider(); await runtime.load()
21
+ if (runtime.canReadLogs() && runtime.logSources.length > 0) await runtime.loadLogs()
22
+ })
23
+ </script>
24
+
25
+ <template>
26
+ <PageContent class="ops-console-page">
27
+ <PageHeader>
28
+ Operations
29
+ <template #actions>
30
+ <ElButton
31
+ :loading="state.loading"
32
+ :disabled="state.mutating"
33
+ @click="runtime.load"
34
+ >
35
+ Reload
36
+ </ElButton>
37
+ </template>
38
+ </PageHeader>
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 && state.overview === null"
46
+ :message="state.error.message"
47
+ />
48
+ <ModuleUnavailableState
49
+ v-else-if="state.error?.status === 503 && state.overview === null"
50
+ :message="state.error.message"
51
+ @action="runtime.load"
52
+ />
53
+ <section
54
+ v-else-if="state.error && state.overview === null"
55
+ role="alert"
56
+ class="ops-state"
57
+ >
58
+ <h2>Unable to load operations</h2><p>{{ state.error.message }}</p><p v-if="state.error.requestId">
59
+ Request ID: {{ state.error.requestId }}
60
+ </p>
61
+ </section>
62
+ <div
63
+ v-else-if="state.loading && state.overview === null"
64
+ class="ops-state"
65
+ role="status"
66
+ >
67
+ Loading operations...
68
+ </div>
69
+
70
+ <ElTabs
71
+ v-else-if="state.overview !== null"
72
+ class="ops-tabs"
73
+ >
74
+ <ElTabPane label="Overview">
75
+ <section
76
+ class="ops-section"
77
+ aria-labelledby="ops-health-heading"
78
+ >
79
+ <h2 id="ops-health-heading">
80
+ Runtime evidence
81
+ </h2>
82
+ <dl class="ops-facts">
83
+ <div><dt>Health</dt><dd>{{ state.overview.health.status }}</dd></div>
84
+ <div>
85
+ <dt>Commit</dt><dd class="mono">
86
+ {{ state.overview.version.commit }}
87
+ </dd>
88
+ </div>
89
+ <div>
90
+ <dt>Tree</dt><dd class="mono">
91
+ {{ state.overview.version.tree }}
92
+ </dd>
93
+ </div>
94
+ <div><dt>Built</dt><dd>{{ state.overview.version.builtAt }}</dd></div>
95
+ <div><dt>Migrations</dt><dd>{{ state.overview.migrations.applied }} / {{ state.overview.migrations.target }}</dd></div>
96
+ <div><dt>Upgrade</dt><dd>{{ state.overview.upgrade.state }} ({{ state.overview.upgrade.code }})</dd></div>
97
+ </dl>
98
+ <div class="table-wrap">
99
+ <table>
100
+ <thead><tr><th>Check</th><th>Status</th><th>Critical</th><th>Latency</th></tr></thead>
101
+ <tbody>
102
+ <tr
103
+ v-for="check in state.overview.health.checks"
104
+ :key="check.key"
105
+ >
106
+ <td>{{ check.key }}</td><td>{{ check.status }}</td><td>{{ check.critical ? 'yes' : 'no' }}</td><td>{{ check.latencyMs }} ms</td>
107
+ </tr>
108
+ </tbody>
109
+ </table>
110
+ </div>
111
+ </section>
112
+ </ElTabPane>
113
+
114
+ <ElTabPane label="Recovery">
115
+ <section
116
+ class="ops-section"
117
+ aria-labelledby="ops-backup-heading"
118
+ >
119
+ <h2 id="ops-backup-heading">
120
+ Backup and restore verification
121
+ </h2>
122
+ <div class="ops-controls">
123
+ <label>Provider<select
124
+ v-model="providerKey"
125
+ :disabled="state.mutating"
126
+ @change="chooseProvider"
127
+ ><option
128
+ v-for="provider in runtime.providers"
129
+ :key="provider.key"
130
+ :value="provider.key"
131
+ >{{ provider.key }}</option></select></label>
132
+ <ElButton
133
+ type="primary"
134
+ :disabled="!runtime.canBackup() || state.mutating || !runtime.providers.find(provider => provider.key === providerKey)?.backup"
135
+ @click="runtime.submitBackup(providerKey)"
136
+ >
137
+ Create backup
138
+ </ElButton>
139
+ </div>
140
+ <div class="ops-controls">
141
+ <label>Backup reference<ElInput
142
+ v-model="backupReferenceKey"
143
+ :disabled="state.mutating"
144
+ /></label>
145
+ <label>New target<select
146
+ v-model="restoreTargetKey"
147
+ :disabled="state.mutating"
148
+ ><option
149
+ v-for="target in targets"
150
+ :key="target"
151
+ :value="target"
152
+ >{{ target }}</option></select></label>
153
+ <ElButton
154
+ :disabled="!runtime.canRestore() || state.mutating || restoreTargetKey === ''"
155
+ @click="runtime.submitRestore(providerKey, backupReferenceKey, restoreTargetKey)"
156
+ >
157
+ Restore and verify
158
+ </ElButton>
159
+ </div>
160
+ <section
161
+ v-if="state.error"
162
+ role="alert"
163
+ class="inline-error"
164
+ >
165
+ <p>{{ state.error.message }}</p><p v-if="state.error.requestId">
166
+ Request ID: {{ state.error.requestId }}
167
+ </p>
168
+ </section>
169
+ <EmptyState
170
+ v-if="state.tasks.length === 0"
171
+ title="No operation tasks"
172
+ message="No backup or restore-verification tasks were submitted in this session."
173
+ />
174
+ <div
175
+ v-else
176
+ class="table-wrap"
177
+ >
178
+ <table>
179
+ <thead><tr><th>Type</th><th>Status</th><th>Attempts</th><th>Updated</th><th>Action</th></tr></thead>
180
+ <tbody>
181
+ <tr
182
+ v-for="task in state.tasks"
183
+ :key="task.taskKey"
184
+ >
185
+ <td>{{ task.taskType }}</td><td>{{ task.status }}</td><td>{{ task.attemptCount }} / {{ task.maxAttempts }}</td><td>{{ task.updatedAt }}</td><td>
186
+ <ElButton
187
+ text
188
+ @click="runtime.refreshTask(task)"
189
+ >
190
+ Refresh
191
+ </ElButton>
192
+ </td>
193
+ </tr>
194
+ </tbody>
195
+ </table>
196
+ </div>
197
+ </section>
198
+ </ElTabPane>
199
+
200
+ <ElTabPane label="Maintenance">
201
+ <section
202
+ class="ops-section"
203
+ aria-labelledby="ops-maintenance-heading"
204
+ >
205
+ <h2 id="ops-maintenance-heading">
206
+ Maintenance window
207
+ </h2>
208
+ <dl
209
+ v-if="state.maintenance"
210
+ class="ops-facts"
211
+ >
212
+ <div><dt>State</dt><dd>{{ state.maintenance.state }}</dd></div><div><dt>Reason</dt><dd>{{ state.maintenance.reasonKey }}</dd></div><div><dt>Starts</dt><dd>{{ state.maintenance.startsAt }}</dd></div><div><dt>Ends</dt><dd>{{ state.maintenance.endsAt }}</dd></div><div><dt>Revision</dt><dd>{{ state.maintenance.revision }}</dd></div>
213
+ </dl>
214
+ <div class="ops-controls">
215
+ <label>Reason<select
216
+ v-model="reasonKey"
217
+ :disabled="state.mutating"
218
+ ><option
219
+ v-for="reason in runtime.maintenanceReasons"
220
+ :key="reason"
221
+ :value="reason"
222
+ >{{ reason }}</option></select></label>
223
+ <label>Starts<ElDatePicker
224
+ v-model="startsAt"
225
+ type="datetime"
226
+ value-format="YYYY-MM-DDTHH:mm:ss.SSS[Z]"
227
+ /></label>
228
+ <label>Ends<ElDatePicker
229
+ v-model="endsAt"
230
+ type="datetime"
231
+ value-format="YYYY-MM-DDTHH:mm:ss.SSS[Z]"
232
+ /></label>
233
+ <ElButton
234
+ type="primary"
235
+ :disabled="!runtime.canMaintain() || state.mutating || reasonKey === '' || startsAt === '' || endsAt === ''"
236
+ @click="schedule"
237
+ >
238
+ {{ state.maintenance === null ? 'Schedule' : 'Replace' }}
239
+ </ElButton>
240
+ <ElButton
241
+ :disabled="!runtime.canMaintain() || state.mutating || !activeMaintenance"
242
+ @click="runtime.closeMaintenance"
243
+ >
244
+ Close
245
+ </ElButton>
246
+ </div>
247
+ </section>
248
+ </ElTabPane>
249
+
250
+ <ElTabPane label="Runtime events">
251
+ <section
252
+ class="ops-section"
253
+ aria-labelledby="ops-logs-heading"
254
+ >
255
+ <h2 id="ops-logs-heading">
256
+ Structured runtime events
257
+ </h2>
258
+ <ForbiddenState
259
+ v-if="!runtime.canReadLogs()"
260
+ message="You do not have permission to read runtime events."
261
+ />
262
+ <template v-else>
263
+ <div class="ops-controls">
264
+ <label>Source<select
265
+ v-model="draftLogSource"
266
+ aria-label="Source"
267
+ ><option
268
+ v-for="source in runtime.logSources"
269
+ :key="source"
270
+ :value="source"
271
+ >{{ source }}</option></select></label><label>Severity<select
272
+ v-model="draftLogSeverity"
273
+ aria-label="Severity"
274
+ ><option
275
+ v-for="severity in LOG_SEVERITIES"
276
+ :key="severity"
277
+ :value="severity"
278
+ >{{ severity }}</option></select></label><ElButton
279
+ :loading="state.logsLoading"
280
+ @click="applyLogFilter"
281
+ >
282
+ Apply
283
+ </ElButton>
284
+ </div>
285
+ <section
286
+ v-if="state.logsError"
287
+ role="alert"
288
+ class="inline-error"
289
+ >
290
+ <p>{{ state.logsError.message }}</p><p v-if="state.logsError.requestId">
291
+ Request ID: {{ state.logsError.requestId }}
292
+ </p>
293
+ </section>
294
+ <EmptyState
295
+ v-else-if="state.logs.length === 0 && !state.logsLoading"
296
+ title="No runtime events"
297
+ message="No structured events match this filter."
298
+ />
299
+ <div
300
+ v-else
301
+ class="table-wrap"
302
+ >
303
+ <table>
304
+ <thead><tr><th>Time</th><th>Severity</th><th>Component</th><th>Event</th><th>Occurrences</th></tr></thead><tbody>
305
+ <tr
306
+ v-for="entry in state.logs"
307
+ :key="`${entry.eventKey}-${entry.occurredAt}`"
308
+ >
309
+ <td>{{ entry.occurredAt }}</td><td>{{ entry.severity }}</td><td>{{ entry.componentKey }}</td><td>{{ entry.message }}</td><td>{{ entry.occurrences }}</td>
310
+ </tr>
311
+ </tbody>
312
+ </table>
313
+ </div>
314
+ <ElButton
315
+ v-if="state.logNextCursor"
316
+ :loading="state.logsLoading"
317
+ @click="runtime.loadLogs(false)"
318
+ >
319
+ Load more
320
+ </ElButton>
321
+ </template>
322
+ </section>
323
+ </ElTabPane>
324
+ </ElTabs>
325
+ </PageContent>
326
+ </template>
327
+
328
+ <style scoped>
329
+ .ops-state, .ops-section { padding: 20px 0; }
330
+ .ops-section h2 { margin: 0 0 16px; font-size: 20px; letter-spacing: 0; }
331
+ .ops-facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px 24px; margin: 0 0 20px; }
332
+ .ops-facts div { min-width: 0; }.ops-facts dt { color: var(--el-text-color-secondary); font-size: 13px; }.ops-facts dd { margin: 4px 0 0; overflow-wrap: anywhere; }
333
+ .ops-controls { display: flex; align-items: end; flex-wrap: wrap; gap: 12px; margin-bottom: 20px; }.ops-controls label { display: grid; min-width: 180px; gap: 6px; font-size: 13px; color: var(--el-text-color-secondary); }.ops-controls select { height: 32px; padding: 0 28px 0 10px; border: 1px solid var(--el-border-color); border-radius: 4px; background: var(--el-bg-color); color: var(--el-text-color-primary); }
334
+ .inline-error { padding: 12px 0; color: var(--el-color-danger); }.inline-error p { margin: 0 0 4px; }
335
+ .table-wrap { width: 100%; overflow-x: auto; margin-bottom: 16px; }table { width: 100%; min-width: 680px; border-collapse: collapse; }th, td { padding: 10px 8px; border-bottom: 1px solid var(--el-border-color); text-align: left; }th { font-size: 13px; color: var(--el-text-color-secondary); }.mono { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; }
336
+ @media (max-width: 640px) { .ops-controls { align-items: stretch; flex-direction: column; }.ops-controls label { width: 100%; }.ops-controls :deep(.el-button) { width: 100%; } }
337
+ </style>
@@ -0,0 +1,169 @@
1
+ export type HealthStatus = 'healthy' | 'degraded' | 'unhealthy'
2
+ export type UpgradeState = 'configuration_required' | 'blocked' | 'ready' | 'running' | 'succeeded' | 'failed'
3
+ export type OpsTaskStatus = 'queued' | 'running' | 'succeeded' | 'dead' | 'cancelled'
4
+ export type MaintenanceState = 'scheduled' | 'active' | 'closed'
5
+ export const LOG_SEVERITIES = ['info', 'warning', 'error', 'critical'] as const
6
+ export type LogSeverity = typeof LOG_SEVERITIES[number]
7
+
8
+ export interface HealthCheck { readonly key: string; readonly status: 'up' | 'down'; readonly critical: boolean; readonly latencyMs: number }
9
+ export interface OpsStatus {
10
+ readonly health: { readonly status: HealthStatus; readonly checks: readonly HealthCheck[] }
11
+ readonly version: { readonly commit: string; readonly tree: string; readonly releaseKey: string | null; readonly builtAt: string }
12
+ readonly migrations: { readonly applied: number; readonly target: number; readonly pending: number; readonly inventoryDigest: string; readonly drift: boolean }
13
+ readonly upgrade: {
14
+ readonly state: UpgradeState; readonly code: string; readonly sourceCommit: string | null; readonly targetCommit: string | null
15
+ readonly repositoryClean: boolean; readonly backupVerified: boolean; readonly sourceEvidenceMatches: boolean
16
+ }
17
+ }
18
+ export interface OpsTask {
19
+ readonly taskKey: string; readonly taskType: 'ops.backup.create' | 'ops.restore.verify'; readonly status: OpsTaskStatus
20
+ readonly attemptCount: number; readonly maxAttempts: number; readonly revision: number; readonly lastErrorCode: string | null
21
+ readonly availableAt: string; readonly createdAt: string; readonly updatedAt: string; readonly completedAt: string | null
22
+ }
23
+ export interface MaintenanceWindow {
24
+ readonly maintenanceKey: string; readonly state: MaintenanceState; readonly reasonKey: string
25
+ readonly startsAt: string; readonly endsAt: string; readonly revision: number
26
+ }
27
+ export interface RuntimeLogEntry {
28
+ readonly eventKey: string; readonly severity: LogSeverity; readonly componentKey: string; readonly message: string
29
+ readonly occurredAt: string; readonly requestId: string | null; readonly occurrences: number
30
+ }
31
+ export interface RuntimeLogPage { readonly items: readonly RuntimeLogEntry[]; readonly nextCursor: string | null }
32
+ export interface OpsTransportResult { readonly body: unknown; readonly headers: Headers; readonly status: number }
33
+ export interface MaintenanceScheduleInput { readonly reasonKey: string; readonly startsAt: string; readonly endsAt: string }
34
+ export interface OpsConsoleTransport {
35
+ overview: (signal: AbortSignal) => Promise<OpsTransportResult>
36
+ submitBackup: (providerKey: string, idempotencyKey: string, signal: AbortSignal) => Promise<OpsTransportResult>
37
+ submitRestore: (providerKey: string, backupReferenceKey: string, targetKey: string, idempotencyKey: string, signal: AbortSignal) => Promise<OpsTransportResult>
38
+ task: (taskKey: string, signal: AbortSignal) => Promise<OpsTransportResult>
39
+ maintenance: (signal: AbortSignal) => Promise<OpsTransportResult>
40
+ scheduleMaintenance: (input: MaintenanceScheduleInput, expectedRevision: number, idempotencyKey: string, signal: AbortSignal) => Promise<OpsTransportResult>
41
+ closeMaintenance: (maintenanceKey: string, expectedRevision: number, idempotencyKey: string, signal: AbortSignal) => Promise<OpsTransportResult>
42
+ logs: (sourceKey: string, severity: LogSeverity, cursor: string | null, pageSize: number, signal: AbortSignal) => Promise<OpsTransportResult>
43
+ }
44
+
45
+ const invalid = (): never => { throw new Error('OPS_RESPONSE_INVALID') }
46
+ const record = (value: unknown): Record<string, unknown> => {
47
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return invalid()
48
+ return value as Record<string, unknown>
49
+ }
50
+ const exact = (value: Record<string, unknown>, keys: readonly string[]): void => {
51
+ const actual = Object.keys(value).sort(); const expected = [...keys].sort()
52
+ if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) invalid()
53
+ }
54
+ const integer = (value: unknown, minimum = 0): value is number => typeof value === 'number' && Number.isSafeInteger(value) && value >= minimum
55
+ const instant = (value: unknown): value is string => typeof value === 'string'
56
+ && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(value) && Number.isFinite(Date.parse(value))
57
+ const qualifiedKey = (value: unknown, maximum = 128): value is string => typeof value === 'string' && value.length <= maximum
58
+ && /^[a-z][a-z0-9]*(?:[.-][a-z0-9]+)*$/.test(value)
59
+ const stableCode = (value: unknown): value is string => typeof value === 'string' && /^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$/.test(value) && value.length <= 128
60
+ const commit = (value: unknown): value is string => typeof value === 'string' && /^[0-9a-f]{40}$/.test(value)
61
+ const opaque = (value: unknown, prefix: string): value is string => typeof value === 'string'
62
+ && new RegExp(`^${prefix}[0-9a-f]{32}$`).test(value)
63
+ const safePublicText = (value: unknown): value is string => typeof value === 'string' && value.length >= 1 && value.length <= 240
64
+ && !/[\u0000-\u001f\u007f]/.test(value)
65
+ && !/(?:password|passwd|secret|token|credential|authorization)\s*[:=]|(?:mysql|postgres(?:ql)?|redis):\/\/|\b(?:select|insert|update|delete|drop|alter)\s+|(?:^|\s)(?:\/[A-Za-z0-9._-]+){2,}|(?:^|\s)[A-Za-z]:\\|\bstack\s+trace\b|https?:\/\/[^\s/@]+:[^\s/@]+@/i.test(value)
66
+ const envelope = (value: unknown): { data: unknown; requestId: string } => {
67
+ const body = record(value); exact(body, ['data', 'meta'])
68
+ const meta = record(body.meta); exact(meta, ['request_id'])
69
+ if (typeof meta.request_id !== 'string' || meta.request_id.length < 1 || meta.request_id.length > 128) return invalid()
70
+ return { data: body.data, requestId: meta.request_id }
71
+ }
72
+
73
+ export const parseOpsStatus = (value: unknown): OpsStatus => {
74
+ const data = record(envelope(value).data); exact(data, ['health', 'version', 'migrations', 'upgrade'])
75
+ const health = record(data.health); exact(health, ['status', 'checks'])
76
+ const version = record(data.version); exact(version, ['commit', 'tree', 'release_key', 'built_at'])
77
+ const migrations = record(data.migrations); exact(migrations, ['applied', 'target', 'pending', 'inventory_digest', 'drift'])
78
+ const upgrade = record(data.upgrade); exact(upgrade, ['state', 'code', 'source_commit', 'target_commit', 'repository_clean', 'backup_verified', 'source_evidence_matches'])
79
+ if (!['healthy', 'degraded', 'unhealthy'].includes(String(health.status)) || !Array.isArray(health.checks) || health.checks.length > 32
80
+ || !commit(version.commit) || !commit(version.tree) || (version.release_key !== null && !qualifiedKey(version.release_key)) || !instant(version.built_at)
81
+ || !integer(migrations.applied) || !integer(migrations.target) || !integer(migrations.pending)
82
+ || migrations.applied + migrations.pending !== migrations.target || typeof migrations.inventory_digest !== 'string' || !/^[0-9a-f]{64}$/.test(migrations.inventory_digest) || typeof migrations.drift !== 'boolean'
83
+ || !['configuration_required', 'blocked', 'ready', 'running', 'succeeded', 'failed'].includes(String(upgrade.state)) || !stableCode(upgrade.code)
84
+ || (upgrade.source_commit !== null && !commit(upgrade.source_commit)) || (upgrade.target_commit !== null && !commit(upgrade.target_commit))
85
+ || typeof upgrade.repository_clean !== 'boolean' || typeof upgrade.backup_verified !== 'boolean' || typeof upgrade.source_evidence_matches !== 'boolean') return invalid()
86
+ const checks = health.checks.map(item => {
87
+ const check = record(item); exact(check, ['key', 'status', 'critical', 'latency_ms'])
88
+ if (!qualifiedKey(check.key, 64) || !['up', 'down'].includes(String(check.status)) || typeof check.critical !== 'boolean'
89
+ || typeof check.latency_ms !== 'number' || !Number.isFinite(check.latency_ms) || check.latency_ms < 0 || check.latency_ms > 60000) return invalid()
90
+ return { key: check.key, status: check.status as 'up' | 'down', critical: check.critical, latencyMs: check.latency_ms }
91
+ })
92
+ const criticalCheckDown = checks.some(check => check.critical && check.status === 'down')
93
+ if ((health.status === 'healthy' && (criticalCheckDown || migrations.drift || migrations.pending > 0))
94
+ || (upgrade.state === 'succeeded' && (!upgrade.repository_clean || !upgrade.backup_verified || !upgrade.source_evidence_matches))) return invalid()
95
+ return {
96
+ health: { status: health.status as HealthStatus, checks },
97
+ version: { commit: version.commit, tree: version.tree, releaseKey: version.release_key as string | null, builtAt: version.built_at },
98
+ migrations: { applied: migrations.applied, target: migrations.target, pending: migrations.pending, inventoryDigest: migrations.inventory_digest, drift: migrations.drift },
99
+ upgrade: { state: upgrade.state as UpgradeState, code: upgrade.code, sourceCommit: upgrade.source_commit as string | null, targetCommit: upgrade.target_commit as string | null, repositoryClean: upgrade.repository_clean, backupVerified: upgrade.backup_verified, sourceEvidenceMatches: upgrade.source_evidence_matches },
100
+ }
101
+ }
102
+
103
+ export const parseOpsTask = (value: unknown): OpsTask => {
104
+ const item = record(envelope(value).data)
105
+ exact(item, ['task_key', 'task_type', 'status', 'attempt_count', 'max_attempts', 'revision', 'last_error_code', 'available_at', 'created_at', 'updated_at', 'completed_at'])
106
+ const terminal = ['succeeded', 'dead', 'cancelled'].includes(String(item.status))
107
+ if (!opaque(item.task_key, 'job_') || !['ops.backup.create', 'ops.restore.verify'].includes(String(item.task_type))
108
+ || !['queued', 'running', 'succeeded', 'dead', 'cancelled'].includes(String(item.status))
109
+ || !integer(item.attempt_count) || !integer(item.max_attempts, 1) || item.max_attempts > 10 || item.attempt_count > item.max_attempts
110
+ || !integer(item.revision, 1) || (item.last_error_code !== null && !stableCode(item.last_error_code))
111
+ || !instant(item.available_at) || !instant(item.created_at) || !instant(item.updated_at)
112
+ || (item.completed_at !== null && !instant(item.completed_at)) || terminal !== (item.completed_at !== null)) return invalid()
113
+ return { taskKey: item.task_key, taskType: item.task_type as OpsTask['taskType'], status: item.status as OpsTaskStatus,
114
+ attemptCount: item.attempt_count, maxAttempts: item.max_attempts, revision: item.revision, lastErrorCode: item.last_error_code as string | null,
115
+ availableAt: item.available_at, createdAt: item.created_at, updatedAt: item.updated_at, completedAt: item.completed_at as string | null }
116
+ }
117
+
118
+ const parseMaintenanceData = (value: unknown): MaintenanceWindow | null => {
119
+ if (value === null) return null
120
+ const item = record(value); exact(item, ['maintenance_key', 'state', 'reason_key', 'starts_at', 'ends_at', 'revision'])
121
+ if (!opaque(item.maintenance_key, 'maintenance_') || !['scheduled', 'active', 'closed'].includes(String(item.state))
122
+ || !qualifiedKey(item.reason_key, 64) || !instant(item.starts_at) || !instant(item.ends_at)
123
+ || Date.parse(item.ends_at) <= Date.parse(item.starts_at) || Date.parse(item.ends_at) - Date.parse(item.starts_at) > 86_400_000
124
+ || !integer(item.revision, 1)) return invalid()
125
+ return { maintenanceKey: item.maintenance_key, state: item.state as MaintenanceState, reasonKey: item.reason_key,
126
+ startsAt: item.starts_at, endsAt: item.ends_at, revision: item.revision }
127
+ }
128
+ export const parseMaintenance = (value: unknown): MaintenanceWindow | null => parseMaintenanceData(envelope(value).data)
129
+
130
+ export const parseRuntimeLogs = (value: unknown): RuntimeLogPage => {
131
+ const data = record(envelope(value).data); exact(data, ['items', 'next_cursor'])
132
+ if (!Array.isArray(data.items) || data.items.length > 100 || (data.next_cursor !== null && (typeof data.next_cursor !== 'string' || !/^cursor_[A-Za-z0-9_-]{8,200}$/.test(data.next_cursor)))) return invalid()
133
+ const items = data.items.map(value => {
134
+ const item = record(value); exact(item, ['event_key', 'severity', 'component_key', 'message', 'occurred_at', 'request_id', 'occurrences'])
135
+ if (!qualifiedKey(item.event_key) || !LOG_SEVERITIES.includes(item.severity as LogSeverity)
136
+ || !qualifiedKey(item.component_key) || !safePublicText(item.message) || !instant(item.occurred_at)
137
+ || (item.request_id !== null && (typeof item.request_id !== 'string' || !/^[A-Za-z0-9._-]{1,128}$/.test(item.request_id)))
138
+ || !integer(item.occurrences, 1) || item.occurrences > 1_000_000) return invalid()
139
+ return { eventKey: item.event_key, severity: item.severity as LogSeverity, componentKey: item.component_key,
140
+ message: item.message, occurredAt: item.occurred_at, requestId: item.request_id as string | null, occurrences: item.occurrences }
141
+ })
142
+ return { items, nextCursor: data.next_cursor as string | null }
143
+ }
144
+
145
+ export const createOpsConsoleFetchTransport = (options: { readonly baseUrl: string; readonly fetch?: (request: Request) => Promise<Response> }): OpsConsoleTransport => {
146
+ const fetcher = options.fetch ?? fetch
147
+ const request = async (path: string, init: RequestInit): Promise<OpsTransportResult> => {
148
+ const headers = new Headers(init.headers); headers.set('Accept', 'application/json')
149
+ if (init.body !== undefined) headers.set('Content-Type', 'application/json')
150
+ const response = await fetcher(new Request(new URL(path, options.baseUrl), { credentials: 'include', ...init, headers }))
151
+ return { body: response.status === 204 ? null : await response.json(), headers: response.headers, status: response.status }
152
+ }
153
+ const writeHeaders = (idempotencyKey: string, revision?: number): HeadersInit => ({
154
+ 'Idempotency-Key': idempotencyKey, ...(revision === undefined ? {} : { 'If-Match': `"rev-${revision}"` }),
155
+ })
156
+ return {
157
+ overview: signal => request('/api/platform/v1/ops/status', { method: 'GET', signal }),
158
+ submitBackup: (providerKey, idempotencyKey, signal) => request('/api/platform/v1/ops/tasks/backup', { method: 'POST', headers: writeHeaders(idempotencyKey), body: JSON.stringify({ provider_key: providerKey }), signal }),
159
+ submitRestore: (providerKey, backupReferenceKey, targetKey, idempotencyKey, signal) => request('/api/platform/v1/ops/tasks/restore', { method: 'POST', headers: writeHeaders(idempotencyKey), body: JSON.stringify({ provider_key: providerKey, backup_reference_key: backupReferenceKey, target_key: targetKey }), signal }),
160
+ task: (taskKey, signal) => request(`/api/platform/v1/ops/tasks/${encodeURIComponent(taskKey)}`, { method: 'GET', signal }),
161
+ maintenance: signal => request('/api/platform/v1/ops/maintenance', { method: 'GET', signal }),
162
+ scheduleMaintenance: (input, expectedRevision, idempotencyKey, signal) => request('/api/platform/v1/ops/maintenance', { method: 'PUT', headers: writeHeaders(idempotencyKey, expectedRevision), body: JSON.stringify({ reason_key: input.reasonKey, starts_at: input.startsAt, ends_at: input.endsAt }), signal }),
163
+ closeMaintenance: (maintenanceKey, expectedRevision, idempotencyKey, signal) => request(`/api/platform/v1/ops/maintenance/${encodeURIComponent(maintenanceKey)}/close`, { method: 'POST', headers: writeHeaders(idempotencyKey, expectedRevision), body: '{}', signal }),
164
+ logs: (sourceKey, severity, cursor, pageSize, signal) => {
165
+ const query = new URLSearchParams({ source: sourceKey, severity, page_size: String(pageSize) }); if (cursor !== null) query.set('cursor', cursor)
166
+ return request(`/api/platform/v1/ops/logs?${query}`, { method: 'GET', signal })
167
+ },
168
+ }
169
+ }
@@ -0,0 +1,3 @@
1
+ export { default as OpsConsolePage } from './OpsConsolePage.vue'
2
+ export * from './contracts'
3
+ export * from './runtime'