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