diff --git a/web/src/ReleaseTargets.test.tsx b/web/src/ReleaseTargets.test.tsx
index 2a967cf..6e18f6d 100644
--- a/web/src/ReleaseTargets.test.tsx
+++ b/web/src/ReleaseTargets.test.tsx
@@ -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('无封存时给出“尚无封存”而不是空表', () => {
diff --git a/web/src/ReleaseTargets.tsx b/web/src/ReleaseTargets.tsx
index 1c4f952..32a2a0e 100644
--- a/web/src/ReleaseTargets.tsx
+++ b/web/src/ReleaseTargets.tsx
@@ -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 ? '投递中…' : '封存测试版本'}
) : (
- {canWrite ? '封存通道未接线' : '封存需要知识工程师及以上权限'}
+ {captureHint(view, canWrite)}
)}
>
@@ -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 ? '收起快照' : '查看快照'}
-
-
+
查看校验记录
-
- 恢复至该版本
-
+ {canRestore && restore && restore.authorization.state !== 'active' ? (
+
+ {pendingGrant ? '签发中…' : '签发恢复授权(30 分钟)'}
+
+ ) : null}
+ {canRestore ? (
+
+ {pendingRestore ? '投递中…' : '恢复至该版本'}
+
+ ) : (
+ 按版本恢复仅管理员可发起
+ )}
+ {restore && !restore.allowed ? (
+
+ {restore.reasons.map((reason) => (
+ - {reason}
+ ))}
+
+ ) : null}
+ {restore?.allowed ? (
+
+ 条件已满足:生产写入闸口打开、这份快照有恢复授权
+ {restore.authorization.expires_at ? `(有效至 ${stamp(restore.authorization.expires_at)})` : ''}
+ 、目标实例未在维护。
+
+ ) : null}
+ {restore === null ? 正在读取恢复条件…
: null}
+ {restoreNotice ? {restoreNotice}
: null}
) : 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}
/>
)
@@ -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(null)
+ const [restoreNotice, setRestoreNotice] = useState(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()}
diff --git a/web/src/libraryRestore.test.ts b/web/src/libraryRestore.test.ts
new file mode 100644
index 0000000..5707df3
--- /dev/null
+++ b/web/src/libraryRestore.test.ts
@@ -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
+ 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)
+ })
+})
diff --git a/web/src/librarySnapshots.ts b/web/src/librarySnapshots.ts
index 679c3ca..50441c6 100644
--- a/web/src/librarySnapshots.ts
+++ b/web/src/librarySnapshots.ts
@@ -185,6 +185,101 @@ export async function enqueueLibraryCapture(scope: string): Promise {
+ 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 {
+ 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 {
+ 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 {
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
}