From b81fcd07e3fbbd7695397d8867a0cbee94422ef5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A7=9A=E7=AB=A0=E6=B5=A9?= Date: Thu, 5 Nov 2026 13:19:27 +0800 Subject: [PATCH] =?UTF-8?q?fix(release):=20=E4=BF=AE=E5=A4=8DtableState?= =?UTF-8?q?=E6=96=87=E6=9C=AC=E8=BA=AB=E4=BB=BD=E4=B8=8E=E5=BF=AB=E7=85=A7?= =?UTF-8?q?=E7=BB=9F=E8=AE=A1=E5=BD=92=E5=B1=9E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 阻塞,未冒充。 --- CHANGELOG.md | 22 +++++ internal/httpapi/backup_schedule.go | 3 + internal/httpapi/library_read_paths_test.go | 22 +++++ internal/httpapi/library_snapshots.go | 4 + internal/httpapi/library_verify.go | 52 +++++++++--- internal/httpapi/library_verify_test.go | 32 +++++++ .../platform/postgres/library_verification.go | 23 +++++ .../v071/library_production_target.go | 43 ++++++---- .../v071/library_resource_scope_f01_test.go | 16 +++- .../v071/library_table_state_text_id_test.go | 58 +++++++++++++ web/src/BackupScheduleSection.tsx | 17 +++- web/src/ReleaseTargets.test.tsx | 43 ++++++++++ web/src/ReleaseTargets.tsx | 85 +++++++++++++++++-- web/src/backupSchedule.ts | 2 + web/src/restoreVerification.ts | 23 +++++ 15 files changed, 404 insertions(+), 41 deletions(-) create mode 100644 internal/releaseadapter/v071/library_table_state_text_id_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8fcd83f..c86a0eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/internal/httpapi/backup_schedule.go b/internal/httpapi/backup_schedule.go index 92e14fe..11918c4 100644 --- a/internal/httpapi/backup_schedule.go +++ b/internal/httpapi/backup_schedule.go @@ -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 diff --git a/internal/httpapi/library_read_paths_test.go b/internal/httpapi/library_read_paths_test.go index 0944c5f..f54d3c9 100644 --- a/internal/httpapi/library_read_paths_test.go +++ b/internal/httpapi/library_read_paths_test.go @@ -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) + } + } +} diff --git a/internal/httpapi/library_snapshots.go b/internal/httpapi/library_snapshots.go index 12f1f63..096bd3d 100644 --- a/internal/httpapi/library_snapshots.go +++ b/internal/httpapi/library_snapshots.go @@ -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 { diff --git a/internal/httpapi/library_verify.go b/internal/httpapi/library_verify.go index a742ab6..3ffdff7 100644 --- a/internal/httpapi/library_verify.go +++ b/internal/httpapi/library_verify.go @@ -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 { diff --git a/internal/httpapi/library_verify_test.go b/internal/httpapi/library_verify_test.go index a34d8ba..f873bc7 100644 --- a/internal/httpapi/library_verify_test.go +++ b/internal/httpapi/library_verify_test.go @@ -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, diff --git a/internal/platform/postgres/library_verification.go b/internal/platform/postgres/library_verification.go index e42ab37..86cefb9 100644 --- a/internal/platform/postgres/library_verification.go +++ b/internal/platform/postgres/library_verification.go @@ -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 diff --git a/internal/releaseadapter/v071/library_production_target.go b/internal/releaseadapter/v071/library_production_target.go index fc9670a..495006c 100644 --- a/internal/releaseadapter/v071/library_production_target.go +++ b/internal/releaseadapter/v071/library_production_target.go @@ -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 } diff --git a/internal/releaseadapter/v071/library_resource_scope_f01_test.go b/internal/releaseadapter/v071/library_resource_scope_f01_test.go index 9e60938..ee8bc83 100644 --- a/internal/releaseadapter/v071/library_resource_scope_f01_test.go +++ b/internal/releaseadapter/v071/library_resource_scope_f01_test.go @@ -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) } diff --git a/internal/releaseadapter/v071/library_table_state_text_id_test.go b/internal/releaseadapter/v071/library_table_state_text_id_test.go new file mode 100644 index 0000000..3924555 --- /dev/null +++ b/internal/releaseadapter/v071/library_table_state_text_id_test.go @@ -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) + } + } +} diff --git a/web/src/BackupScheduleSection.tsx b/web/src/BackupScheduleSection.tsx index 62d5684..b88f562 100644 --- a/web/src/BackupScheduleSection.tsx +++ b/web/src/BackupScheduleSection.tsx @@ -126,9 +126,20 @@ export function BackupScheduleView(props: BackupScheduleViewProps) {
备份脚本
{scriptLine}
主机 cron
-
{schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'}
+
+ {schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'} + {schedule.host_checked_at + ? ` · 状态读取于 ${formatBackupMoment(schedule.host_checked_at)}` + : ' · 状态读取时间未知'} +
下次计划(主机读回)
-
{schedule.host_next_run || '未读取(不以历史下发时间推算)'}
+
+ {schedule.host_next_run + ? schedule.host_next_run + : schedule.host_schedule_enabled + ? `已配置每日 ${schedule.host_schedule_time};尚未核验下一次实际执行(不以历史下发时间推算)` + : '未启用定时或未从主机读到计划'} +
最近一次运行
{schedule.last_run_stamp || '—'} @@ -139,6 +150,8 @@ export function BackupScheduleView(props: BackupScheduleViewProps) { : ' · 有失败/读取错误' : ''}
+
最近一次成功
+
未从主机读取(不能用最近一次运行代替成功;失败库/读取错误见下方)
备份耗时 / 体积
{schedule.last_run_stamp diff --git a/web/src/ReleaseTargets.test.tsx b/web/src/ReleaseTargets.test.tsx index cd59f74..a534bd1 100644 --- a/web/src/ReleaseTargets.test.tsx +++ b/web/src/ReleaseTargets.test.tsx @@ -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], diff --git a/web/src/ReleaseTargets.tsx b/web/src/ReleaseTargets.tsx index a9fa67f..b556d06 100644 --- a/web/src/ReleaseTargets.tsx +++ b/web/src/ReleaseTargets.tsx @@ -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>['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 ( +
+ 查看校验记录 + {state === 'loading' ?

正在读取该快照的恢复验证记录…

: null} + {state === 'error' ?

{error}

: null} + {state === 'loaded' && !record ? ( +

该快照没有真实恢复验证记录;未验证,原因不是加载失败。

+ ) : null} + {record ? ( +
+

+ 该快照最近一次:{record.status === 'passed' ? '通过' : '未通过'} · {record.finished_at} · 作业{' '} + {record.job_id} +

+ {record.failure ?

原因:{record.failure}

: null} + {record.report ? ( +
+ 本次恢复验证报告 +
{JSON.stringify(record.report, null, 2)}
+
+ ) : null} +
+ ) : null} +
+ ) +} + interface DrawerProps { view: ScopeView page: LibrarySnapshotList | null @@ -1534,9 +1605,7 @@ function Drawer({ 以上是同一恢复点的配套内容,不支持只选择 Wiki 或某张数据表恢复。恢复速度需实测;“有备份文件”不等于可恢复。

- + {canRestore && restore && restore.authorization.state !== 'active' ? (