diff --git a/CHANGELOG.md b/CHANGELOG.md index 3108286..8fcd83f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 开发快照 0.1.359(发布管理 F01–F11:资源闭包、状态真源与易用性) + +### Fixed + +- **F01 小目标大租户捕获/校验慢**:资源闭包不再按租户扫描 `resources` 并对每个资源反复 + 扫描所有正文表。先从已限定范围的 `resource_bindings` 与正文句柄集合提取精确 + `resource_id` / `handle`,再按索引取资源;捕获、预备份指纹、写前镜像、恢复后内容校验 + 共用同一条 `scopedResourceRows` 路径。绑定资源与无绑定的正文引用都保留,缺资源仍失败。 + 隔离 PG 夹具(20,000 条无关资源)旧扫描约 683ms、新导出约 41ms,闭包逐 ID 相等; + 真实 168k 规模与端到端维护耗时须父在目标环境复测。 +- **F02/F03 阶段与验证证据**:恢复验证使用自己的 capture → 基线校验 → canary → 恢复 → + 登记证据阶段,不再把登记证据叫“新版本生效”;顶部优先显示当前在飞作业。已验证且 + API reason 为空时不再同屏显示“还没有读到验证状态”;pending/loading/error/passed + 分开,最近一次通过可展开本次报告。 +- **F04/F06/F07/F08/F09/F10/F11**:发布页显示实际目标实例/项目/知识库;运行中主按钮改为 + “查看当前任务”并禁止重复启动;快照抽屉默认只列已登记版本,提供类型筛选、分页和 + 选中项滚动定位;下次计划缺失时显示未读取而不沿用陈旧时间;每个 scope 使用自己的 + overview(不同 scope/错配响应不借观测);新操作/切 scope 清除旧通知;数据卡明确 + “存储行”口径并在无总量时不伪造“未上报”。 +- **F05 高影响确认**:恢复验证与按版本恢复先显示目标、影响和恢复方式的一次确认,确认后 + 才投递;不新增审批门槛。 + +### Tests + +- 新增 F01 隔离 PG 资源闭包对拍与耗时;前端恢复验证通过/报告/确认、验证阶段、快照筛选、 + 运行中主按钮等挂载/静态回归。 + ## 开发快照 0.1.358(业务 HTTP 超时不再被探针上限截断) ### Fixed diff --git a/internal/httpapi/library_flow.go b/internal/httpapi/library_flow.go index f1bd640..257ef96 100644 --- a/internal/httpapi/library_flow.go +++ b/internal/httpapi/library_flow.go @@ -342,7 +342,11 @@ type libraryOverview struct { CaptureAvailable bool `json:"capture_available"` CaptureReason string `json:"capture_reason,omitempty"` Executor libraryExecutorView `json:"executor"` - Now time.Time `json:"now"` + // 实际目标身份:页面不能只显示"weknora-test"或旧文档级绑定名称。 + TargetInstance string `json:"target_instance,omitempty"` + TargetProject string `json:"target_project,omitempty"` + TargetKnowledgeBaseID string `json:"target_knowledge_base_id,omitempty"` + Now time.Time `json:"now"` } // libraryVersionRef 只回答"这个快照被登记成哪一类版本、由哪个作业登记"。 @@ -686,10 +690,16 @@ func libraryOverviewHandler(w http.ResponseWriter, r *http.Request, config Confi } now := time.Now().UTC() overview := libraryOverview{Scope: scope, Jobs: []governance.LibraryJob{}, Now: now, + TargetInstance: config.LibraryTargetInstance, TargetProject: config.LibraryTargetProject, Gate: libraryGateView{Reasons: []string{}}, Diff: libraryDiffView{State: "unknown", Reason: "生产与测试的文档级差异读取未接线", Added: []any{}, Updated: []any{}, Removed: []any{}}} // 可发起封存 = worker 真实探测到执行器可用且指向本实例;原因逐条给全。 overview.CaptureAvailable, overview.CaptureReason, overview.Executor = libraryExecutorAvailability(r.Context(), config) + if scope == "internal" { + overview.TargetKnowledgeBaseID = config.LibraryProductionInternalKB + } else { + overview.TargetKnowledgeBaseID = config.LibraryProductionExternalKB + } // 测试封存来自归档 + 版本登记:归档里**不止**测试封存——生产预备份与目标基线 // 快照同样带 scope,如果只按"这个 scope 最新一份"挑,页面就会把目标自己的内容 diff --git a/internal/releaseadapter/v071/library_backup_store.go b/internal/releaseadapter/v071/library_backup_store.go index 1eae127..a4e7334 100644 --- a/internal/releaseadapter/v071/library_backup_store.go +++ b/internal/releaseadapter/v071/library_backup_store.go @@ -73,32 +73,56 @@ type tableFingerprint struct { // fingerprints 读取当前生产指纹;Prepare 在导出前后各取一次做一致性判断。 func (s *ProductionBackupStore) fingerprints(ctx context.Context) (map[string]tableFingerprint, error) { result := map[string]tableFingerprint{} + exporter := PostgresExporter{db: s.pool} + var bodies, bindings []json.RawMessage for _, table := range productionLibraryTables { - rows, err := s.pool.Query(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`, + if table == "resources" { + continue + } + rows, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`, table, table, scopeWhere(table)), s.identity.TenantID, s.identity.KnowledgeBaseID) if err != nil { return nil, fmt.Errorf("读取生产 %s 指纹失败", table) } - digests := make([]string, 0, 64) - for rows.Next() { - var raw map[string]json.RawMessage - if err := rows.Scan(&raw); err != nil { - rows.Close() + if table != "resource_bindings" && table != "knowledge_tag_relations" { + bodies = append(bodies, rows...) + } + if table == "resource_bindings" { + bindings = rows + } + digests := make([]string, 0, len(rows)) + for _, raw := range rows { + var value map[string]json.RawMessage + if err := json.Unmarshal(raw, &value); err != nil { return nil, err } - digest, err := canonicalRowDigest(raw) + digest, err := canonicalRowDigest(value) if err != nil { - rows.Close() return nil, err } digests = append(digests, digest) } - rows.Close() - if err := rows.Err(); err != nil { - return nil, err - } result[table] = tableFingerprint{Count: int64(len(digests)), Digest: combineRowDigests(digests)} } + // F01: resolve the resources once from exact binding ids and body handles + // instead of scanning every tenant resource with a correlated body filter. + rows, err := scopedResourceRows(ctx, &exporter, s.identity, true, bodies, bindings) + if err != nil { + return nil, fmt.Errorf("读取生产 resources 指纹失败: %w", err) + } + digests := make([]string, 0, len(rows)) + for _, raw := range rows { + var value map[string]json.RawMessage + if err := json.Unmarshal(raw, &value); err != nil { + return nil, err + } + digest, err := canonicalRowDigest(value) + if err != nil { + return nil, err + } + digests = append(digests, digest) + } + result["resources"] = tableFingerprint{Count: int64(len(digests)), Digest: combineRowDigests(digests)} return result, nil } diff --git a/internal/releaseadapter/v071/library_beforeimage.go b/internal/releaseadapter/v071/library_beforeimage.go index 6d16d8c..92e0dcb 100644 --- a/internal/releaseadapter/v071/library_beforeimage.go +++ b/internal/releaseadapter/v071/library_beforeimage.go @@ -25,17 +25,34 @@ func exportLibraryBeforeImage(ctx context.Context, db librarySnapshotDB, identit return PostgresSnapshot{}, errors.New("before-image schema contract mismatch") } exporter := PostgresExporter{db: tx} - snapshot := PostgresSnapshot{} + rowsByTable := map[string][]json.RawMessage{} + var bodies, bindings []json.RawMessage for _, table := range productionLibraryTables { + if table == "resources" { + continue + } rows, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s ORDER BY (%s)::text`, table, table, scopeWhere(table), identityExpr(table)), identity.TenantID, identity.KnowledgeBaseID) if err != nil { return PostgresSnapshot{}, err } - snapshot.Tables = append(snapshot.Tables, newTableSnapshot(table, rows)) + rowsByTable[table] = rows + if table != "resource_bindings" && table != "knowledge_tag_relations" { + bodies = append(bodies, rows...) + } + if table == "resource_bindings" { + bindings = rows + } } - var bodies []json.RawMessage - for _, table := range snapshot.Tables { - bodies = append(bodies, table.Rows...) + // F01: the before-image needs exact rollback rows, including deleted + // resources, but must not rescan every tenant resource per body table. + resources, err := scopedResourceRows(ctx, &exporter, identity, true, bodies, bindings) + if err != nil { + return PostgresSnapshot{}, err + } + rowsByTable["resources"] = resources + snapshot := PostgresSnapshot{} + for _, table := range productionLibraryTables { + snapshot.Tables = append(snapshot.Tables, newTableSnapshot(table, rowsByTable[table])) } handles, err := libraryResourceHandles(bodies) if err != nil { diff --git a/internal/releaseadapter/v071/library_export.go b/internal/releaseadapter/v071/library_export.go index 462c7b5..891357c 100644 --- a/internal/releaseadapter/v071/library_export.go +++ b/internal/releaseadapter/v071/library_export.go @@ -81,13 +81,17 @@ func ExportLibraryWithReleases(ctx context.Context, db librarySnapshotDB, identi // The latter includes Wiki manual pages, which document bundles used to miss. var bodies []json.RawMessage for _, table := range snapshot.Tables { + if table.Name == "resource_bindings" || table.Name == "knowledge_tag_relations" { + continue + } bodies = append(bodies, table.Rows...) } handles, err := libraryResourceHandles(bodies) if err != nil { return PostgresSnapshot{}, err } - resources, err := exporter.queryRows(ctx, libraryResourcesSQL, identity.TenantID, identity.KnowledgeBaseID, handles) + resources, err := scopedResourceRows(ctx, &exporter, identity, false, bodies, + snapshot.Table("resource_bindings").Rows) if err != nil { return PostgresSnapshot{}, err } @@ -151,4 +155,48 @@ var libraryContentQueries = []struct{ name, sql string }{ {"wiki_log_entries", `SELECT to_jsonb(w) FROM wiki_log_entries w WHERE w.tenant_id=$1 AND w.knowledge_base_id=$2 ORDER BY w.id`}, } -const libraryResourcesSQL = `SELECT to_jsonb(r) FROM resources r WHERE r.tenant_id=$1 AND r.deleted_at IS NULL AND (r.handle=ANY($3::text[]) OR EXISTS (SELECT 1 FROM resource_bindings b WHERE b.resource_id=r.id AND b.tenant_id=$1 AND ((b.owner_type='knowledge' AND EXISTS (SELECT 1 FROM knowledges k WHERE k.id=b.owner_id AND k.tenant_id=$1 AND k.knowledge_base_id=$2 AND k.deleted_at IS NULL)) OR (b.owner_type='chunk' AND EXISTS (SELECT 1 FROM chunks c JOIN knowledges k ON k.id=c.knowledge_id AND k.tenant_id=c.tenant_id AND k.knowledge_base_id=c.knowledge_base_id WHERE c.id=b.owner_id AND c.tenant_id=$1 AND c.knowledge_base_id=$2 AND c.deleted_at IS NULL AND k.deleted_at IS NULL))))) ORDER BY r.id` +// F01: resources were previously selected by an OR over a per-row EXISTS that +// re-scanned every body table, so a small KB in a large tenant paid for all +// unrelated resources. The caller now extracts the exact binding ids and body +// handles first; both are indexed identities, never filename guesses. +const libraryResourcesSQL = `SELECT to_jsonb(r) FROM resources r +WHERE r.tenant_id=$1 AND ($4::boolean OR r.deleted_at IS NULL) + AND (r.id=ANY($2::text[]) OR r.handle=ANY($3::text[])) +ORDER BY r.id` + +// scopedResourceRows is the single F01 read path: binding ids and body handles +// are extracted once from already scoped rows, then both are resolved through +// indexed resource identities. The old per-resource EXISTS/position filter +// rescanned every body table for every tenant resource. +func scopedResourceRows(ctx context.Context, exporter *PostgresExporter, identity PostgresIdentity, + includeDeleted bool, bodies, bindings []json.RawMessage) ([]json.RawMessage, error) { + handles, err := libraryResourceHandles(bodies) + if err != nil { + return nil, err + } + ids, err := libraryBindingResourceIDs(bindings) + if err != nil { + return nil, err + } + return exporter.queryRows(ctx, libraryResourcesSQL, identity.TenantID, ids, handles, includeDeleted) +} + +// libraryBindingResourceIDs reads the exact resource ids owned by the already +// scoped resource_bindings rows. It does not infer ownership from names. +func libraryBindingResourceIDs(rows []json.RawMessage) ([]string, error) { + seen := map[string]bool{} + ids := make([]string, 0, len(rows)) + for _, raw := range rows { + var row struct { + ResourceID string `json:"resource_id"` + } + if err := json.Unmarshal(raw, &row); err != nil || row.ResourceID == "" { + return nil, errors.New("invalid scoped resource binding") + } + if !seen[row.ResourceID] { + seen[row.ResourceID] = true + ids = append(ids, row.ResourceID) + } + } + return ids, nil +} diff --git a/internal/releaseadapter/v071/library_production_target.go b/internal/releaseadapter/v071/library_production_target.go index c985429..fc9670a 100644 --- a/internal/releaseadapter/v071/library_production_target.go +++ b/internal/releaseadapter/v071/library_production_target.go @@ -301,23 +301,61 @@ func identityExpr(table string) string { } func (t *ProductionLibraryTarget) tableState(ctx context.Context, table string) (int64, string, error) { - rows, err := t.pool.Query(ctx, fmt.Sprintf(`SELECT %s FROM %s WHERE %s ORDER BY 1`, - "("+identityExpr(table)+")::text", table, scopeWhere(table)), t.scope.TenantID, t.scope.KnowledgeBaseID) - if err != nil { - return 0, "", fmt.Errorf("读取生产 %s 失败", table) + identity := PostgresIdentity{TenantID: t.scope.TenantID, KnowledgeBaseID: t.scope.KnowledgeBaseID} + var rows []json.RawMessage + if table == "resources" { + // F01: derive exact references once; do not run the per-resource body + // scan used by the legacy scope predicate. + exporter := PostgresExporter{db: t.pool} + var bodies, bindings []json.RawMessage + for _, name := range productionLibraryTables { + if name == "resources" { + continue + } + items, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`, + name, name, scopeWhere(name)), identity.TenantID, identity.KnowledgeBaseID) + if err != nil { + return 0, "", fmt.Errorf("读取生产 %s 失败", table) + } + if name != "resource_bindings" && name != "knowledge_tag_relations" { + bodies = append(bodies, items...) + } + if name == "resource_bindings" { + bindings = items + } + } + items, err := scopedResourceRows(ctx, &exporter, identity, true, bodies, bindings) + if err != nil { + 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`, + "("+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() { + ids := make([]string, 0, len(rows)) + for _, raw := range rows { var id string - if err := rows.Scan(&id); err != nil { - return 0, "", err + 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 + } } ids = append(ids, id) } - if err := rows.Err(); err != nil { - return 0, "", err - } 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 new file mode 100644 index 0000000..9e60938 --- /dev/null +++ b/internal/releaseadapter/v071/library_resource_scope_f01_test.go @@ -0,0 +1,113 @@ +package v071 + +import ( + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// 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. +func TestLibraryResourceClosureIsBoundedByTargetReferences(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_") || os.Getenv("KG_LIBRARY_TEST_ISOLATED") != "1" { + t.Fatal("isolated database required") + } + ctx := context.Background() + db, err := pgxpool.NewWithConfig(ctx, cfg) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err = db.Exec(ctx, `DROP SCHEMA public CASCADE; CREATE SCHEMA public`); err != nil { + t.Fatal(err) + } + if _, err = db.Exec(ctx, libraryTestSchema); err != nil { + t.Fatal(err) + } + if _, err = db.Exec(ctx, `CREATE INDEX resources_handle_f01 ON resources(handle); CREATE INDEX resources_pkey_f01 ON resources(id);`); err != nil { + t.Fatal(err) + } + const unrelated = 20000 + if _, err = db.Exec(ctx, `INSERT INTO resources(id,handle,tenant_id,physical_path,deleted_at) + SELECT 'unrelated-'||i, lpad(i::text,22,'0'), 10000, 'unrelated/'||i, NULL FROM generate_series(1,$1) i`, unrelated); err != nil { + t.Fatal(err) + } + // A body-only reference must still be found without a binding row. + if _, err = db.Exec(ctx, `INSERT INTO resources(id,handle,tenant_id,physical_path,deleted_at) + VALUES ('res-body','BBBBBBBBBBBBBBBBBBBBBB',10000,'body.txt',NULL); + UPDATE chunks SET content='see resource://BBBBBBBBBBBBBBBBBBBBBB' WHERE id='chunk-safe'`); err != nil { + t.Fatal(err) + } + identity := PostgresIdentity{TenantID: 10000, KnowledgeBaseID: "kb-safe"} + + start := time.Now() + oldRows, err := db.Query(ctx, fmt.Sprintf(`SELECT to_jsonb(resources)::text FROM resources WHERE %s`, scopeWhere("resources")), + identity.TenantID, identity.KnowledgeBaseID) + if err != nil { + t.Fatal(err) + } + oldIDs := []string{} + for oldRows.Next() { + var raw string + if err := oldRows.Scan(&raw); err != nil { + oldRows.Close() + t.Fatal(err) + } + var row struct { + ID string `json:"id"` + } + if err := json.Unmarshal([]byte(raw), &row); err != nil { + oldRows.Close() + t.Fatal(err) + } + oldIDs = append(oldIDs, row.ID) + } + oldRows.Close() + oldElapsed := time.Since(start) + + start = time.Now() + snapshot, err := ExportLibrary(ctx, db, identity) + if err != nil { + t.Fatal(err) + } + newElapsed := time.Since(start) + newIDs := []string{} + for _, raw := range snapshot.Table("resources").Rows { + var row struct { + ID string `json:"id"` + } + if err := json.Unmarshal(raw, &row); err != nil { + t.Fatal(err) + } + newIDs = append(newIDs, row.ID) + } + if fmt.Sprint(oldIDs) != fmt.Sprint(newIDs) { + t.Fatalf("resource closure changed: old=%v new=%v", oldIDs, newIDs) + } + var bodies []json.RawMessage + for _, table := range snapshot.Tables { + bodies = append(bodies, table.Rows...) + } + handles, handleErr := libraryResourceHandles(bodies) + t.Logf("old_ids=%v new_ids=%v handles=%v handle_err=%v", oldIDs, newIDs, handles, handleErr) + 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) + if newElapsed >= oldElapsed { + t.Fatalf("F01 path did not improve: old=%s new=%s", oldElapsed, newElapsed) + } +} diff --git a/internal/releaseadapter/v071/library_verify_content.go b/internal/releaseadapter/v071/library_verify_content.go index a5c4eda..c2bc93e 100644 --- a/internal/releaseadapter/v071/library_verify_content.go +++ b/internal/releaseadapter/v071/library_verify_content.go @@ -68,22 +68,52 @@ func joinLines(items []string) string { // 归档没有的列(生产默认值/后续新增列)不参与比对,避免"我们没管的列"造成假失败, // 但归档里有的每一列都必须逐字一致。 func (t *ProductionLibraryTarget) productionTableContent(ctx context.Context, table string, columns map[string]bool) (int64, string, error) { - // 不加表别名:scopeWhere(resources) 里引用了 resources.id,别名会让它失效。 - rows, err := t.pool.Query(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`, table, table, scopeWhere(table)), - t.scope.TenantID, t.scope.KnowledgeBaseID) - if err != nil { - return 0, "", fmt.Errorf("读取生产 %s 内容失败: %w", table, err) + identity := PostgresIdentity{TenantID: t.scope.TenantID, KnowledgeBaseID: t.scope.KnowledgeBaseID} + exporter := PostgresExporter{db: t.pool} + var rows []json.RawMessage + if table == "resources" { + // F01: verification must use the same bounded reference set as capture; + // the legacy per-resource body scan made post-sync verification minutes + // long on a small target inside a large tenant. + var bodies, bindings []json.RawMessage + for _, name := range productionLibraryTables { + if name == "resources" { + continue + } + items, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`, + name, name, scopeWhere(name)), identity.TenantID, identity.KnowledgeBaseID) + if err != nil { + return 0, "", fmt.Errorf("读取生产 %s 内容失败: %w", table, err) + } + if name != "resource_bindings" && name != "knowledge_tag_relations" { + bodies = append(bodies, items...) + } + if name == "resource_bindings" { + bindings = items + } + } + items, err := scopedResourceRows(ctx, &exporter, identity, true, bodies, bindings) + if err != nil { + return 0, "", fmt.Errorf("读取生产 %s 内容失败: %w", table, err) + } + rows = items + } else { + items, err := exporter.queryRows(ctx, fmt.Sprintf(`SELECT to_jsonb(%s) FROM %s WHERE %s`, table, table, scopeWhere(table)), + identity.TenantID, identity.KnowledgeBaseID) + if err != nil { + return 0, "", fmt.Errorf("读取生产 %s 内容失败: %w", table, err) + } + rows = items } - defer rows.Close() - digests := make([]string, 0, 64) - for rows.Next() { - var raw map[string]json.RawMessage - if err := rows.Scan(&raw); err != nil { + digests := make([]string, 0, len(rows)) + for _, raw := range rows { + var value map[string]json.RawMessage + if err := json.Unmarshal(raw, &value); err != nil { return 0, "", err } projected := make(map[string]json.RawMessage, len(columns)) for key := range columns { - if value, ok := raw[key]; ok { + if value, ok := value[key]; ok { projected[key] = value } } @@ -93,9 +123,6 @@ func (t *ProductionLibraryTarget) productionTableContent(ctx context.Context, ta } digests = append(digests, digest) } - if err := rows.Err(); err != nil { - return 0, "", err - } return int64(len(digests)), combineRowDigests(digests), nil } diff --git a/web/src/BackupScheduleSection.tsx b/web/src/BackupScheduleSection.tsx index 3ba67a8..62d5684 100644 --- a/web/src/BackupScheduleSection.tsx +++ b/web/src/BackupScheduleSection.tsx @@ -127,10 +127,17 @@ export function BackupScheduleView(props: BackupScheduleViewProps) {
{scriptLine}
主机 cron
{schedule.host_cron_service ? `服务 ${schedule.host_cron_service}` : '—'}
-
最近一次备份
+
下次计划(主机读回)
+
{schedule.host_next_run || '未读取(不以历史下发时间推算)'}
+
最近一次运行
{schedule.last_run_stamp || '—'} {runMoment(schedule) ? ` · ${runMoment(schedule)}` : ''} + {schedule.last_run_stamp + ? schedule.last_run_failed_databases.length === 0 && !schedule.last_run_error + ? ' · 成功' + : ' · 有失败/读取错误' + : ''}
备份耗时 / 体积
diff --git a/web/src/ReleaseTargets.test.tsx b/web/src/ReleaseTargets.test.tsx index 66766d7..cd59f74 100644 --- a/web/src/ReleaseTargets.test.tsx +++ b/web/src/ReleaseTargets.test.tsx @@ -99,6 +99,26 @@ const overview: LibraryOverview = { ], }, authorization: { state: 'none', reason: '没有与目标快照匹配的有效发布授权' }, + // 测试夹具把两份归档登记为测试封存:默认筛选「已登记版本」会隐藏未登记文件, + // 但仍可由用户切到「全部归档」显式查看。 + versions: [ + { + id: 'ver-internal', + kind: 'test_sealed', + snapshot_id: internalSnapshot.snapshot_id, + version_label: '封存 f992d64ea45b', + source_job_id: '', + created_at: internalSnapshot.created_at, + }, + { + id: 'ver-external', + kind: 'test_sealed', + snapshot_id: externalSnapshot.snapshot_id, + version_label: '封存 01343b0b310b', + source_job_id: '', + created_at: externalSnapshot.created_at, + }, + ], capture_available: true, now: '2026-10-28T03:40:00Z', } @@ -309,9 +329,17 @@ describe('ReleaseTargetsView(确认稿布局)', () => { }) it('抽屉只展示当前选中库的历史', () => { - const html = render({ drawerOpen: true, scope: 'external' }) - expect(html).toContain('快照 01343b0b310b') - expect(html).not.toContain('快照 f992d64ea45b') + const html = render({ + drawerOpen: true, + scope: 'external', + overviews: { + internal: overview, + external: { ...overview, scope: 'external', versions: [overview.versions![1]] }, + }, + }) + const drawer = html.slice(html.indexOf('aria-label="版本与快照"')) + expect(drawer).toContain('快照 01343b0b310b') + expect(drawer).not.toContain('快照 f992d64ea45b') }) // E3:归档里同时躺着测试封存、目标基线与每次预备份。旧页面把每一行都写"测试封存", @@ -570,7 +598,86 @@ describe('ReleaseTargetsView(确认稿布局)', () => { expect(html).toContain('按版本恢复') expect(html).toContain('按版本恢复已完成') const stageBar = html.slice(html.indexOf('aria-label="作业阶段"'), html.indexOf('rel-steps-note')) - expect(stageBar).toContain('生产已回到该快照') + expect(stageBar).toContain('目标已回到该快照') + }) + + it('恢复验证使用自己的阶段,不把登记证据说成新版本生效', () => { + const verifyJob: LibraryJobRecord = { + ...persistedSeal, + id: 'libverify-internal-1', + kind: 'verify', + state: 'running', + stage: 'restore', + stages: [ + { + stage: 'capture', + state: 'succeeded', + total: null, + completed: null, + bytes_total: null, + bytes_done: null, + detail: '已捕获', + updated_at: '2026-11-05T01:00:00Z', + }, + { + stage: 'verify', + state: 'succeeded', + total: null, + completed: null, + bytes_total: null, + bytes_done: null, + detail: '基线校验通过', + updated_at: '2026-11-05T01:01:00Z', + }, + { + stage: 'canary', + state: 'succeeded', + total: null, + completed: null, + bytes_total: null, + bytes_done: null, + detail: 'canary 通过', + updated_at: '2026-11-05T01:02:00Z', + }, + { + stage: 'restore', + state: 'running', + total: null, + completed: null, + bytes_total: null, + bytes_done: null, + detail: '正在恢复', + updated_at: '2026-11-05T01:03:00Z', + }, + { + stage: 'finalize', + state: 'pending', + total: null, + completed: null, + bytes_total: null, + bytes_done: null, + detail: '', + updated_at: '2026-11-05T01:04:00Z', + }, + ], + } + const html = render({ overview: { ...overview, job: verifyJob, jobs: [verifyJob] } }) + expect(html).toContain('恢复验证阶段') + expect(html).toContain('捕获目标') + expect(html).toContain('登记验证证据') + expect(html).not.toContain('新版本生效') + }) + + it('快照抽屉默认隐藏未登记归档,并给出全部归档入口', () => { + const html = render({ + drawerOpen: true, + overview: { ...overview, versions: [] }, + }) + const drawer = html.slice(html.indexOf('aria-label="版本与快照"')) + expect(drawer).toContain('已登记版本') + expect(drawer).toContain('全部归档(含未登记)') + expect(drawer).not.toContain('快照 f992d64ea45b') + expect(drawer).toContain('当前筛选下没有已登记版本') }) it('同步中布局显示四类数据卡与明细,且不编造百分比', () => { @@ -579,10 +686,13 @@ describe('ReleaseTargetsView(确认稿布局)', () => { overview: { ...overview, job: persistedSeal, jobs: [persistedSeal] }, }) expect(html).toContain('测试库封存作业') - expect(html).toContain('数据库 · 文档与分块') + // F06: 运行中主按钮只能查看当前任务,不能再次启动。 + expect(html).toContain('查看当前任务') + expect(html).not.toContain('▶ 启动同步') + expect(html).toContain('数据库 · 存储行') expect(html).toContain('原文件 · 图片 · 附件') expect(html).toContain('检索索引 · 向量') - expect(html).toContain('Wiki · 完整快照') + expect(html).toContain('Wiki · 存储行') expect(html).toContain('同步明细') // 唯一允许出现的百分比是免责声明里的「100% ≠ 作业完成」,没有任何进度主张 expect(html.match(/[1-9]\d*%/g) ?? []).toEqual(['100%']) diff --git a/web/src/ReleaseTargets.tsx b/web/src/ReleaseTargets.tsx index 4c6013f..a9fa67f 100644 --- a/web/src/ReleaseTargets.tsx +++ b/web/src/ReleaseTargets.tsx @@ -135,6 +135,8 @@ interface ViewProps { onGrantRestore: () => void onRestore: () => void overview: LibraryOverview | null + /** F09: 每个 scope 的独立观测;不能拿当前 scope 的 overview 重算另一个页签。 */ + overviews?: Record maintenance: LibraryMaintenanceView | null // 维护记录里 owner_job_id 对应的作业详情;缺失就是“未读取”,不能用 overview.job 顶替。 maintenanceOwnerJob?: LibraryJobLookup @@ -153,8 +155,10 @@ function scopeView( const history = (page?.snapshots ?? []).filter((item) => item.scope === scope) const scoped = jobs.filter((job) => job.scope === scope) const persisted = overview?.jobs ?? [] + // F02/F06: 顶部阶段必须跟当前在飞作业;只有没有在飞作业时才显示最近终态。 + // 同一个 overview 里可能有刚完成的封存和正在跑的同步,不能用"最近一条"覆盖当前阶段。 const job = - overview?.job ?? persisted.find((item) => item.state === 'pending' || item.state === 'running') ?? persisted[0] + persisted.find((item) => item.state === 'pending' || item.state === 'running') ?? overview?.job ?? persisted[0] return { scope, // 候选只认接口给的 test_sealed(来自版本登记):归档里还有生产预备份与 @@ -598,6 +602,39 @@ export function wikiContributionLines(diff: LibraryOverview['diff'] | undefined) function StageBar({ view }: { view: ScopeView }) { const job = view.job + if (job?.kind === 'verify') { + const labels: Record = { + capture: '捕获目标', + verify: '基线校验', + canary: '写入并核对 canary', + restore: '按预备份恢复', + finalize: '登记验证证据', + } + return ( + <> +
    + {Object.entries(labels).map(([name, label], index) => { + const row = job.stages.find((item) => item.stage === name) + const state = row ? toState(row.state as 'succeeded' | 'running' | 'failed' | 'pending') : 'idle' + return ( +
  1. + + {state === 'done' ? '✓' : state === 'live' ? '●' : state === 'fail' ? '!' : '○'} + + {label} + {row?.detail || (state === 'idle' ? '未开始' : '')} + {index < 4 ? → : null} +
  2. + ) + })} +
