@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,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
|
+
}
|