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) {
+ 作业 {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 ( <> -{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+ 目标实例 {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(
{view?.reason || '还没有读到验证状态。'} {verificationReasonText(view, stale)}
最近一次:{latest.status === 'passed' ? '通过' : '未通过'} · 快照 {latest.snapshot_id.slice(0, 12) || '—'} ·{' '}
@@ -66,11 +83,37 @@ export function RestoreVerificationView(props: RestoreVerificationViewProps) {
未通过原因:{latest.failure} {error} {notice}
+ 目标:{scope === 'internal' ? '对内知识库' : '对外知识库'} · {view?.target?.instance || '目标实例未配置'}
+
+ 影响:短暂停止目标写入者并关闭入口,在目标写入 canary,再按预备份恢复并逐类复核;完成后按原运行状态收尾。
+ 恢复方式:本次作业自己的预备份与写前镜像;不是测试封存,也不会登记新版本。查看本次恢复验证报告
+ {JSON.stringify(latest.report, null, 2)}
+