fix(release): 修复tableState文本身份与快照统计归属
build / build (push) Successful in 1m57s

tableState 非 resources 分支恢复直接扫描 text 身份,避免 queryRows 把普通
doc id/UUID/复合键/数字 id 当 JSON canonical 校验而失败;resources 仍使用
F01 精确引用闭包。隔离 PG 覆盖字符串、UUID、复合键与数字 ID。

F11:快照 HTTP 投影补 bindings/wiki_log_entries;作业统计按本作业
target_snapshot_id/snapshot_id 取对应快照,取不到显示未知,不借最新封存;
running/pending/failed/unknown 不再在无 detail 时显示完成。

F03:verifications 支持 snapshot_id 精确查询,返回 snapshot_latest 并只按
该快照回答 verified;抽屉“查看校验记录”按选中快照加载,不借用最新记录。

F08:显示主机探测时间;无真实 next-run 时说明尚未核验下一次执行;最近成功
单列未知,不以最近运行冒充。

验证:Go/前端全量、隔离 PG tableState/F01 对拍与 make verify 17 项通过。
实际 Current→Prepare→Verify ES 集成被共享 ES 磁盘 watermark 阻塞,未冒充。
This commit is contained in:
姚章浩
2026-11-05 13:19:27 +08:00
parent 1a0f99e346
commit b81fcd07e3
15 changed files with 404 additions and 41 deletions
+22
View File
@@ -1,5 +1,27 @@
# Changelog
## 开发快照 0.1.360(tableState 文本身份、快照统计归属与精确验证记录)
### Fixed
- **tableState 文本身份回归**:非 resources 分支恢复直接扫描 `(id)::text`,不再把普通
doc id/UUID/复合键/数字 ID 送入 `queryRows` 的 JSON canonical 校验;resources 仍走
F01 精确引用快照路径。新增隔离 PG 的字符串、UUID、复合键、数字 ID 回归。
- **F11 统计**:HTTP 快照投影补 `bindings`、`wiki_log_entries`;组件按当前作业自己的
`target_snapshot_id/snapshot_id` 选快照,取不到显示未知,不借最新 test_sealed;阶段
无 detail 时按 running/pending/failed/unknown 展示,不把缺失 completed 当完成。
- **F03 精确快照验证记录**:GET verifications 支持 `snapshot_id` 精确查询,返回
`snapshot_latest` 并只按该快照回答 verified;快照抽屉的“查看校验记录”按选中快照
加载,不借用其他快照/最新 N 条记录。
- **F08 主机计划展示**:显示主机探测时间;无真实 next-run 时明确“已配置每日…尚未核验
下一次实际执行”;最近成功单列并显示未读取,不以最近运行或 manifest 无 failure 冒充。
### Tests
- 隔离 PG tableState 文本 ID、F01 20k 资源闭包对拍、HTTP totals/精确 snapshot verification
回归;前端作业快照/阶段状态、恢复验证报告与确认块回归。实际 Current→Prepare→Verify
ES 集成尝试受共享 ES 磁盘 watermark 阻塞,未冒充通过。
## 开发快照 0.1.359(发布管理 F01–F11:资源闭包、状态真源与易用性)
### Fixed
+3
View File
@@ -98,6 +98,8 @@ type backupScheduleView struct {
HostCronLines []string `json:"host_cron_lines"`
HostCronService string `json:"host_cron_service"`
HostScriptPresent bool `json:"host_script_present"`
// HostCheckedAt 是本次真实主机探测时间;没有探测(只读库)时为空。
HostCheckedAt string `json:"host_checked_at,omitempty"`
LastRunStamp string `json:"last_run_stamp"`
LastRunCreatedAt string `json:"last_run_created_at"`
@@ -296,6 +298,7 @@ func (config Config) backupSchedulePayload(ctx context.Context, probeHost bool)
if !probeHost {
return view, nil
}
view.HostCheckedAt = formatTime(config.Now().UTC())
show, err := config.BackupHost.Show(ctx)
if err != nil {
view.HostReachable = false
@@ -266,3 +266,25 @@ func TestLibraryReadPathsRejectTamperedManifest(t *testing.T) {
t.Fatalf("storage must fail closed: status=%d body=%s", recorder.Code, recorder.Body.String())
}
}
// F11: the HTTP projection must include binding and Wiki log counts; the
// previous projection silently omitted them and the UI turned absence into 0.
func TestLibrarySnapshotTotalsIncludeBindingsAndWikiLogs(t *testing.T) {
view := librarySnapshotViewOfNoUsage("snap-totals", librarysnapshot.Manifest{
Scope: "internal", KnowledgeBaseID: "kb", TenantID: 10000,
Parts: []librarysnapshot.Part{
{Kind: "database", Name: "knowledges", Count: 4},
{Kind: "database", Name: "chunks", Count: 38},
{Kind: "database", Name: "resources", Count: 21},
{Kind: "database", Name: "resource_bindings", Count: 4},
{Kind: "wiki", Name: "wiki_pages", Count: 31},
{Kind: "wiki", Name: "wiki_folders", Count: 21},
{Kind: "wiki", Name: "wiki_log_entries", Count: 11},
},
})
for key, want := range map[string]int64{"documents": 4, "chunks": 38, "resources": 21, "bindings": 4, "wiki_pages": 31, "wiki_folders": 21, "wiki_log_entries": 11} {
if view.Totals[key] != want {
t.Fatalf("totals[%s]=%d want=%d (%v)", key, view.Totals[key], want, view.Totals)
}
}
}
+4
View File
@@ -261,10 +261,14 @@ func librarySnapshotViewOfNoUsage(id string, manifest librarysnapshot.Manifest)
}
case "resources":
snapshot.Totals["resources"] = part.Count
case "resource_bindings":
snapshot.Totals["bindings"] = part.Count
case "wiki_pages":
snapshot.Totals["wiki_pages"] = part.Count
case "wiki_folders":
snapshot.Totals["wiki_folders"] = part.Count
case "wiki_log_entries":
snapshot.Totals["wiki_log_entries"] = part.Count
}
}
sort.Slice(snapshot.Parts, func(i, j int) bool {
+42 -10
View File
@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"net/http"
"strings"
"time"
"knowledge-governance/internal/governance"
@@ -23,16 +24,19 @@ type libraryVerifyRequest struct {
// libraryVerificationView 是页面需要的全部事实:当前目标是谁、最近一次验证结果、
// 证据是否对当前目标成立、以及同步闸口能不能据此放行。
type libraryVerificationView struct {
Scope string `json:"scope"`
Target libraryVerificationTarget `json:"target"`
Verified bool `json:"verified"`
Reason string `json:"reason"`
Latest *governance.LibraryRestoreVerification `json:"latest,omitempty"`
History []governance.LibraryRestoreVerification `json:"history"`
Running bool `json:"running"`
JobID string `json:"job_id,omitempty"`
State string `json:"state,omitempty"`
Stage string `json:"stage,omitempty"`
Scope string `json:"scope"`
Target libraryVerificationTarget `json:"target"`
Verified bool `json:"verified"`
Reason string `json:"reason"`
Latest *governance.LibraryRestoreVerification `json:"latest,omitempty"`
// SnapshotLatest 只在请求显式带 snapshot_id 时返回:快照报告不能拿 scope
// 最近一条代替。
SnapshotLatest *governance.LibraryRestoreVerification `json:"snapshot_latest,omitempty"`
History []governance.LibraryRestoreVerification `json:"history"`
Running bool `json:"running"`
JobID string `json:"job_id,omitempty"`
State string `json:"state,omitempty"`
Stage string `json:"stage,omitempty"`
}
type libraryVerificationTarget struct {
@@ -177,11 +181,39 @@ func libraryVerifications(w http.ResponseWriter, r *http.Request, config Config)
if history, err := store.LibraryRestoreVerifications(r.Context(), scope, 10); err == nil {
view.History = history
}
if snapshotID := strings.TrimSpace(r.URL.Query().Get("snapshot_id")); snapshotID != "" {
if exact, ok := config.Store.(interface {
LibraryRestoreVerificationForSnapshot(context.Context, string, string) (governance.LibraryRestoreVerification, bool, error)
}); ok {
item, found, exactErr := exact.LibraryRestoreVerificationForSnapshot(r.Context(), scope, snapshotID)
if exactErr != nil {
view.Reason = "读取该快照的恢复验证记录失败"
writeJSON(w, http.StatusServiceUnavailable, view)
return
}
if found {
view.SnapshotLatest = &item
}
}
}
view.Verified, view.Reason = governance.LibraryVerificationSatisfied(latest, scope)
if !found {
view.Verified = false
view.Reason = "还没有对当前目标做过恢复验证:点「跑一次恢复验证」(真实备份 → canary → 恢复 → 复核)"
}
if snapshotID := strings.TrimSpace(r.URL.Query().Get("snapshot_id")); snapshotID != "" {
// 显式按快照查询时,verified 只回答该快照;不能让 scope 最新通过记录
// 把另一个未演练的快照染绿。
view.Verified = false
if view.SnapshotLatest == nil {
view.Reason = "该快照没有真实恢复验证记录(未验证;不是读取失败)"
} else if view.SnapshotLatest.Status == governance.LibraryVerificationPassed {
view.Verified = true
view.Reason = ""
} else {
view.Reason = "该快照最近一次恢复验证未通过"
}
}
// 在飞作业必须从真实业务行读出来:旧实现只构造 running 字段却从不赋值,
// 页面因此在验收里看不到"已接受/正在执行",按钮也不会进入执行态(审核 B4)。
if jobs, ok := config.Store.(interface {
+32
View File
@@ -34,6 +34,15 @@ func (s *verificationStore) LatestLibraryRestoreVerification(_ context.Context,
return governance.LibraryRestoreVerification{}, false, nil
}
func (s *verificationStore) LibraryRestoreVerificationForSnapshot(_ context.Context, scope, snapshotID string) (governance.LibraryRestoreVerification, bool, error) {
for _, item := range s.verifications {
if item.Scope == scope && item.SnapshotID == snapshotID {
return item, true, nil
}
}
return governance.LibraryRestoreVerification{}, false, nil
}
func (s *verificationStore) LibraryRestoreVerifications(_ context.Context, scope string, limit int) ([]governance.LibraryRestoreVerification, error) {
items := make([]governance.LibraryRestoreVerification, 0, len(s.verifications))
for _, item := range s.verifications {
@@ -131,6 +140,29 @@ func TestLibraryVerificationsWithoutEvidenceIsNotVerified(t *testing.T) {
}
}
// F03: the selected snapshot must resolve its own evidence, never the scope's
// newest passed record.
func TestLibraryVerificationsExactSnapshotDoesNotBorrowLatest(t *testing.T) {
store := &verificationStore{verifications: []governance.LibraryRestoreVerification{
{ID: "verify-new", Scope: "internal", Status: governance.LibraryVerificationPassed,
SnapshotID: "snap-new", JobID: "job-new", FinishedAt: time.Now().UTC()},
{ID: "verify-old", Scope: "internal", Status: governance.LibraryVerificationFailed,
SnapshotID: "snap-old", JobID: "job-old", Failure: "old restore mismatch",
FinishedAt: time.Now().UTC().Add(-time.Hour)},
}}
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/api/v1/library/verifications?scope=internal&snapshot_id=snap-old", nil)
libraryVerifications(recorder, request, Config{Store: store})
body := decodeBody(t, recorder)
exact, ok := body["snapshot_latest"].(map[string]any)
if !ok || exact["snapshot_id"] != "snap-old" || exact["status"] != "failed" {
t.Fatalf("snapshot_latest=%v body=%v", body["snapshot_latest"], body)
}
if body["verified"] != false {
t.Fatalf("old failed snapshot must not inherit latest passed: %v", body["verified"])
}
}
func TestLibraryVerificationsWithPassedEvidenceIsVerified(t *testing.T) {
store := &verificationStore{verifications: []governance.LibraryRestoreVerification{{
ID: "libverify-internal-job1", Scope: "internal", Status: governance.LibraryVerificationPassed,
@@ -71,6 +71,29 @@ LIMIT 1`, s.libraryTargetID, scope).Scan(&item.ID, &item.Scope, &item.Status, &i
}
// LibraryRestoreVerifications 是页面用的轻量清单(最近 N 条),按目标过滤。
// LibraryRestoreVerificationForSnapshot 只按本次精确快照查证据,不能用最近 N 条
// 扫描代替:旧快照的报告可能已经掉出列表,但仍是该快照真实的恢复演练记录。
func (s *Store) LibraryRestoreVerificationForSnapshot(ctx context.Context, scope, snapshotID string) (governance.LibraryRestoreVerification, bool, error) {
var item governance.LibraryRestoreVerification
var report []byte
err := s.db.QueryRow(ctx, `
SELECT id, scope, status, snapshot_id, job_id, failure, report, started_at, finished_at
FROM library_restore_verifications
WHERE target_id=$1 AND scope=$2 AND snapshot_id=$3
ORDER BY finished_at DESC, created_at DESC
LIMIT 1`, s.libraryTargetID, scope, snapshotID).Scan(&item.ID, &item.Scope, &item.Status,
&item.SnapshotID, &item.JobID, &item.Failure, &report, &item.StartedAt, &item.FinishedAt)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return governance.LibraryRestoreVerification{}, false, nil
}
return governance.LibraryRestoreVerification{}, false, fmt.Errorf("load snapshot restore verification: %w", err)
}
item.Report = decodeEvidenceObject(report)
return item, true, nil
}
func (s *Store) LibraryRestoreVerifications(ctx context.Context, scope string, limit int) ([]governance.LibraryRestoreVerification, error) {
if limit <= 0 {
limit = 10
@@ -329,32 +329,39 @@ func (t *ProductionLibraryTarget) tableState(ctx context.Context, table string)
return 0, "", fmt.Errorf("读取生产 %s 失败: %w", table, err)
}
rows = items
} else {
exporter := PostgresExporter{db: t.pool}
items, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY 1`,
}
// Non-resource identities are plain text (UUID, doc id, numeric id, or a
// composite key), not JSON. Scanning them through queryRows would run
// canonicalJSON on text and fail on every non-empty table.
if table != "resources" {
rows, err := t.pool.Query(ctx, fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY 1`,
"("+identityExpr(table)+")::text", table, scopeWhere(table)), identity.TenantID, identity.KnowledgeBaseID)
if err != nil {
return 0, "", fmt.Errorf("读取生产 %s 失败", table)
}
rows = items
defer rows.Close()
ids := make([]string, 0, 64)
for rows.Next() {
var id string
if err := rows.Scan(&id); err != nil {
return 0, "", err
}
ids = append(ids, id)
}
if err := rows.Err(); err != nil {
return 0, "", err
}
return int64(len(ids)), digestIDs(ids), nil
}
ids := make([]string, 0, len(rows))
for _, raw := range rows {
var id string
if table == "resources" {
var value struct {
ID string `json:"id"`
}
if err := json.Unmarshal(raw, &value); err != nil || value.ID == "" {
return 0, "", fmt.Errorf("读取生产 %s 身份失败", table)
}
id = value.ID
} else {
if err := json.Unmarshal(raw, &id); err != nil {
return 0, "", err
}
var value struct {
ID string `json:"id"`
}
ids = append(ids, id)
if err := json.Unmarshal(raw, &value); err != nil || value.ID == "" {
return 0, "", fmt.Errorf("读取生产 %s 身份失败", table)
}
ids = append(ids, value.ID)
}
return int64(len(ids)), digestIDs(ids), nil
}
@@ -14,8 +14,10 @@ import (
// F01 regression: a small target library inside a large tenant must resolve
// resources from exact binding ids + body handles, not by scanning every
// tenant resource with a per-row body predicate. The old predicate is kept
// here only as an oracle for equality and timing; never as the product path.
// tenant resource with a per-row body predicate. The fixture has one active
// target document/chunk (plus one deleted and one foreign-tenant row), not the
// historical 4/38 target; the 20,000 unrelated resources are the scale input.
// The old predicate is kept only as an oracle for equality and timing.
func TestLibraryResourceClosureIsBoundedByTargetReferences(t *testing.T) {
dsn := os.Getenv("KG_LIBRARY_TEST_DATABASE_URL")
if dsn == "" {
@@ -106,7 +108,15 @@ func TestLibraryResourceClosureIsBoundedByTargetReferences(t *testing.T) {
if len(newIDs) != 2 {
t.Fatalf("expected bound + body-only resources, got %v", newIDs)
}
t.Logf("unrelated_resources=%d old_scan=%s new_export=%s resources=%v", unrelated, oldElapsed, newElapsed, newIDs)
var activeDocs, activeChunks int
if err := db.QueryRow(ctx, `SELECT count(*) FROM knowledges WHERE tenant_id=10000 AND knowledge_base_id='kb-safe' AND deleted_at IS NULL`).Scan(&activeDocs); err != nil {
t.Fatal(err)
}
if err := db.QueryRow(ctx, `SELECT count(*) FROM chunks c JOIN knowledges k ON k.id=c.knowledge_id WHERE k.tenant_id=10000 AND k.knowledge_base_id='kb-safe' AND k.deleted_at IS NULL AND c.deleted_at IS NULL`).Scan(&activeChunks); err != nil {
t.Fatal(err)
}
t.Logf("target_docs=%d target_chunks=%d unrelated_resources=%d old_scan=%s new_export=%s resources=%v",
activeDocs, activeChunks, unrelated, oldElapsed, newElapsed, newIDs)
if newElapsed >= oldElapsed {
t.Fatalf("F01 path did not improve: old=%s new=%s", oldElapsed, newElapsed)
}
@@ -0,0 +1,58 @@
package v071
import (
"context"
"os"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
)
// F01/F02 regression: tableState reads normal text identities (doc id, UUID,
// composite relation key, numeric local id) without treating them as JSON.
// The fixture is session-local temp tables; it never writes a persistent table.
func TestTableStateReadsTextIdentities(t *testing.T) {
dsn := os.Getenv("KG_LIBRARY_TEST_DATABASE_URL")
if dsn == "" {
t.Skip("requires explicitly isolated kg_library_test_ PostgreSQL database")
}
cfg, err := pgxpool.ParseConfig(dsn)
if err != nil || !strings.HasPrefix(cfg.ConnConfig.Database, "kg_library_test_") {
t.Fatal("isolated database required")
}
cfg.MaxConns = 1
cfg.ConnConfig.RuntimeParams["search_path"] = "pg_temp"
cfg.ConnConfig.RuntimeParams["statement_timeout"] = "10000"
ctx := context.Background()
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
t.Fatal(err)
}
defer pool.Close()
_, err = pool.Exec(ctx, `
CREATE TEMP TABLE knowledges(id text,tenant_id bigint,knowledge_base_id text);
CREATE TEMP TABLE knowledge_tag_relations(knowledge_id text,tag_id text);
CREATE TEMP TABLE wiki_log_entries(id bigint,tenant_id bigint,knowledge_base_id text);
INSERT INTO knowledges VALUES ('doc-parent',10000,'kb-parent'),
('f643a7e7-bbb9-4209-9033-fca7f76f011b',10000,'kb-parent');
INSERT INTO knowledge_tag_relations VALUES ('doc-parent','tag-parent'),
('f643a7e7-bbb9-4209-9033-fca7f76f011b','tag-two');
INSERT INTO wiki_log_entries VALUES (1,10000,'kb-parent'),(2,10000,'kb-parent');`)
if err != nil {
t.Fatal(err)
}
target := &ProductionLibraryTarget{pool: pool, scope: ProductionScope{TenantID: 10000, KnowledgeBaseID: "kb-parent"}}
for _, fixture := range []struct {
table string
count int64
}{{"knowledges", 2}, {"knowledge_tag_relations", 2}, {"wiki_log_entries", 2}} {
count, _, err := target.tableState(ctx, fixture.table)
if err != nil {
t.Fatalf("%s plain identity rejected: %v", fixture.table, err)
}
if count != fixture.count {
t.Fatalf("%s count=%d want=%d", fixture.table, count, fixture.count)
}
}
}
+15 -2
View File
@@ -126,9 +126,20 @@ export function BackupScheduleView(props: BackupScheduleViewProps) {
<dt>备份脚本</dt>
<dd>{scriptLine}</dd>
<dt>主机 cron</dt>
<dd>{schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'}</dd>
<dd>
{schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'}
{schedule.host_checked_at
? ` · 状态读取于 ${formatBackupMoment(schedule.host_checked_at)}`
: ' · 状态读取时间未知'}
</dd>
<dt>下次计划(主机读回)</dt>
<dd>{schedule.host_next_run || '未读取(不以历史下发时间推算)'}</dd>
<dd>
{schedule.host_next_run
? schedule.host_next_run
: schedule.host_schedule_enabled
? `已配置每日 ${schedule.host_schedule_time};尚未核验下一次实际执行(不以历史下发时间推算)`
: '未启用定时或未从主机读到计划'}
</dd>
<dt>最近一次运行</dt>
<dd>
{schedule.last_run_stamp || '—'}
@@ -139,6 +150,8 @@ export function BackupScheduleView(props: BackupScheduleViewProps) {
: ' · 有失败/读取错误'
: ''}
</dd>
<dt>最近一次成功</dt>
<dd>未从主机读取(不能用最近一次运行代替成功;失败库/读取错误见下方)</dd>
<dt>备份耗时 / 体积</dt>
<dd>
{schedule.last_run_stamp
+43
View File
@@ -680,6 +680,49 @@ describe('ReleaseTargetsView(确认稿布局)', () => {
expect(drawer).toContain('当前筛选下没有已登记版本')
})
it('统计数据只取当前作业快照,并按真实阶段状态展示', () => {
const jobSnapshot: LibrarySnapshot = {
...internalSnapshot,
snapshot_id: 'old-job-snapshot',
totals: {
documents: 4,
chunks: 38,
resources: 21,
bindings: 4,
wiki_pages: 31,
wiki_folders: 21,
wiki_log_entries: 11,
},
}
const job: LibraryJobRecord = {
...persistedSeal,
id: 'libsync-old',
kind: 'sync',
target_snapshot_id: jobSnapshot.snapshot_id,
stage: 'database',
stages: [
{
stage: 'database',
state: 'running',
total: null,
completed: null,
bytes_total: null,
bytes_done: null,
detail: '',
updated_at: '2026-11-05T01:00:00Z',
},
],
}
const html = render({
page: { ...page, snapshots: [internalSnapshot, jobSnapshot] },
overview: { ...overview, job, jobs: [job], test_sealed: internalSnapshot },
})
expect(html).toContain('文档 4 · 分块 38 · 资源 21 · 绑定 4')
expect(html).toContain('页面 31 · 目录 21 · 日志 11')
expect(html).toContain('执行中;执行器尚未提供该分项明细')
expect(html).not.toContain('已完成 0 / 0')
})
it('同步中布局显示四类数据卡与明细,且不编造百分比', () => {
const html = render({
jobs: [runningJob],
+77 -8
View File
@@ -31,6 +31,7 @@ import {
import { useOptionalAuth } from './auth'
import './releaseTargets.css'
import { RestoreVerificationSection } from './RestoreVerificationSection'
import { fetchSnapshotVerification } from './restoreVerification'
// 发布管理:按已确认稿实现——顶部两个知识库页签,下面是**一个**作业区。
//
@@ -1064,9 +1065,20 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
const running = job?.state === 'running' || job?.state === 'pending'
const stage = (name: string) => dataJob?.stages.find((item) => item.stage === name)
const sealed = view.overview?.test_sealed
// F11: counts must belong to the snapshot this job actually used. A newly
// sealed test version must not decorate an older sync/restore/verify job.
const jobSnapshotID = job?.target_snapshot_id || job?.snapshot_id || ''
const sealed = jobSnapshotID
? view.history.find((item) => item.snapshot_id === jobSnapshotID)
: job
? undefined
: view.overview?.test_sealed
const breakdown = (keys: [string, string][]) =>
sealed ? keys.map(([key, label]) => `${label} ${sealed.totals[key] ?? 0}`).join(' · ') : ''
sealed
? keys
.map(([key, label]) => `${label} ${typeof sealed.totals[key] === 'number' ? sealed.totals[key] : '未知'}`)
.join(' · ')
: ''
const cards = [
// F11: these totals are storage rows, not business document/page counts.
{
@@ -1185,9 +1197,17 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
{card.row?.detail
? card.row.detail
: card.row
? card.row.total != null
? `已完成 ${card.row.completed ?? card.row.total} / ${card.row.total} 条存储行`
: '该分项已完成,执行器未提供可核对总量'
? card.row.state === 'running'
? '执行中;执行器尚未提供该分项明细'
: card.row.state === 'pending'
? '等待执行;不能按完成展示'
: card.row.state === 'failed'
? '失败;没有可核对完成量'
: card.row.state === 'succeeded'
? card.row.total != null
? `已完成 ${card.row.completed ?? card.row.total} / ${card.row.total} 条存储行`
: '该分项已完成,执行器未提供可核对总量'
: '状态未知;不按完成展示'
: dataJob
? '本次作业没有产生该分项阶段记录'
: '目标同步执行链未接通,分项不会产生数据'}
@@ -1267,6 +1287,57 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO
)
}
// F03: exact snapshot evidence. This component deliberately reads
// snapshot_latest, not latest: a passed record for another snapshot must not
// make this one look verified.
function SnapshotVerification({ scope, snapshotID }: { scope: Scope; snapshotID: string }) {
const [state, setState] = useState<'loading' | 'loaded' | 'error'>('loading')
const [record, setRecord] = useState<Awaited<ReturnType<typeof fetchSnapshotVerification>>['snapshot_latest']>(null)
const [error, setError] = useState('')
useEffect(() => {
const controller = new AbortController()
setState('loading')
setRecord(null)
setError('')
fetchSnapshotVerification(scope, snapshotID, controller.signal)
.then((value) => {
setRecord(value.snapshot_latest ?? null)
setState('loaded')
})
.catch((reason: unknown) => {
if ((reason as Error)?.name === 'AbortError') return
setError(reason instanceof Error ? reason.message : '读取该快照的恢复验证记录失败')
setState('error')
})
return () => controller.abort()
}, [scope, snapshotID])
return (
<details className="rel-snapshot-verification">
<summary>查看校验记录</summary>
{state === 'loading' ? <p className="rel-hint">正在读取该快照的恢复验证记录…</p> : null}
{state === 'error' ? <p className="rel-warn">{error}</p> : null}
{state === 'loaded' && !record ? (
<p className="rel-hint">该快照没有真实恢复验证记录;未验证,原因不是加载失败。</p>
) : null}
{record ? (
<div>
<p className="rel-hint">
该快照最近一次:{record.status === 'passed' ? '通过' : '未通过'} · {record.finished_at} · 作业{' '}
{record.job_id}
</p>
{record.failure ? <p className="rel-warn">原因:{record.failure}</p> : null}
{record.report ? (
<details>
<summary>本次恢复验证报告</summary>
<pre>{JSON.stringify(record.report, null, 2)}</pre>
</details>
) : null}
</div>
) : null}
</details>
)
}
interface DrawerProps {
view: ScopeView
page: LibrarySnapshotList | null
@@ -1534,9 +1605,7 @@ function Drawer({
以上是同一恢复点的配套内容,不支持只选择 Wiki 或某张数据表恢复。恢复速度需实测;“有备份文件”不等于可恢复。
</p>
<div className="rel-drawer-actions">
<button type="button" disabled title="逐项校验证据在同步/恢复执行时产生,作业详情里可见">
查看校验记录
</button>
<SnapshotVerification scope={view.scope} snapshotID={selected.snapshot_id} />
{canRestore && restore && restore.authorization.state !== 'active' ? (
<button type="button" className="rel-secondary" onClick={onGrantRestore} disabled={pendingGrant}>
{pendingGrant ? '签发中…' : '签发恢复授权(30 分钟)'}
+2
View File
@@ -20,6 +20,7 @@ export interface BackupSchedule {
host_schedule_time: string
// 主机若没有返回真实 next-run,保持空串;页面不得用历史下发时间推算。
host_next_run?: string
host_checked_at?: string
host_keep_days: number
host_keep_months: number
host_cron_lines: string[]
@@ -80,6 +81,7 @@ function scheduleProjection(item: Record<string, unknown>): BackupSchedule {
host_schedule_enabled: item.host_schedule_enabled === true,
host_schedule_time: asString(item.host_schedule_time),
host_next_run: asString(item.host_next_run),
host_checked_at: asString(item.host_checked_at),
host_keep_days: asNumber(item.host_keep_days),
host_keep_months: asNumber(item.host_keep_months),
host_cron_lines: asStringList(item.host_cron_lines),
+23
View File
@@ -33,6 +33,7 @@ export interface LibraryVerificationView {
verified: boolean
reason: string
latest?: LibraryVerificationRecord | null
snapshot_latest?: LibraryVerificationRecord | null
history: LibraryVerificationRecord[]
running?: boolean
job_id?: string
@@ -91,6 +92,7 @@ export function parseVerificationView(value: unknown): LibraryVerificationView |
? value.history.map(parseVerificationRecord).filter((item): item is LibraryVerificationRecord => item !== null)
: []
const latest = parseVerificationRecord(value.latest) ?? null
const snapshotLatest = parseVerificationRecord(value.snapshot_latest) ?? null
return {
scope: text(value.scope),
target,
@@ -98,6 +100,7 @@ export function parseVerificationView(value: unknown): LibraryVerificationView |
verified: bool(value.verified),
reason: text(value.reason),
latest,
snapshot_latest: snapshotLatest,
history,
running: bool(value.running),
job_id: text(value.job_id) || undefined,
@@ -109,6 +112,26 @@ export function parseVerificationView(value: unknown): LibraryVerificationView |
// fetchLibraryVerification 只返回**这份 scope** 的观测:
// HTTP 非 2xx、响应不可解析、或响应 scope 与请求不一致都抛错。
// 调用方据此把"读失败"和"没有证据"分开显示,不能拿旧绿色遮住失败(审核 B5)。
// F03: exact snapshot report. Never fall back to the scope's latest record.
export async function fetchSnapshotVerification(
scope: string,
snapshotID: string,
signal?: AbortSignal
): Promise<LibraryVerificationView> {
const query = `scope=${encodeURIComponent(scope)}&snapshot_id=${encodeURIComponent(snapshotID)}`
const response = await fetch(`/api/v1/library/verifications?${query}`, {
headers: { Accept: 'application/json' },
signal,
})
const value = (await response.json().catch(() => null)) as unknown
if (!response.ok) throw new Error(`读取该快照的恢复验证记录失败(HTTP ${response.status})`)
const parsed = parseVerificationView(value)
if (!parsed) throw new Error('读取该快照的恢复验证记录失败:响应不可解析')
if (parsed.scope !== scope) throw new Error('读取该快照的恢复验证记录失败:scope 不一致')
return parsed
}
export async function fetchLibraryVerification(scope: string, signal?: AbortSignal): Promise<LibraryVerificationView> {
const response = await fetch(`/api/v1/library/verifications?scope=${encodeURIComponent(scope)}`, {
headers: { Accept: 'application/json' },