+

+ 作业 {job.id} · 恢复验证 · {jobStateText(job)} + {job.error ? ` · ${job.error}` : ''};完成只表示本次恢复验证证据已登记,不会登记新版本。 +

+ + ) + } const sealJob = job?.kind === 'seal' ? job : undefined // 同步、恢复、建立基线共用同一串阶段(预备份 → 数据四类 → 校验 → 登记版本)。 // 只按"sync"找阶段会让刚跑完的恢复/基线在页面上显示成"本次作业尚未开始"。 @@ -662,16 +699,16 @@ function StageBar({ view }: { view: ScopeView }) { hint: verifyStage ? (verifyStage.state === 'succeeded' ? '校验通过' : '未完成') : '未接入', }, { - label: '新版本生效', + label: writeJob?.kind === 'restore' ? '目标恢复生效' : '生产生效', state: writeJob && writeJob.state === 'succeeded' ? 'done' : 'idle', hint: writeJob && writeJob.state === 'succeeded' ? writeJob.kind === 'restore' - ? '生产已回到该快照' + ? '目标已回到该快照,恢复后复核通过' : writeJob.kind === 'baseline' ? '目标基线已登记(目标内容未改)' : '生产已生效' - : '未接入', + : '未开始', }, ] return ( @@ -1027,11 +1064,34 @@ 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 + const breakdown = (keys: [string, string][]) => + sealed ? keys.map(([key, label]) => `${label} ${sealed.totals[key] ?? 0}`).join(' · ') : '' const cards = [ - { title: '数据库 · 文档与分块', row: stage('database'), axis: '文档 / 分块' }, - { title: '原文件 · 图片 · 附件', row: stage('resources'), axis: '对象 / 字节' }, - { title: '检索索引 · 向量', row: stage('index'), axis: '索引文档' }, - { title: 'Wiki · 完整快照', row: stage('wiki'), axis: 'Wiki 页' }, + // F11: these totals are storage rows, not business document/page counts. + { + title: '数据库 · 存储行', + row: stage('database'), + axis: '行(文档/分块/标签/关系)', + counts: breakdown([ + ['documents', '文档'], + ['chunks', '分块'], + ['resources', '资源'], + ['bindings', '绑定'], + ]), + }, + { title: '原文件 · 图片 · 附件', row: stage('resources'), axis: '对象 / 字节', counts: '' }, + { title: '检索索引 · 向量', row: stage('index'), axis: '索引文档', counts: '' }, + { + title: 'Wiki · 存储行', + row: stage('wiki'), + axis: '页面 / 目录 / 日志', + counts: breakdown([ + ['wiki_pages', '页面'], + ['wiki_folders', '目录'], + ['wiki_log_entries', '日志'], + ]), + }, ].map((card) => ({ ...card, // 只有真实的 total 才画进度;没有 total 就是"未知总量",绝不按 0 或 100% 画。 @@ -1044,7 +1104,7 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO return ( <> -
+

