feat(library-web): 恢复入口按真实条件启用并支持签发恢复授权
原来"恢复至该版本"写死 disabled,标题还写"生产恢复未接线"——而 POST /library/restores 与授权接口早已可用,等于把整库还原的唯一入口藏起来。 现在页面按 GET /library/restore-availability 启用:缺授权时管理员可签发 30 分钟 有效期的 restore 授权(固定期限,没有"永久授权"),条件满足才可点恢复;工程师只看到 条件与"仅管理员可发起"。封存不可发起时也不再写死"通道未接线",而是显示接口原因、 执行器身份与观测时间。
This commit is contained in:
@@ -149,6 +149,13 @@ const base: ViewProps = {
|
||||
pendingCapture: false,
|
||||
canCapture: true,
|
||||
canWrite: true,
|
||||
canRestore: false,
|
||||
restore: null,
|
||||
restoreNotice: null,
|
||||
pendingRestore: false,
|
||||
pendingGrant: false,
|
||||
onGrantRestore: vi.fn(),
|
||||
onRestore: vi.fn(),
|
||||
maintenance: null,
|
||||
now: new Date('2026-10-28T03:35:00Z').getTime(),
|
||||
onRefresh: vi.fn(),
|
||||
@@ -239,18 +246,62 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
|
||||
expect(html).toContain('列表只校验清单自身,字节级复核在同步/恢复执行前进行')
|
||||
})
|
||||
|
||||
it('版本与快照抽屉按 scope 列出全部历史并区分测试封存/不可直接恢复', () => {
|
||||
const html = render({ drawerOpen: true, expandedSnapshot: internalSnapshot.snapshot_id })
|
||||
it('版本与快照抽屉按 scope 列出全部历史,并把恢复条件交给接口判定', () => {
|
||||
const html = render({ drawerOpen: true, expandedSnapshot: internalSnapshot.snapshot_id, canRestore: true })
|
||||
expect(html).toContain('版本与快照')
|
||||
expect(html).toContain('封存 f992d64ea45b')
|
||||
expect(html).toContain('测试封存')
|
||||
expect(html).toContain('不可直接恢复')
|
||||
expect(html).toContain('恢复条件见下方')
|
||||
expect(html).toContain('恢复至该版本')
|
||||
expect(html).toMatch(/恢复至该版本[^<]*<\/button>/)
|
||||
expect(html).toContain('分项数量 / 字节 / 摘要(2 项)')
|
||||
expect(html).toContain('不支持只选择 Wiki 或某张数据表恢复')
|
||||
})
|
||||
|
||||
it('管理员在缺授权时看到签发入口,恢复按钮按接口给出的原因禁用', () => {
|
||||
const html = render({
|
||||
drawerOpen: true,
|
||||
expandedSnapshot: internalSnapshot.snapshot_id,
|
||||
canRestore: true,
|
||||
restore: {
|
||||
allowed: false,
|
||||
reasons: ['没有针对该快照的恢复授权:先签发 restore 授权(绑定同一快照)'],
|
||||
scope: 'internal',
|
||||
snapshot_id: internalSnapshot.snapshot_id,
|
||||
authorization: { state: 'none' },
|
||||
},
|
||||
})
|
||||
expect(html).toContain('签发恢复授权(30 分钟)')
|
||||
expect(html).toContain('没有针对该快照的恢复授权')
|
||||
expect(html).toMatch(/rel-danger[^>]*disabled/)
|
||||
// 不能再说"未接线":条件不满足要给出具体原因
|
||||
expect(html).not.toContain('生产恢复未接线')
|
||||
})
|
||||
|
||||
it('条件全满足时恢复按钮可用,并写明授权有效期', () => {
|
||||
const html = render({
|
||||
drawerOpen: true,
|
||||
expandedSnapshot: internalSnapshot.snapshot_id,
|
||||
canRestore: true,
|
||||
restore: {
|
||||
allowed: true,
|
||||
reasons: [],
|
||||
scope: 'internal',
|
||||
snapshot_id: internalSnapshot.snapshot_id,
|
||||
authorization: { state: 'active', authorized_by: 'yaozhanghao', expires_at: '2026-10-28T04:10:00Z' },
|
||||
},
|
||||
})
|
||||
expect(html).toContain('条件已满足')
|
||||
expect(html).toContain('有效至')
|
||||
expect(html).not.toMatch(/rel-danger[^>]*disabled/)
|
||||
})
|
||||
|
||||
it('工程师看不到恢复入口,只看到权限说明', () => {
|
||||
const html = render({ drawerOpen: true, expandedSnapshot: internalSnapshot.snapshot_id, canRestore: false })
|
||||
expect(html).toContain('按版本恢复仅管理员可发起')
|
||||
expect(html).not.toContain('签发恢复授权')
|
||||
})
|
||||
|
||||
it('抽屉只展示当前选中库的历史', () => {
|
||||
const html = render({ drawerOpen: true, scope: 'external' })
|
||||
expect(html).toContain('封存 01343b0b310b')
|
||||
@@ -277,14 +328,30 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
|
||||
expect(html).toContain('主机侧正在暂停写入者、导出并恢复')
|
||||
})
|
||||
|
||||
it('无权限时不给封存入口,未接线时说明通道状态', () => {
|
||||
it('无权限时不给封存入口;不可发起时给出接口返回的真实原因与执行器身份', () => {
|
||||
expect(render({ canCapture: false, canWrite: false })).toContain('封存需要知识工程师及以上权限')
|
||||
const unwired = render({
|
||||
const unavailable = render({
|
||||
canCapture: false,
|
||||
canWrite: true,
|
||||
page: { ...page, capture_available: false, capture_reason: '主机侧封存 agent 未接线' },
|
||||
page: { ...page, capture_available: false },
|
||||
overview: {
|
||||
...overview,
|
||||
capture_available: false,
|
||||
capture_reason: '执行器指向的实例不匹配:agent="weknora071full",期望="kgmaint-weknora"',
|
||||
executor: {
|
||||
available: false,
|
||||
busy: false,
|
||||
project: 'weknora071full',
|
||||
source_app: 'WKNFull-app',
|
||||
observed_at: '2026-10-28T03:34:00Z',
|
||||
},
|
||||
},
|
||||
})
|
||||
expect(unwired).toContain('封存通道未接线')
|
||||
expect(unavailable).toContain('执行器指向的实例不匹配')
|
||||
expect(unavailable).toContain('执行器 weknora071full / WKNFull-app')
|
||||
expect(unavailable).toContain('观测于')
|
||||
// 通道可用/不可用都不许用写死的"未接线"糊过去
|
||||
expect(unavailable).not.toContain('封存通道未接线')
|
||||
})
|
||||
|
||||
it('无封存时给出“尚无封存”而不是空表', () => {
|
||||
|
||||
+163
-7
@@ -1,10 +1,13 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
import {
|
||||
createLibraryRestoreAuthorization,
|
||||
enqueueLibraryCapture,
|
||||
enqueueLibraryRecovery,
|
||||
enqueueLibraryRestore,
|
||||
fetchLibraryCaptures,
|
||||
fetchLibraryMaintenance,
|
||||
fetchLibraryOverview,
|
||||
fetchLibraryRestoreAvailability,
|
||||
fetchLibrarySnapshots,
|
||||
formatBytes,
|
||||
scopeLabel,
|
||||
@@ -12,6 +15,7 @@ import {
|
||||
type LibraryJobRecord,
|
||||
type LibraryMaintenanceView,
|
||||
type LibraryOverview,
|
||||
type LibraryRestoreAvailability,
|
||||
type LibrarySnapshot,
|
||||
type LibrarySnapshotList,
|
||||
type LibraryStageRow,
|
||||
@@ -85,6 +89,14 @@ interface ViewProps {
|
||||
pendingCapture: boolean
|
||||
canCapture: boolean
|
||||
canWrite: boolean
|
||||
// 按版本恢复是管理员动作:工程师看得到条件,但拿不到恢复入口。
|
||||
canRestore: boolean
|
||||
restore: LibraryRestoreAvailability | null
|
||||
restoreNotice: string | null
|
||||
pendingRestore: boolean
|
||||
pendingGrant: boolean
|
||||
onGrantRestore: () => void
|
||||
onRestore: () => void
|
||||
overview: LibraryOverview | null
|
||||
maintenance: LibraryMaintenanceView | null
|
||||
now: number
|
||||
@@ -118,6 +130,20 @@ function scopeView(
|
||||
|
||||
const scopeName = (scope: Scope) => (scope === 'internal' ? '对内知识库' : '对外知识库')
|
||||
|
||||
// 不可发起时必须说清是哪一条不满足:读的是接口给出的真实原因(执行器不可达、
|
||||
// 指向别的实例、降级、观测过期),不是一句写死的"通道未接线"。
|
||||
function captureHint(view: ScopeView, canWrite: boolean): string {
|
||||
if (!canWrite) return '封存需要知识工程师及以上权限'
|
||||
const executor = view.overview?.executor
|
||||
const reason = view.overview?.capture_reason || executor?.reason
|
||||
const observed = executor?.observed_at ? `(观测于 ${stamp(executor.observed_at)})` : ''
|
||||
const identity = executor?.project
|
||||
? `执行器 ${executor.project}${executor.source_app ? ` / ${executor.source_app}` : ''} · `
|
||||
: ''
|
||||
if (reason) return `${identity}封存通道不可用:${reason}${observed}`
|
||||
return '封存通道状态未知:还没有读到执行器观测'
|
||||
}
|
||||
|
||||
function statusOf(view: ScopeView) {
|
||||
const job = view.job
|
||||
if (job && (job.state === 'running' || job.state === 'pending')) {
|
||||
@@ -629,7 +655,7 @@ function ReadyBody({
|
||||
{pendingCapture ? '投递中…' : '封存测试版本'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="rel-hint">{canWrite ? '封存通道未接线' : '封存需要知识工程师及以上权限'}</span>
|
||||
<span className="rel-hint">{captureHint(view, canWrite)}</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
@@ -822,8 +848,16 @@ interface DrawerProps {
|
||||
onExpand: (id: string | null) => void
|
||||
onClose: () => void
|
||||
canCapture: boolean
|
||||
canRestore: boolean
|
||||
pendingCapture: boolean
|
||||
onCapture: () => void
|
||||
// 恢复条件来自容器(真实接口判定):双闸 / 该快照的授权 / 目标是否在维护。
|
||||
restore: LibraryRestoreAvailability | null
|
||||
restoreNotice: string | null
|
||||
pendingRestore: boolean
|
||||
pendingGrant: boolean
|
||||
onGrantRestore: () => void
|
||||
onRestore: () => void
|
||||
}
|
||||
|
||||
function Drawer({
|
||||
@@ -835,8 +869,15 @@ function Drawer({
|
||||
onExpand,
|
||||
onClose,
|
||||
canCapture,
|
||||
canRestore,
|
||||
pendingCapture,
|
||||
onCapture,
|
||||
restore,
|
||||
restoreNotice,
|
||||
pendingRestore,
|
||||
pendingGrant,
|
||||
onGrantRestore,
|
||||
onRestore,
|
||||
}: DrawerProps) {
|
||||
if (!open) return null
|
||||
const listing = view.history
|
||||
@@ -919,8 +960,12 @@ function Drawer({
|
||||
>
|
||||
{isSelected ? '收起快照' : '查看快照'}
|
||||
</button>
|
||||
<button type="button" className="rel-link" disabled title="生产恢复未接线,也没有恢复演练记录">
|
||||
不可直接恢复
|
||||
<button
|
||||
type="button"
|
||||
className="rel-link"
|
||||
onClick={() => onExpand(isSelected ? null : snapshot.snapshot_id)}
|
||||
>
|
||||
{isSelected ? '恢复条件见下方' : '查看恢复条件'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -991,13 +1036,48 @@ function Drawer({
|
||||
以上是同一恢复点的配套内容,不支持只选择 Wiki 或某张数据表恢复。恢复速度需实测;“有备份文件”不等于可恢复。
|
||||
</p>
|
||||
<div className="rel-drawer-actions">
|
||||
<button type="button" disabled title="生产恢复验证记录未接入">
|
||||
<button type="button" disabled title="逐项校验证据在同步/恢复执行时产生,作业详情里可见">
|
||||
查看校验记录
|
||||
</button>
|
||||
<button type="button" className="rel-danger" disabled title="生产恢复未接线,也没有经过恢复演练">
|
||||
恢复至该版本
|
||||
</button>
|
||||
{canRestore && restore && restore.authorization.state !== 'active' ? (
|
||||
<button type="button" className="rel-secondary" onClick={onGrantRestore} disabled={pendingGrant}>
|
||||
{pendingGrant ? '签发中…' : '签发恢复授权(30 分钟)'}
|
||||
</button>
|
||||
) : null}
|
||||
{canRestore ? (
|
||||
<button
|
||||
type="button"
|
||||
className="rel-danger"
|
||||
onClick={onRestore}
|
||||
disabled={pendingRestore || restore?.allowed !== true}
|
||||
title={
|
||||
restore?.allowed
|
||||
? '把整套生产知识状态恢复到这个快照'
|
||||
: (restore?.reasons ?? []).join(';') || '恢复条件尚未读取到'
|
||||
}
|
||||
>
|
||||
{pendingRestore ? '投递中…' : '恢复至该版本'}
|
||||
</button>
|
||||
) : (
|
||||
<span className="rel-hint">按版本恢复仅管理员可发起</span>
|
||||
)}
|
||||
</div>
|
||||
{restore && !restore.allowed ? (
|
||||
<ul className="rel-gate" aria-label="恢复条件">
|
||||
{restore.reasons.map((reason) => (
|
||||
<li key={reason}>{reason}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : null}
|
||||
{restore?.allowed ? (
|
||||
<p className="rel-hint">
|
||||
条件已满足:生产写入闸口打开、这份快照有恢复授权
|
||||
{restore.authorization.expires_at ? `(有效至 ${stamp(restore.authorization.expires_at)})` : ''}
|
||||
、目标实例未在维护。
|
||||
</p>
|
||||
) : null}
|
||||
{restore === null ? <p className="rel-hint">正在读取恢复条件…</p> : null}
|
||||
{restoreNotice ? <p className="rel-notice">{restoreNotice}</p> : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -1047,6 +1127,13 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
pendingCapture,
|
||||
canCapture,
|
||||
canWrite,
|
||||
canRestore,
|
||||
restore,
|
||||
restoreNotice,
|
||||
pendingRestore,
|
||||
pendingGrant,
|
||||
onGrantRestore,
|
||||
onRestore,
|
||||
now,
|
||||
maintenance,
|
||||
onRefresh,
|
||||
@@ -1241,8 +1328,15 @@ export function ReleaseTargetsView(props: ViewProps) {
|
||||
onExpand={onExpandSnapshot}
|
||||
onClose={() => onDrawer(false)}
|
||||
canCapture={canCapture}
|
||||
canRestore={canRestore}
|
||||
pendingCapture={pendingCapture}
|
||||
onCapture={onCapture}
|
||||
restore={restore}
|
||||
restoreNotice={restoreNotice}
|
||||
pendingRestore={pendingRestore}
|
||||
pendingGrant={pendingGrant}
|
||||
onGrantRestore={onGrantRestore}
|
||||
onRestore={onRestore}
|
||||
/>
|
||||
</section>
|
||||
)
|
||||
@@ -1313,8 +1407,63 @@ export function ReleaseTargets() {
|
||||
|
||||
const role = auth?.identity?.role
|
||||
const canWrite = role === 'engineer' || role === 'admin'
|
||||
// 恢复写的是整套生产知识状态,接口只认管理员;页面必须与它一致。
|
||||
const canRestore = role === 'admin'
|
||||
const canCapture = canWrite && page?.capture_available === true
|
||||
|
||||
// 恢复条件随"展开的那份快照"变化:闸口、该快照的授权、实例是否在维护都由接口判定。
|
||||
const [restore, setRestore] = useState<LibraryRestoreAvailability | null>(null)
|
||||
const [restoreNotice, setRestoreNotice] = useState<string | null>(null)
|
||||
const [pendingRestore, setPendingRestore] = useState(false)
|
||||
const [pendingGrant, setPendingGrant] = useState(false)
|
||||
const [restoreRound, setRestoreRound] = useState(0)
|
||||
const restoreSnapshot = expandedSnapshot ?? ''
|
||||
useEffect(() => {
|
||||
if (!drawerOpen || !restoreSnapshot) {
|
||||
setRestore(null)
|
||||
return
|
||||
}
|
||||
const controller = new AbortController()
|
||||
void fetchLibraryRestoreAvailability(scope, restoreSnapshot, controller.signal)
|
||||
.then((value) => setRestore(value))
|
||||
.catch(() => setRestore(null))
|
||||
return () => controller.abort()
|
||||
}, [drawerOpen, restoreSnapshot, scope, restoreRound])
|
||||
|
||||
const grantRestore = async () => {
|
||||
if (!restoreSnapshot) return
|
||||
setPendingGrant(true)
|
||||
setRestoreNotice(null)
|
||||
try {
|
||||
const grant = await createLibraryRestoreAuthorization(scope, restoreSnapshot)
|
||||
setRestoreNotice(
|
||||
`已签发恢复授权${grant.authorized_by ? `(${grant.authorized_by})` : ''},有效至 ${stamp(grant.expires_at)}`
|
||||
)
|
||||
setRestoreRound((round) => round + 1)
|
||||
await load()
|
||||
} catch (grantError) {
|
||||
setRestoreNotice(grantError instanceof Error ? grantError.message : '签发恢复授权失败')
|
||||
} finally {
|
||||
setPendingGrant(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startRestore = async () => {
|
||||
if (!restoreSnapshot) return
|
||||
setPendingRestore(true)
|
||||
setRestoreNotice(null)
|
||||
try {
|
||||
const accepted = await enqueueLibraryRestore(scope, restoreSnapshot)
|
||||
setRestoreNotice(`已投递恢复:${accepted.job_id}(整套生产知识状态回到该快照)`)
|
||||
setRestoreRound((round) => round + 1)
|
||||
await load()
|
||||
} catch (restoreError) {
|
||||
setRestoreNotice(restoreError instanceof Error ? restoreError.message : '发起恢复失败')
|
||||
} finally {
|
||||
setPendingRestore(false)
|
||||
}
|
||||
}
|
||||
|
||||
const startCapture = async () => {
|
||||
setPendingCapture(true)
|
||||
setNotice(null)
|
||||
@@ -1364,6 +1513,13 @@ export function ReleaseTargets() {
|
||||
pendingCapture={pendingCapture}
|
||||
canCapture={canCapture}
|
||||
canWrite={canWrite}
|
||||
canRestore={canRestore}
|
||||
restore={restore}
|
||||
restoreNotice={restoreNotice}
|
||||
pendingRestore={pendingRestore}
|
||||
pendingGrant={pendingGrant}
|
||||
onGrantRestore={() => void grantRestore()}
|
||||
onRestore={() => void startRestore()}
|
||||
maintenance={maintenances[scope]}
|
||||
now={now}
|
||||
onRefresh={() => void load()}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
createLibraryRestoreAuthorization,
|
||||
enqueueLibraryRestore,
|
||||
fetchLibraryRestoreAvailability,
|
||||
} from './librarySnapshots'
|
||||
|
||||
const jsonResponse = (body: unknown, status = 200) =>
|
||||
new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe('整库恢复的前端契约', () => {
|
||||
it('读取恢复条件时把原因与授权状态原样带回来', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
allowed: false,
|
||||
reasons: ['没有针对该快照的恢复授权:先签发 restore 授权(绑定同一快照)'],
|
||||
scope: 'internal',
|
||||
snapshot_id: 'snap-1',
|
||||
authorization: { state: 'none', reason: '没有与目标快照匹配的有效发布授权' },
|
||||
})
|
||||
)
|
||||
)
|
||||
const availability = await fetchLibraryRestoreAvailability('internal', 'snap-1')
|
||||
expect(availability?.allowed).toBe(false)
|
||||
expect(availability?.reasons).toHaveLength(1)
|
||||
expect(availability?.authorization.state).toBe('none')
|
||||
const requested = (vi.mocked(fetch).mock.calls[0]?.[0] as string) ?? ''
|
||||
expect(requested).toContain('scope=internal')
|
||||
expect(requested).toContain('snapshot_id=snap-1')
|
||||
})
|
||||
|
||||
it('签发恢复授权必须声明 action=restore 与有限有效期', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
jsonResponse(
|
||||
{
|
||||
authorization: {
|
||||
id: 'libauth-restore-1',
|
||||
authorized_by: 'yaozhanghao',
|
||||
expires_at: '2026-10-29T09:00:00Z',
|
||||
},
|
||||
},
|
||||
201
|
||||
)
|
||||
)
|
||||
)
|
||||
const grant = await createLibraryRestoreAuthorization('internal', 'snap-1')
|
||||
expect(grant.id).toBe('libauth-restore-1')
|
||||
const init = vi.mocked(fetch).mock.calls[0]?.[1] as RequestInit
|
||||
const body = JSON.parse(String(init.body)) as Record<string, unknown>
|
||||
expect(body.action).toBe('restore')
|
||||
expect(body.expires_in_minutes).toBe(30)
|
||||
})
|
||||
|
||||
it('恢复被拒时把逐条原因说给用户,而不是只说 HTTP 状态', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ code: 'library_restore_blocked', reasons: ['生产写入闸口关闭', '没有该快照的恢复授权'] }, 503)
|
||||
)
|
||||
)
|
||||
await expect(enqueueLibraryRestore('internal', 'snap-1')).rejects.toThrow('生产写入闸口关闭;没有该快照的恢复授权')
|
||||
})
|
||||
|
||||
it('投递成功时返回作业号,供页面显示真实进度', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValue(
|
||||
jsonResponse({ job: { id: 'librestore-internal-1', scope: 'internal' }, reused: false }, 202)
|
||||
)
|
||||
)
|
||||
const accepted = await enqueueLibraryRestore('internal', 'snap-1')
|
||||
expect(accepted.job_id).toBe('librestore-internal-1')
|
||||
expect(accepted.reused).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -185,6 +185,101 @@ export async function enqueueLibraryCapture(scope: string): Promise<LibraryCaptu
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复能不能点,由真实条件决定:双闸、这份快照的恢复授权、目标实例是否在维护。
|
||||
export interface LibraryRestoreAvailability {
|
||||
allowed: boolean
|
||||
reasons: string[]
|
||||
scope: string
|
||||
snapshot_id: string
|
||||
authorization: LibraryAuthorizationView
|
||||
}
|
||||
|
||||
export async function fetchLibraryRestoreAvailability(
|
||||
scope: string,
|
||||
snapshotID: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<LibraryRestoreAvailability | null> {
|
||||
const query = `scope=${encodeURIComponent(scope)}&snapshot_id=${encodeURIComponent(snapshotID)}`
|
||||
const response = await fetch(`/api/v1/library/restore-availability?${query}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
signal,
|
||||
})
|
||||
const value = (await response.json().catch(() => null)) as unknown
|
||||
if (!record(value)) return null
|
||||
return {
|
||||
allowed: bool(value.allowed),
|
||||
reasons: Array.isArray(value.reasons) ? value.reasons.map(text).filter(Boolean) : [],
|
||||
scope: text(value.scope),
|
||||
snapshot_id: text(value.snapshot_id),
|
||||
authorization: record(value.authorization)
|
||||
? {
|
||||
state: text(value.authorization.state) === 'active' ? 'active' : 'none',
|
||||
authorized_by: text(value.authorization.authorized_by) || undefined,
|
||||
expires_at: text(value.authorization.expires_at) || undefined,
|
||||
reason: text(value.authorization.reason) || undefined,
|
||||
}
|
||||
: { state: 'none' },
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复整套生产知识状态:同一份快照的文档、分块、索引、资源与 Wiki 一起回放。
|
||||
export interface LibraryRestoreAccepted {
|
||||
job_id: string
|
||||
scope: string
|
||||
reused: boolean
|
||||
}
|
||||
|
||||
export async function enqueueLibraryRestore(scope: string, snapshotID: string): Promise<LibraryRestoreAccepted> {
|
||||
const response = await fetch('/api/v1/library/restores', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ scope, snapshot_id: snapshotID }),
|
||||
})
|
||||
const value = (await response.json().catch(() => null)) as unknown
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) throw new Error('按版本恢复仅管理员可发起。')
|
||||
if (response.status === 503 && record(value)) {
|
||||
const reasons = Array.isArray(value.reasons) ? value.reasons.map(text).filter(Boolean) : []
|
||||
throw new Error(reasons.length > 0 ? reasons.join(';') : text(value.error) || '恢复条件不满足')
|
||||
}
|
||||
if (response.status === 409 && record(value)) throw new Error(text(value.error) || '该目标已有在飞作业')
|
||||
throw new Error(`发起恢复失败(HTTP ${response.status})`)
|
||||
}
|
||||
if (!record(value) || !record(value.job)) throw new Error('恢复作业响应无效')
|
||||
return { job_id: text(value.job.id), scope: text(value.job.scope), reused: bool(value.reused) }
|
||||
}
|
||||
|
||||
export interface LibraryRestoreGrant {
|
||||
id: string
|
||||
authorized_by: string
|
||||
expires_at: string
|
||||
}
|
||||
|
||||
// 恢复授权与执行是两件事:授权把"目标 + 快照 + 参数"钉死并给很短的有效期,
|
||||
// 同步授权不能顶替恢复授权(摘要不同)。
|
||||
export async function createLibraryRestoreAuthorization(
|
||||
scope: string,
|
||||
snapshotID: string,
|
||||
minutes = 30
|
||||
): Promise<LibraryRestoreGrant> {
|
||||
const response = await fetch('/api/v1/library/authorizations', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ scope, snapshot_id: snapshotID, action: 'restore', expires_in_minutes: minutes }),
|
||||
})
|
||||
const value = (await response.json().catch(() => null)) as unknown
|
||||
if (!response.ok) {
|
||||
if (response.status === 403) throw new Error('生产发布授权仅管理员可签发。')
|
||||
throw new Error(`签发恢复授权失败(HTTP ${response.status})`)
|
||||
}
|
||||
if (!record(value) || !record(value.authorization)) throw new Error('授权响应无效')
|
||||
return {
|
||||
id: text(value.authorization.id),
|
||||
authorized_by: text(value.authorization.authorized_by),
|
||||
expires_at: text(value.authorization.expires_at),
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchLibraryCaptures(signal?: AbortSignal): Promise<LibraryCaptureJob[]> {
|
||||
const response = await fetch('/api/v1/library/captures', {
|
||||
headers: { Accept: 'application/json' },
|
||||
@@ -391,6 +486,18 @@ export interface LibraryGate {
|
||||
reasons: string[]
|
||||
}
|
||||
|
||||
export interface LibraryExecutorView {
|
||||
available: boolean
|
||||
reason?: string
|
||||
instance?: string
|
||||
project?: string
|
||||
source_app?: string
|
||||
archive_root?: string
|
||||
busy: boolean
|
||||
degraded?: string
|
||||
observed_at?: string
|
||||
}
|
||||
|
||||
export interface LibraryOverview {
|
||||
scope: string
|
||||
test_sealed?: LibrarySnapshot
|
||||
@@ -404,6 +511,8 @@ export interface LibraryOverview {
|
||||
authorization: LibraryAuthorizationView
|
||||
capture_available: boolean
|
||||
capture_reason?: string
|
||||
// 执行器是 worker 的真实观测:指向哪个实例、何时观测、是否降级/在跑。
|
||||
executor?: LibraryExecutorView
|
||||
now: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user