{sealJob ? '测试库封存作业' : dataJob ? `${jobKindText(dataJob.kind)}作业` : '本次作业'} @@ -1054,7 +1114,7 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO : dataJob?.kind === 'baseline' ? `目标基线 ${dataJob.target_snapshot_id.slice(0, 12) || '(本次捕获后登记)'}` : `测试封存 ${view.overview?.test_sealed?.snapshot_id.slice(0, 12) ?? '未接入'}`}{' '} - → 生产当前{' '} + → 目标当前{' '} {view.overview?.production.state === 'known' && view.overview.production.version ? view.overview.production.version.version_label || @@ -1117,15 +1177,20 @@ function ProgressBody({ view, now, onOpen }: { view: ScopeView; now: number; onO

{card.count} · {card.axis}

+ {card.counts ?

封存快照分项:{card.counts}

: null}

{card.row?.detail ? card.row.detail - : dataJob - ? '该分项还没有上报数据' - : '生产同步执行链未接通,分项不会产生数据'} + : card.row + ? card.row.total != null + ? `已完成 ${card.row.completed ?? card.row.total} / ${card.row.total} 条存储行` + : '该分项已完成,执行器未提供可核对总量' + : dataJob + ? '本次作业没有产生该分项阶段记录' + : '目标同步执行链未接通,分项不会产生数据'}

))} @@ -1242,9 +1307,34 @@ function Drawer({ onGrantRestore, onRestore, }: DrawerProps) { - if (!open) return null const listing = view.history + const [kindFilter, setKindFilter] = useState<'registered' | 'all' | 'test' | 'production'>('registered') + const [historyPage, setHistoryPage] = useState(0) + const [confirmRestore, setConfirmRestore] = useState(false) + const selectedRef = useRef(null) + useEffect(() => { + setKindFilter('registered') + setHistoryPage(0) + setConfirmRestore(false) + }, [view.scope, open]) + useEffect(() => { + if (expanded) selectedRef.current?.scrollIntoView?.({ block: 'nearest' }) + }, [expanded]) + const versionLabelOf = (snapshot: LibrarySnapshot) => + snapshotVersionLabel(view.overview?.versions, snapshot.snapshot_id) + const filtered = listing.filter((snapshot) => { + const label = versionLabelOf(snapshot) + if (kindFilter === 'all') return true + if (kindFilter === 'test') return label.includes('测试封存') + if (kindFilter === 'production') return label.includes('生产生效') + return !label.startsWith('未登记') + }) + const pageSize = 8 + const pageCount = Math.max(1, Math.ceil(filtered.length / pageSize)) + const currentPage = Math.min(historyPage, pageCount - 1) + const pageItems = filtered.slice(currentPage * pageSize, currentPage * pageSize + pageSize) const selected = listing.find((item) => item.snapshot_id === expanded) + if (!open) return null return (
+

+ ) : null} {restore?.allowed ? (

条件已满足:生产写入闸口打开、这份快照有恢复授权 @@ -1487,6 +1640,7 @@ export function ReleaseTargetsView(props: ViewProps) { page, jobs, overview, + overviews, loading, error, notice, @@ -1516,10 +1670,17 @@ export function ReleaseTargetsView(props: ViewProps) { onCapture, onRecover, } = props - const internal = scopeView(page, jobs, 'internal', overview?.scope === 'internal' ? overview : null) - const external = scopeView(page, jobs, 'external', overview?.scope === 'external' ? overview : null) + // A response with a different scope is not an observation for this tab. + const scopedOverview = (target: Scope): LibraryOverview | null => { + const candidate = overviews?.[target] ?? overview + return candidate?.scope === target ? candidate : null + } + const internal = scopeView(page, jobs, 'internal', scopedOverview('internal')) + const external = scopeView(page, jobs, 'external', scopedOverview('external')) const view = scope === 'internal' ? internal : external const active = Boolean(view.activeJob) || view.job?.state === 'running' || view.job?.state === 'pending' + // F06: 运行中禁止再次启动,也禁止把主按钮继续显示成可启动的绿色动作。 + const runningJob = active && view.job ? view.job : undefined // 三类版本严格分开:target 是测试封存,production 是生产权威版本,prebackup 是本次预备份。 const target = view.overview?.test_sealed // 没有 overview 时不能补一个 {state:'unknown'} 再当成"已探测且非空": @@ -1608,20 +1769,33 @@ export function ReleaseTargetsView(props: ViewProps) {

+

+ 目标实例 {view.overview?.target_instance || view.overview?.executor?.instance || '未读取'} · 项目{' '} + {view.overview?.target_project || view.overview?.executor?.project || '未读取'} · 知识库{' '} + {view.overview?.target_knowledge_base_id || target?.knowledge_base_id || '未读取'}({scopeName(scope)}) +

- 生产当前{' '} + 目标当前{' '} {productionClass === 'known' && production?.version ? ( {production.version.version_label || `生效 ${production.version.snapshot_id.slice(0, 12)}`} @@ -2055,6 +2229,7 @@ export function ReleaseTargets() { page={page} jobs={jobs} overview={overviews[scope]} + overviews={overviews} loading={loading} error={error} notice={notice} @@ -2062,6 +2237,8 @@ export function ReleaseTargets() { onScope={(next) => { setScope(next) setExpandedSnapshot(null) + // F10: 旧操作通知属于旧作业/旧对象,切范围后不得继续挂在页顶。 + setNotice(null) }} drawerOpen={drawerOpen} onDrawer={setDrawerOpen} @@ -2085,6 +2262,8 @@ export function ReleaseTargets() { }} verification={{ onAccepted: (jobId) => { + // F10: 新验证作业开始时清掉上一次封存/同步的旧通知。 + setNotice(null) setActiveVerifyJob(jobId) void load() }, diff --git a/web/src/RestoreVerificationSection.mounted.test.tsx b/web/src/RestoreVerificationSection.mounted.test.tsx index c749835..d4ad4f7 100644 --- a/web/src/RestoreVerificationSection.mounted.test.tsx +++ b/web/src/RestoreVerificationSection.mounted.test.tsx @@ -152,6 +152,8 @@ describe('RestoreVerificationSection mounted (offline fixtures)', () => { ) }) await press('跑一次恢复验证(维护隔离)') + expect(onAccepted).not.toHaveBeenCalled() + await press('确认并投递') expect(onAccepted).toHaveBeenCalledWith('libverify-internal-1') expect(container.textContent).toContain('验证执行中') await press('刷新验证状态') @@ -179,6 +181,9 @@ describe('RestoreVerificationSection mounted (offline fixtures)', () => { await act(async () => { ;[...container.querySelectorAll('button')].find((item) => item.textContent?.includes('跑一次恢复验证'))!.click() }) + await act(async () => { + ;[...container.querySelectorAll('button')].find((item) => item.textContent === '确认并投递')!.click() + }) await act(async () => { root.render( diff --git a/web/src/RestoreVerificationSection.test.tsx b/web/src/RestoreVerificationSection.test.tsx index 1f729e3..da3be68 100644 --- a/web/src/RestoreVerificationSection.test.tsx +++ b/web/src/RestoreVerificationSection.test.tsx @@ -72,17 +72,46 @@ describe('RestoreVerificationView', () => { status: 'passed', snapshot_id: '0f9b39a16738', job_id: 'libverify-internal-1', + report: { status: 'passed', checks: ['database', 'index', 'objects', 'wiki'] }, started_at: '2026-11-03T01:00:00Z', finished_at: '2026-11-03T01:02:00Z', }, }) ) expect(html).toContain('已验证(当前目标)') + // F03: a passed record with empty API reason must not print "还没有读到验证状态". + expect(html).toContain('当前目标的最近一次真实恢复验证已通过') + expect(html).not.toContain('还没有读到验证状态') + expect(html).toContain('查看本次恢复验证报告') expect(html).toContain('0f9b39a16738') expect(html).toContain('a8e9c8ad-411c-4c53-8421-599d29e9b179') expect(html).toContain('weknora_test_chunks') }) + it('高影响恢复验证先给出目标/影响/恢复方式的一次确认', () => { + const html = renderToStaticMarkup( + undefined} + onConfirm={() => undefined} + onCancel={() => undefined} + onReload={() => undefined} + /> + ) + expect(html).toContain('恢复验证影响确认') + expect(html).toContain('目标:对内知识库 · weknora-test') + expect(html).toContain('短暂停止目标写入者并关闭入口') + expect(html).toContain('确认并投递') + expect(html).toContain('取消') + }) + it('失败时把失败原因写出来,按钮仍可重试', () => { const html = render( view({ diff --git a/web/src/RestoreVerificationSection.tsx b/web/src/RestoreVerificationSection.tsx index c53d51f..7524aa8 100644 --- a/web/src/RestoreVerificationSection.tsx +++ b/web/src/RestoreVerificationSection.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useRef, useState } from 'react' import { enqueueLibraryVerification, fetchLibraryVerification, + verificationReasonText, verificationStatusText, verificationTargetLines, } from './restoreVerification' @@ -26,6 +27,9 @@ export interface RestoreVerificationViewProps { stale: boolean onRun: () => void onReload: () => void + confirming?: boolean + onConfirm?: () => void + onCancel?: () => void } const stampText = (value: string): string => { @@ -35,7 +39,20 @@ const stampText = (value: string): string => { } export function RestoreVerificationView(props: RestoreVerificationViewProps) { - const { view, scope, isAdmin, pending, error, notice, stale, onRun, onReload } = props + const { + view, + scope, + isAdmin, + pending, + error, + notice, + stale, + onRun, + onReload, + confirming = false, + onConfirm, + onCancel, + } = props const status = verificationStatusText(view, stale) const latest = view?.latest ?? null const running = Boolean(view?.running) @@ -58,7 +75,7 @@ export function RestoreVerificationView(props: RestoreVerificationViewProps) { ))} ) : null} -

{view?.reason || '还没有读到验证状态。'}

+

{verificationReasonText(view, stale)}

{latest ? (

最近一次:{latest.status === 'passed' ? '通过' : '未通过'} · 快照 {latest.snapshot_id.slice(0, 12) || '—'} ·{' '} @@ -66,11 +83,37 @@ export function RestoreVerificationView(props: RestoreVerificationViewProps) {

) : null} {latest?.failure ?

未通过原因:{latest.failure}

: null} + {latest?.report ? ( +
+ 查看本次恢复验证报告 +
{JSON.stringify(latest.report, null, 2)}
+
+ ) : null} {error ?

{error}

: null} {notice ?

{notice}

: null} + {confirming ? ( +
+ 确认停写并执行恢复验证? +

+ 目标:{scope === 'internal' ? '对内知识库' : '对外知识库'} · {view?.target?.instance || '目标实例未配置'} +

+

+ 影响:短暂停止目标写入者并关闭入口,在目标写入 canary,再按预备份恢复并逐类复核;完成后按原运行状态收尾。 +

+

恢复方式:本次作业自己的预备份与写前镜像;不是测试封存,也不会登记新版本。

+
+ + +
+
+ ) : null}
{isAdmin ? ( - ) : ( @@ -109,6 +152,7 @@ export function RestoreVerificationSection({ const [pending, setPending] = useState(false) const [error, setError] = useState(null) const [notice, setNotice] = useState('') + const [confirming, setConfirming] = useState(false) const mounted = useRef(true) const generation = useRef(0) const viewScopeRef = useRef('') @@ -170,6 +214,7 @@ export function RestoreVerificationSection({ setStale(false) setError(null) setNotice('') + setConfirming(false) acceptedJob.current = '' sawRunning.current = false const controller = new AbortController() @@ -226,7 +271,13 @@ export function RestoreVerificationSection({ error={error} notice={notice} stale={stale} - onRun={onRun} + confirming={confirming} + onRun={() => setConfirming(true)} + onConfirm={() => { + setConfirming(false) + onRun() + }} + onCancel={() => setConfirming(false)} onReload={() => void reload(scope)} /> ) diff --git a/web/src/backupSchedule.ts b/web/src/backupSchedule.ts index b0a772f..6fd0c4a 100644 --- a/web/src/backupSchedule.ts +++ b/web/src/backupSchedule.ts @@ -18,6 +18,8 @@ export interface BackupSchedule { host_error: string host_schedule_enabled: boolean host_schedule_time: string + // 主机若没有返回真实 next-run,保持空串;页面不得用历史下发时间推算。 + host_next_run?: string host_keep_days: number host_keep_months: number host_cron_lines: string[] @@ -77,6 +79,7 @@ function scheduleProjection(item: Record): BackupSchedule { host_error: asString(item.host_error), 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_keep_days: asNumber(item.host_keep_days), host_keep_months: asNumber(item.host_keep_months), host_cron_lines: asStringList(item.host_cron_lines), diff --git a/web/src/librarySnapshots.ts b/web/src/librarySnapshots.ts index c2a62c1..981bb96 100644 --- a/web/src/librarySnapshots.ts +++ b/web/src/librarySnapshots.ts @@ -689,6 +689,9 @@ export interface LibraryOverview { capture_reason?: string // 执行器是 worker 的真实观测:指向哪个实例、何时观测、是否降级/在跑。 executor?: LibraryExecutorView + target_instance?: string + target_project?: string + target_knowledge_base_id?: string now: string } diff --git a/web/src/releaseTargets.css b/web/src/releaseTargets.css index 187e433..83d986b 100644 --- a/web/src/releaseTargets.css +++ b/web/src/releaseTargets.css @@ -672,3 +672,46 @@ .rel-wiki-pages li { font-variant-numeric: tabular-nums; } + +/* F04/F07/F08: 目标身份、历史筛选与分页、恢复验证报告。 */ +.rel-target-identity { + margin: 8px 0 0; + font-size: 12px; + color: var(--rel-muted); + word-break: break-all; +} +.rel-history-filters { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin: 4px 0 8px; +} +.rel-history-filters button { + font-size: 12px; + padding: 4px 10px; +} +.rel-history-filters button.active { + color: #168b73; + background: #ebf8f3; + border-color: #a6d7c8; +} +.rel-history-pager { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin: 8px 0; + font-size: 12px; + color: var(--rel-muted); +} +.rel-report pre { + max-height: 260px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; + font-size: 11px; + background: #f7fafc; + border: 1px solid #e6edf2; + border-radius: 7px; + padding: 8px; +} diff --git a/web/src/restoreVerification.ts b/web/src/restoreVerification.ts index 4502e6b..6c11237 100644 --- a/web/src/restoreVerification.ts +++ b/web/src/restoreVerification.ts @@ -11,6 +11,7 @@ export interface LibraryVerificationRecord { snapshot_id: string job_id: string failure?: string + report?: Record started_at: string finished_at: string } @@ -76,6 +77,7 @@ export function parseVerificationRecord(value: unknown): LibraryVerificationReco snapshot_id: text(value.snapshot_id), job_id: text(value.job_id), failure: text(value.failure) || undefined, + report: record(value.report) ? value.report : undefined, started_at: text(value.started_at), finished_at: text(value.finished_at), } @@ -172,3 +174,16 @@ export function verificationStatusText(view: LibraryVerificationView | null, sta if (view.latest) return view.latest.status === 'failed' ? '最近一次未通过' : '未通过' return '还没有验证记录' } + +// F03: when the API has a passed record, its reason is empty by design. The +// panel must not fall back to "还没有读到验证状态" and contradict the green +// status. This also keeps loading/error/never-verified distinct. +export function verificationReasonText(view: LibraryVerificationView | null, stale = false): string { + if (!view) return '还没有读到验证状态' + if (stale) return '刷新失败:这是上次成功读取的证据,当前目标是否仍可恢复未知' + if (view.running) return view.reason || `验证作业 ${view.job_id || ''} 正在执行(${view.stage || '执行中'})` + if (view.verified) + return `当前目标的最近一次真实恢复验证已通过${view.latest ? `(快照 ${view.latest.snapshot_id.slice(0, 12)})` : ''}` + if (view.latest) return view.reason || view.latest.failure || '最近一次恢复验证未通过' + return view.reason || '还没有验证记录' +}