Add biz_state Phase2 cutover compare (templates, port maps, jobs, UI).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-17 16:25:52 +08:00
parent 4fa1b9b4dc
commit 1e2cc98d49
15 changed files with 1525 additions and 1 deletions

View file

@ -448,6 +448,13 @@ def _run_collect_session(
else:
batch.status = "success"
db.commit()
if batch.status in ("success", "partial"):
try:
from .compare_service import try_auto_compare_for_task
try_auto_compare_for_task(db, task_id, batch_id)
except Exception:
_log.exception("biz_state auto compare hook failed task=%s", task_id)
_purge_old_batches(db, task_id=task_id, keep=retention)
finally:

View file

@ -0,0 +1,166 @@
"""Pure compare engine: same-table row diff with optional port mapping on before side."""
from __future__ import annotations
from typing import Any
def apply_port_map(
row: dict[str, Any],
*,
iface_fields: list[str],
port_map: dict[str, str],
) -> dict[str, Any]:
out = dict(row)
for f in iface_fields:
val = str(out.get(f) or "").strip()
if val and val in port_map:
out[f] = port_map[val]
return out
def row_key(row: dict[str, Any], key_fields: list[str]) -> tuple[str, ...]:
return tuple(str(row.get(k) or "").strip() for k in key_fields)
def mapping_stats(
*,
before_rows: list[dict[str, Any]],
after_rows: list[dict[str, Any]],
iface_fields: list[str],
port_map: dict[str, str],
) -> dict[str, Any]:
"""hit/miss/unused for port mapping validation."""
before_ifaces: set[str] = set()
after_ifaces: set[str] = set()
for f in iface_fields:
for r in before_rows:
v = str(r.get(f) or "").strip()
if v:
before_ifaces.add(v)
for r in after_rows:
v = str(r.get(f) or "").strip()
if v:
after_ifaces.add(v)
mapped_before = set(port_map.keys())
mapped_after = set(port_map.values())
hit_before = sorted(mapped_before & before_ifaces)
miss_before = sorted(mapped_before - before_ifaces)
unused = sorted(mapped_before - before_ifaces) # same as miss for before presence
hit_after = sorted(mapped_after & after_ifaces)
miss_after = sorted(mapped_after - after_ifaces)
return {
"before_iface_count": len(before_ifaces),
"after_iface_count": len(after_ifaces),
"map_pairs": len(port_map),
"hit_before": hit_before,
"miss_before": miss_before,
"hit_after": hit_after,
"miss_after": miss_after,
"unused_before_keys": unused,
"ok": not miss_before and not miss_after,
}
def compare_rows(
*,
before_rows: list[dict[str, Any]],
after_rows: list[dict[str, Any]],
key_fields: list[str],
iface_fields: list[str],
compare_fields: list[str],
port_map: dict[str, str] | None = None,
) -> dict[str, Any]:
"""Return summary + diffs list.
Diff kinds: added | removed | changed | unchanged
"""
if not key_fields:
raise ValueError("key_fields required")
pmap = dict(port_map or {})
before_mapped: list[dict[str, Any]] = [
apply_port_map(r, iface_fields=iface_fields, port_map=pmap) for r in before_rows
]
after_index: dict[tuple[str, ...], dict[str, Any]] = {}
for r in after_rows:
after_index[row_key(r, key_fields)] = r
before_keys: set[tuple[str, ...]] = set()
diffs: list[dict[str, Any]] = []
added = removed = changed = unchanged = 0
for orig, mapped in zip(before_rows, before_mapped):
k = row_key(mapped, key_fields)
before_keys.add(k)
after = after_index.get(k)
if after is None:
removed += 1
diffs.append(
{
"kind": "removed",
"key": {f: mapped.get(f, "") for f in key_fields},
"before": orig,
"after": None,
"mapped_before": mapped,
"changes": {},
}
)
continue
field_changes: dict[str, dict[str, Any]] = {}
for f in compare_fields:
bv = mapped.get(f, "")
av = after.get(f, "")
# Compare using mapped before for iface fields already rewritten
if str(bv) != str(av):
field_changes[f] = {"before": bv, "after": av}
if field_changes:
changed += 1
diffs.append(
{
"kind": "changed",
"key": {f: mapped.get(f, "") for f in key_fields},
"before": orig,
"after": after,
"mapped_before": mapped,
"changes": field_changes,
}
)
else:
unchanged += 1
for k, after in after_index.items():
if k in before_keys:
continue
added += 1
diffs.append(
{
"kind": "added",
"key": {f: after.get(f, "") for f in key_fields},
"before": None,
"after": after,
"mapped_before": None,
"changes": {},
}
)
stats = mapping_stats(
before_rows=before_rows,
after_rows=after_rows,
iface_fields=iface_fields,
port_map=pmap,
)
return {
"summary": {
"before_count": len(before_rows),
"after_count": len(after_rows),
"added": added,
"removed": removed,
"changed": changed,
"unchanged": unchanged,
},
"diffs": diffs,
"mapping_stats": stats,
}

View file

@ -0,0 +1,503 @@
"""Compare templates, port mappings, jobs, and runs."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy.orm import Session
from ..models import (
BizCompareJob,
BizCompareRun,
BizCompareTemplate,
BizPortMapping,
BizPortMappingRow,
BizStateBatch,
BizStateLldpNeighbor,
)
from ..timeutil import utcnow_naive
from .compare_engine import compare_rows, mapping_stats
from .profiles import metric_field_map
def _utcnow() -> datetime:
return utcnow_naive()
def _default_lldp_template_fields() -> dict[str, list[str]]:
fields = metric_field_map().get("lldp_neighbor") or []
keys = [f.name for f in fields if f.is_key]
ifaces = [f.name for f in fields if f.is_interface]
compare = [f.name for f in fields if f.name not in keys or f.name in ifaces]
# Prefer comparing identity+meta that aren't pure key-only if listed
compare = [f.name for f in fields if f.role in ("identity", "state", "meta") or f.is_key]
# Deduplicate while keeping order
seen: set[str] = set()
cmp_out: list[str] = []
for n in compare:
if n not in seen:
seen.add(n)
cmp_out.append(n)
return {
"key_fields": keys or ["local_if", "remote_sys", "remote_if"],
"iface_fields": ifaces or ["local_if"],
"compare_fields": cmp_out or ["remote_sys", "remote_if", "remote_ip", "protocol"],
}
def ensure_default_lldp_template(db: Session) -> BizCompareTemplate:
row = (
db.query(BizCompareTemplate)
.filter(BizCompareTemplate.metric_id == "lldp_neighbor", BizCompareTemplate.name == "LLDP default")
.one_or_none()
)
if row:
return row
defs = _default_lldp_template_fields()
row = BizCompareTemplate(
id=uuid4().hex,
name="LLDP default",
metric_id="lldp_neighbor",
key_fields=defs["key_fields"],
iface_fields=defs["iface_fields"],
compare_fields=defs["compare_fields"],
ignore_fields=[],
note="Built-in template for LLDP neighbor cutover compare",
created_at=_utcnow(),
updated_at=_utcnow(),
)
db.add(row)
db.commit()
db.refresh(row)
return row
def _template_out(t: BizCompareTemplate) -> dict[str, Any]:
return {
"id": t.id,
"name": t.name,
"metric_id": t.metric_id,
"key_fields": list(t.key_fields or []),
"iface_fields": list(t.iface_fields or []),
"compare_fields": list(t.compare_fields or []),
"ignore_fields": list(t.ignore_fields or []),
"note": t.note,
"updated_at": t.updated_at.isoformat() + "Z" if t.updated_at else None,
}
def list_templates(db: Session) -> list[dict[str, Any]]:
ensure_default_lldp_template(db)
rows = db.query(BizCompareTemplate).order_by(BizCompareTemplate.name.asc()).all()
return [_template_out(t) for t in rows]
def create_template(db: Session, body: dict[str, Any]) -> dict[str, Any]:
metric_id = str(body.get("metric_id") or "").strip()
if not metric_id:
raise HTTPException(status_code=400, detail="metric_id_required")
key_fields = [str(x) for x in (body.get("key_fields") or []) if str(x).strip()]
if not key_fields:
raise HTTPException(status_code=400, detail="key_fields_required")
t = BizCompareTemplate(
id=uuid4().hex,
name=str(body.get("name") or metric_id)[:256],
metric_id=metric_id,
key_fields=key_fields,
iface_fields=[str(x) for x in (body.get("iface_fields") or []) if str(x).strip()],
compare_fields=[str(x) for x in (body.get("compare_fields") or []) if str(x).strip()],
ignore_fields=[str(x) for x in (body.get("ignore_fields") or []) if str(x).strip()],
note=str(body.get("note") or "")[:512],
created_at=_utcnow(),
updated_at=_utcnow(),
)
db.add(t)
db.commit()
return _template_out(t)
def update_template(db: Session, template_id: str, body: dict[str, Any]) -> dict[str, Any]:
t = db.get(BizCompareTemplate, template_id)
if not t:
raise HTTPException(status_code=404, detail="template_not_found")
if "name" in body:
t.name = str(body.get("name") or "")[:256]
if "key_fields" in body:
keys = [str(x) for x in (body.get("key_fields") or []) if str(x).strip()]
if not keys:
raise HTTPException(status_code=400, detail="key_fields_required")
t.key_fields = keys
if "iface_fields" in body:
t.iface_fields = [str(x) for x in (body.get("iface_fields") or []) if str(x).strip()]
if "compare_fields" in body:
t.compare_fields = [str(x) for x in (body.get("compare_fields") or []) if str(x).strip()]
if "ignore_fields" in body:
t.ignore_fields = [str(x) for x in (body.get("ignore_fields") or []) if str(x).strip()]
if "note" in body:
t.note = str(body.get("note") or "")[:512]
t.updated_at = _utcnow()
db.commit()
return _template_out(t)
def delete_template(db: Session, template_id: str) -> None:
t = db.get(BizCompareTemplate, template_id)
if not t:
raise HTTPException(status_code=404, detail="template_not_found")
db.delete(t)
db.commit()
def _mapping_out(db: Session, m: BizPortMapping) -> dict[str, Any]:
rows = (
db.query(BizPortMappingRow)
.filter(BizPortMappingRow.mapping_id == m.id)
.order_by(BizPortMappingRow.before_if.asc())
.all()
)
return {
"id": m.id,
"name": m.name,
"note": m.note,
"rows": [{"id": r.id, "before_if": r.before_if, "after_if": r.after_if} for r in rows],
"updated_at": m.updated_at.isoformat() + "Z" if m.updated_at else None,
}
def list_mappings(db: Session) -> list[dict[str, Any]]:
rows = db.query(BizPortMapping).order_by(BizPortMapping.name.asc()).all()
return [_mapping_out(db, m) for m in rows]
def create_mapping(db: Session, body: dict[str, Any]) -> dict[str, Any]:
m = BizPortMapping(
id=uuid4().hex,
name=str(body.get("name") or "port map")[:256],
note=str(body.get("note") or "")[:512],
created_at=_utcnow(),
updated_at=_utcnow(),
)
db.add(m)
db.flush()
_replace_mapping_rows(db, m.id, list(body.get("rows") or []))
db.commit()
return _mapping_out(db, m)
def update_mapping(db: Session, mapping_id: str, body: dict[str, Any]) -> dict[str, Any]:
m = db.get(BizPortMapping, mapping_id)
if not m:
raise HTTPException(status_code=404, detail="mapping_not_found")
if "name" in body:
m.name = str(body.get("name") or "")[:256]
if "note" in body:
m.note = str(body.get("note") or "")[:512]
if "rows" in body:
_replace_mapping_rows(db, m.id, list(body.get("rows") or []))
m.updated_at = _utcnow()
db.commit()
return _mapping_out(db, m)
def _replace_mapping_rows(db: Session, mapping_id: str, rows_in: list[dict[str, Any]]) -> None:
db.query(BizPortMappingRow).filter(BizPortMappingRow.mapping_id == mapping_id).delete()
seen: set[str] = set()
for raw in rows_in:
before = str(raw.get("before_if") or "").strip()
after = str(raw.get("after_if") or "").strip()
if not before or not after:
continue
if before in seen:
raise HTTPException(status_code=400, detail=f"duplicate_before_if:{before}")
seen.add(before)
db.add(
BizPortMappingRow(
id=uuid4().hex,
mapping_id=mapping_id,
before_if=before[:128],
after_if=after[:128],
)
)
def delete_mapping(db: Session, mapping_id: str) -> None:
m = db.get(BizPortMapping, mapping_id)
if not m:
raise HTTPException(status_code=404, detail="mapping_not_found")
db.query(BizPortMappingRow).filter(BizPortMappingRow.mapping_id == mapping_id).delete()
db.delete(m)
db.commit()
def _port_map_dict(db: Session, mapping_id: str) -> dict[str, str]:
if not mapping_id:
return {}
rows = db.query(BizPortMappingRow).filter(BizPortMappingRow.mapping_id == mapping_id).all()
return {str(r.before_if): str(r.after_if) for r in rows if r.before_if and r.after_if}
def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dict[str, Any]]:
if metric_id != "lldp_neighbor":
raise HTTPException(status_code=400, detail=f"unsupported_metric:{metric_id}")
rows = (
db.query(BizStateLldpNeighbor)
.filter(BizStateLldpNeighbor.batch_id == batch_id)
.all()
)
return [
{
"local_if": n.local_if,
"remote_sys": n.remote_sys,
"remote_if": n.remote_if,
"remote_ip": n.remote_ip,
"protocol": n.protocol,
}
for n in rows
]
def validate_mapping(
db: Session,
*,
mapping_id: str,
before_batch_id: str,
after_batch_id: str,
template_id: str = "",
) -> dict[str, Any]:
tpl = db.get(BizCompareTemplate, template_id) if template_id else ensure_default_lldp_template(db)
if not tpl:
raise HTTPException(status_code=404, detail="template_not_found")
before = _load_metric_rows(db, batch_id=before_batch_id, metric_id=tpl.metric_id)
after = _load_metric_rows(db, batch_id=after_batch_id, metric_id=tpl.metric_id)
pmap = _port_map_dict(db, mapping_id)
return mapping_stats(
before_rows=before,
after_rows=after,
iface_fields=list(tpl.iface_fields or []),
port_map=pmap,
)
def _job_out(j: BizCompareJob) -> dict[str, Any]:
return {
"id": j.id,
"name": j.name,
"template_id": j.template_id,
"mapping_id": j.mapping_id,
"before_task_id": j.before_task_id,
"after_task_id": j.after_task_id,
"before_batch_id": j.before_batch_id,
"after_batch_id": j.after_batch_id,
"mode": j.mode,
"status": j.status,
"note": j.note,
"updated_at": j.updated_at.isoformat() + "Z" if j.updated_at else None,
}
def list_jobs(db: Session) -> list[dict[str, Any]]:
rows = db.query(BizCompareJob).order_by(BizCompareJob.updated_at.desc()).all()
return [_job_out(j) for j in rows]
def create_job(db: Session, body: dict[str, Any]) -> dict[str, Any]:
ensure_default_lldp_template(db)
template_id = str(body.get("template_id") or "").strip()
if not template_id:
tpl = ensure_default_lldp_template(db)
template_id = tpl.id
else:
if not db.get(BizCompareTemplate, template_id):
raise HTTPException(status_code=404, detail="template_not_found")
j = BizCompareJob(
id=uuid4().hex,
name=str(body.get("name") or "compare")[:256],
template_id=template_id,
mapping_id=str(body.get("mapping_id") or ""),
before_task_id=str(body.get("before_task_id") or ""),
after_task_id=str(body.get("after_task_id") or ""),
before_batch_id=str(body.get("before_batch_id") or ""),
after_batch_id=str(body.get("after_batch_id") or ""),
mode=str(body.get("mode") or "manual")[:16],
status="ready",
note=str(body.get("note") or "")[:512],
created_at=_utcnow(),
updated_at=_utcnow(),
)
if j.mode == "auto":
j.status = "auto"
db.add(j)
db.commit()
return _job_out(j)
def update_job(db: Session, job_id: str, body: dict[str, Any]) -> dict[str, Any]:
j = db.get(BizCompareJob, job_id)
if not j:
raise HTTPException(status_code=404, detail="job_not_found")
for key in (
"name",
"template_id",
"mapping_id",
"before_task_id",
"after_task_id",
"before_batch_id",
"after_batch_id",
"mode",
"status",
"note",
):
if key in body and body.get(key) is not None:
setattr(j, key, str(body.get(key) or ""))
j.updated_at = _utcnow()
db.commit()
return _job_out(j)
def delete_job(db: Session, job_id: str) -> None:
j = db.get(BizCompareJob, job_id)
if not j:
raise HTTPException(status_code=404, detail="job_not_found")
db.query(BizCompareRun).filter(BizCompareRun.job_id == job_id).delete()
db.delete(j)
db.commit()
def _resolve_after_batch(db: Session, job: BizCompareJob) -> str:
if job.mode != "auto":
return str(job.after_batch_id or "")
task_id = str(job.after_task_id or "")
if not task_id:
return str(job.after_batch_id or "")
latest = (
db.query(BizStateBatch)
.filter(
BizStateBatch.task_id == task_id,
BizStateBatch.status.in_(("success", "partial")),
)
.order_by(BizStateBatch.started_at.desc())
.first()
)
return str(latest.id) if latest else ""
def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> dict[str, Any]:
j = db.get(BizCompareJob, job_id)
if not j:
raise HTTPException(status_code=404, detail="job_not_found")
tpl = db.get(BizCompareTemplate, j.template_id)
if not tpl:
raise HTTPException(status_code=404, detail="template_not_found")
before_batch_id = str(j.before_batch_id or "")
after_batch_id = str(force_after_batch_id or "").strip() or _resolve_after_batch(db, j)
if not before_batch_id or not after_batch_id:
raise HTTPException(status_code=400, detail="before_and_after_batch_required")
if not db.get(BizStateBatch, before_batch_id) or not db.get(BizStateBatch, after_batch_id):
raise HTTPException(status_code=404, detail="batch_not_found")
key_fields = list(tpl.key_fields or [])
iface_fields = list(tpl.iface_fields or [])
compare_fields = list(tpl.compare_fields or [])
ignore = set(str(x) for x in (tpl.ignore_fields or []))
compare_fields = [f for f in compare_fields if f not in ignore]
if not compare_fields:
compare_fields = [f for f in key_fields if f not in ignore]
before_rows = _load_metric_rows(db, batch_id=before_batch_id, metric_id=tpl.metric_id)
after_rows = _load_metric_rows(db, batch_id=after_batch_id, metric_id=tpl.metric_id)
pmap = _port_map_dict(db, j.mapping_id)
result = compare_rows(
before_rows=before_rows,
after_rows=after_rows,
key_fields=key_fields,
iface_fields=iface_fields,
compare_fields=compare_fields,
port_map=pmap,
)
run = BizCompareRun(
id=uuid4().hex,
job_id=j.id,
template_id=tpl.id,
mapping_id=j.mapping_id,
before_batch_id=before_batch_id,
after_batch_id=after_batch_id,
metric_id=tpl.metric_id,
status="success",
summary_json=result["summary"],
diffs_json=result["diffs"],
mapping_stats_json=result["mapping_stats"],
message="",
created_at=_utcnow(),
)
db.add(run)
j.updated_at = _utcnow()
if j.mode == "manual":
j.after_batch_id = after_batch_id
db.commit()
return get_run(db, run.id)
def get_run(db: Session, run_id: str) -> dict[str, Any]:
r = db.get(BizCompareRun, run_id)
if not r:
raise HTTPException(status_code=404, detail="run_not_found")
return {
"id": r.id,
"job_id": r.job_id,
"template_id": r.template_id,
"mapping_id": r.mapping_id,
"before_batch_id": r.before_batch_id,
"after_batch_id": r.after_batch_id,
"metric_id": r.metric_id,
"status": r.status,
"summary": r.summary_json or {},
"diffs": r.diffs_json or [],
"mapping_stats": r.mapping_stats_json or {},
"message": r.message,
"created_at": r.created_at.isoformat() + "Z" if r.created_at else None,
}
def list_runs(db: Session, job_id: str, *, limit: int = 20) -> list[dict[str, Any]]:
rows = (
db.query(BizCompareRun)
.filter(BizCompareRun.job_id == job_id)
.order_by(BizCompareRun.created_at.desc())
.limit(max(1, min(100, int(limit))))
.all()
)
return [
{
"id": r.id,
"before_batch_id": r.before_batch_id,
"after_batch_id": r.after_batch_id,
"status": r.status,
"summary": r.summary_json or {},
"created_at": r.created_at.isoformat() + "Z" if r.created_at else None,
}
for r in rows
]
def try_auto_compare_for_task(db: Session, task_id: str, batch_id: str) -> int:
"""When a new after batch lands, run auto jobs pinned to that after task."""
jobs = (
db.query(BizCompareJob)
.filter(BizCompareJob.mode == "auto", BizCompareJob.after_task_id == task_id)
.all()
)
n = 0
for j in jobs:
if not j.before_batch_id:
continue
try:
run_compare(db, j.id, force_after_batch_id=batch_id)
n += 1
except Exception:
continue
return n

View file

@ -20,6 +20,8 @@ def apply_biz_state_schema(conn: Connection) -> None:
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_task_id ON biz_state_batch (task_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_command_batch_id ON biz_state_batch_command (batch_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_lldp_neighbor_batch_id ON biz_state_lldp_neighbor (batch_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_job_status ON biz_compare_job (status)",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_run_job_id ON biz_compare_run (job_id)",
):
try:
_run_sql(conn, sql)

View file

@ -163,3 +163,141 @@ def api_export_batch(batch_id: str, db: Session = Depends(get_db)) -> StreamingR
media_type="application/zip",
headers={"Content-Disposition": f'attachment; filename="biz_state_{batch_id}.zip"'},
)
# ---- Phase2: templates / port maps / compare jobs ----
from .biz_state import compare_service as cmp_svc # noqa: E402
class TemplateIn(BaseModel):
name: str = ""
metric_id: str = "lldp_neighbor"
key_fields: list[str] = Field(default_factory=list)
iface_fields: list[str] = Field(default_factory=list)
compare_fields: list[str] = Field(default_factory=list)
ignore_fields: list[str] = Field(default_factory=list)
note: str = ""
class MappingRowIn(BaseModel):
before_if: str
after_if: str
class MappingIn(BaseModel):
name: str = ""
note: str = ""
rows: list[MappingRowIn] = Field(default_factory=list)
class ValidateMappingIn(BaseModel):
mapping_id: str
before_batch_id: str
after_batch_id: str
template_id: str = ""
class CompareJobIn(BaseModel):
name: str = ""
template_id: str = ""
mapping_id: str = ""
before_task_id: str = ""
after_task_id: str = ""
before_batch_id: str = ""
after_batch_id: str = ""
mode: str = "manual"
note: str = ""
@router.get("/compare/templates")
def api_list_templates(db: Session = Depends(get_db)) -> dict[str, Any]:
return {"items": cmp_svc.list_templates(db)}
@router.post("/compare/templates")
def api_create_template(body: TemplateIn, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.create_template(db, body.model_dump())
@router.patch("/compare/templates/{template_id}")
def api_patch_template(
template_id: str, body: TemplateIn, db: Session = Depends(get_db)
) -> dict[str, Any]:
return cmp_svc.update_template(db, template_id, body.model_dump(exclude_unset=True))
@router.delete("/compare/templates/{template_id}")
def api_delete_template(template_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
cmp_svc.delete_template(db, template_id)
return {"ok": True}
@router.get("/compare/mappings")
def api_list_mappings(db: Session = Depends(get_db)) -> dict[str, Any]:
return {"items": cmp_svc.list_mappings(db)}
@router.post("/compare/mappings")
def api_create_mapping(body: MappingIn, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.create_mapping(db, body.model_dump())
@router.patch("/compare/mappings/{mapping_id}")
def api_patch_mapping(
mapping_id: str, body: MappingIn, db: Session = Depends(get_db)
) -> dict[str, Any]:
return cmp_svc.update_mapping(db, mapping_id, body.model_dump(exclude_unset=True))
@router.delete("/compare/mappings/{mapping_id}")
def api_delete_mapping(mapping_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
cmp_svc.delete_mapping(db, mapping_id)
return {"ok": True}
@router.post("/compare/mappings/validate")
def api_validate_mapping(body: ValidateMappingIn, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.validate_mapping(
db,
mapping_id=body.mapping_id,
before_batch_id=body.before_batch_id,
after_batch_id=body.after_batch_id,
template_id=body.template_id,
)
@router.get("/compare/jobs")
def api_list_jobs(db: Session = Depends(get_db)) -> dict[str, Any]:
return {"items": cmp_svc.list_jobs(db)}
@router.post("/compare/jobs")
def api_create_job(body: CompareJobIn, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.create_job(db, body.model_dump())
@router.patch("/compare/jobs/{job_id}")
def api_patch_job(job_id: str, body: CompareJobIn, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.update_job(db, job_id, body.model_dump(exclude_unset=True))
@router.delete("/compare/jobs/{job_id}")
def api_delete_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
cmp_svc.delete_job(db, job_id)
return {"ok": True}
@router.post("/compare/jobs/{job_id}/run")
def api_run_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.run_compare(db, job_id)
@router.get("/compare/jobs/{job_id}/runs")
def api_list_runs(job_id: str, limit: int = 20, db: Session = Depends(get_db)) -> dict[str, Any]:
return {"items": cmp_svc.list_runs(db, job_id, limit=limit)}
@router.get("/compare/runs/{run_id}")
def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.get_run(db, run_id)

View file

@ -25,6 +25,11 @@ from .managed_ne import (
UmeCliOverride,
)
from .biz_state import (
BizCompareJob,
BizCompareRun,
BizCompareTemplate,
BizPortMapping,
BizPortMappingRow,
BizStateBatch,
BizStateBatchCommand,
BizStateCommandOverride,
@ -130,4 +135,9 @@ __all__ = [
"BizStateLldpNeighbor",
"BizStateEvent",
"BizStateCommandOverride",
"BizCompareTemplate",
"BizPortMapping",
"BizPortMappingRow",
"BizCompareJob",
"BizCompareRun",
]

View file

@ -149,3 +149,85 @@ class BizStateCommandOverride(Base):
sample_output: Mapped[str] = mapped_column(Text, default="")
enabled: Mapped[bool | None] = mapped_column(Boolean, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
class BizCompareTemplate(Base):
"""Global compare template: which fields are keys / interfaces / compared."""
__tablename__ = "biz_compare_template"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
name: Mapped[str] = mapped_column(String(256), default="", index=True)
metric_id: Mapped[str] = mapped_column(String(64), default="", index=True)
key_fields: Mapped[list] = mapped_column(_JsonType, default=list)
iface_fields: Mapped[list] = mapped_column(_JsonType, default=list)
compare_fields: Mapped[list] = mapped_column(_JsonType, default=list)
ignore_fields: Mapped[list] = mapped_column(_JsonType, default=list)
note: Mapped[str] = mapped_column(String(512), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
class BizPortMapping(Base):
"""Named port mapping set for cutover (before_if → after_if)."""
__tablename__ = "biz_port_mapping"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
name: Mapped[str] = mapped_column(String(256), default="", index=True)
note: Mapped[str] = mapped_column(String(512), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
class BizPortMappingRow(Base):
__tablename__ = "biz_port_mapping_row"
__table_args__ = (
UniqueConstraint("mapping_id", "before_if", name="uq_biz_port_map_before"),
)
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
mapping_id: Mapped[str] = mapped_column(String(64), default="", index=True)
before_if: Mapped[str] = mapped_column(String(128), default="")
after_if: Mapped[str] = mapped_column(String(128), default="")
class BizCompareJob(Base):
"""Cutover compare job linking template, port map, and before/after batches."""
__tablename__ = "biz_compare_job"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
name: Mapped[str] = mapped_column(String(256), default="", index=True)
template_id: Mapped[str] = mapped_column(String(64), default="", index=True)
mapping_id: Mapped[str] = mapped_column(String(64), default="", index=True)
before_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
after_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
before_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
after_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
# manual: fixed after_batch; auto: after_batch_id empty → use latest after task batch
mode: Mapped[str] = mapped_column(String(16), default="manual", index=True)
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|ready|auto
note: Mapped[str] = mapped_column(String(512), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
class BizCompareRun(Base):
"""One execution of a compare job."""
__tablename__ = "biz_compare_run"
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
job_id: Mapped[str] = mapped_column(String(64), default="", index=True)
template_id: Mapped[str] = mapped_column(String(64), default="")
mapping_id: Mapped[str] = mapped_column(String(64), default="")
before_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
after_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
metric_id: Mapped[str] = mapped_column(String(64), default="", index=True)
status: Mapped[str] = mapped_column(String(32), default="success", index=True)
summary_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
diffs_json: Mapped[list] = mapped_column(_JsonType, default=list)
mapping_stats_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
message: Mapped[str] = mapped_column(String(1024), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)

View file

@ -0,0 +1,67 @@
"""Unit tests for biz_state compare engine."""
from __future__ import annotations
import unittest
from netx_api.biz_state.compare_engine import compare_rows, mapping_stats
class CompareEngineTests(unittest.TestCase):
def test_basic_added_removed_changed(self) -> None:
before = [
{"local_if": "gei-0/1", "remote_sys": "A", "remote_if": "x1", "remote_ip": "1.1.1.1"},
{"local_if": "gei-0/2", "remote_sys": "B", "remote_if": "y1", "remote_ip": "2.2.2.2"},
]
after = [
{"local_if": "gei-0/1", "remote_sys": "A", "remote_if": "x1", "remote_ip": "1.1.1.9"},
{"local_if": "gei-0/3", "remote_sys": "C", "remote_if": "z1", "remote_ip": ""},
]
out = compare_rows(
before_rows=before,
after_rows=after,
key_fields=["local_if", "remote_sys", "remote_if"],
iface_fields=["local_if"],
compare_fields=["remote_sys", "remote_if", "remote_ip"],
port_map={},
)
s = out["summary"]
self.assertEqual(s["changed"], 1)
self.assertEqual(s["removed"], 1)
self.assertEqual(s["added"], 1)
kinds = {d["kind"] for d in out["diffs"]}
self.assertEqual(kinds, {"changed", "removed", "added"})
def test_port_map_rewrites_before_key(self) -> None:
before = [
{"local_if": "old-1", "remote_sys": "Peer", "remote_if": "p1", "remote_ip": ""},
]
after = [
{"local_if": "new-1", "remote_sys": "Peer", "remote_if": "p1", "remote_ip": ""},
]
out = compare_rows(
before_rows=before,
after_rows=after,
key_fields=["local_if", "remote_sys", "remote_if"],
iface_fields=["local_if"],
compare_fields=["remote_sys", "remote_if", "remote_ip"],
port_map={"old-1": "new-1"},
)
self.assertEqual(out["summary"]["unchanged"], 1)
self.assertEqual(out["summary"]["added"], 0)
self.assertEqual(out["summary"]["removed"], 0)
def test_mapping_stats(self) -> None:
stats = mapping_stats(
before_rows=[{"local_if": "a"}, {"local_if": "b"}],
after_rows=[{"local_if": "x"}, {"local_if": "y"}],
iface_fields=["local_if"],
port_map={"a": "x", "missing": "y"},
)
self.assertIn("a", stats["hit_before"])
self.assertIn("missing", stats["miss_before"])
self.assertFalse(stats["ok"])
if __name__ == "__main__":
unittest.main()

View file

@ -45,6 +45,7 @@ src/
| `/network/tasks/port-traffic` | 端口流量监控(设备管理) | `network` |
| `/network/tasks/port-traffic/wall` | 流量大屏列表(打开独立页签) | `network` |
| `/network/tasks/biz-state` | 业务状态监控(LLDP 快照 Phase1) | `network` |
| `/network/tasks/biz-compare` | 业务状态比对(模板/映射/前后 diff) | `network` |
| `/port-traffic/wall/:boardId` | 流量大屏专有页签(无网络侧栏) | `port-traffic-wall` |
| `/topology` | 拓扑管理(模式化编辑器:选择/平移/拖动/连线、框选、自动布局、拖放添加) | `topology` |
| `/webcrt` | WebCRT 终端 | `webcrt` |
@ -132,7 +133,7 @@ src/
- Phase1 样板:LLDP 邻居快照;建任务默认启用对应厂商 LLDP profile;支持自定义只采不解析行
- 调度:`NETX_BIZ_STATE_SCHEDULER_ENABLED`(默认开),tick `NETX_BIZ_STATE_SCHEDULER_TICK_SEC`
- 前端:`/network/tasks/biz-state`(任务列表 / 启用项 / 批次 / 导出 zip)
- Phase2(未做):比对模板、端口映射、前后批次 diff、大屏
- Phase2:比对模板、端口映射、CompareJob(manual/auto)、`/network/tasks/biz-compare`
- Phase3(未做):占位符发现→人选关联(如 VRF)
## 拓扑管理(Fabric + 站点目录,对齐厂商)

View file

@ -46,6 +46,9 @@ const PortTrafficWallPage = lazy(() =>
const BizStatePage = lazy(() =>
import("./pages/network/BizStatePage").then((m) => ({ default: m.BizStatePage })),
);
const BizComparePage = lazy(() =>
import("./pages/network/BizComparePage").then((m) => ({ default: m.BizComparePage })),
);
const UsersPage = lazy(() => import("./pages/UsersPage").then((m) => ({ default: m.UsersPage })));
const AuditLayout = lazy(() =>
import("./pages/audit/AuditLayout").then((m) => ({ default: m.AuditLayout })),
@ -124,6 +127,7 @@ function ProtectedApp() {
<Route path="tasks/port-traffic/wall" element={<LegacyPortTrafficWallRedirect />} />
<Route path="tasks/port-traffic" element={<PortTrafficPage />} />
<Route path="tasks/biz-state" element={<BizStatePage />} />
<Route path="tasks/biz-compare" element={<BizComparePage />} />
</Route>
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />
<Route path="/users" element={<UsersPage />} />

View file

@ -80,6 +80,12 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [
labelKey: "network.nav.bizState",
group: "tasks",
},
{
id: "biz-compare",
path: "/network/tasks/biz-compare",
labelKey: "network.nav.bizCompare",
group: "tasks",
},
],
},
] as const;

View file

@ -157,6 +157,7 @@ const en = {
portTraffic: "Port traffic",
portTrafficWall: "Traffic wall",
bizState: "Business state",
bizCompare: "Business compare",
},
collapseNav: "Collapse sidebar",
expandNav: "Expand sidebar",

View file

@ -157,6 +157,7 @@ const zh = {
portTraffic: "流量监控",
portTrafficWall: "流量大屏",
bizState: "业务状态监控",
bizCompare: "业务状态比对",
},
collapseNav: "折叠侧栏",
expandNav: "展开侧栏",

View file

@ -0,0 +1,487 @@
import { useCallback, useEffect, useState } from "react";
import {
bizCompareCreateJob,
bizCompareCreateMapping,
bizCompareGetRun,
bizCompareListJobs,
bizCompareListMappings,
bizCompareListRuns,
bizCompareListTemplates,
bizCompareRunJob,
bizCompareUpdateJob,
bizCompareUpdateMapping,
bizCompareValidateMapping,
bizStateListBatches,
bizStateListTasks,
} from "../../services/api";
type TaskOpt = { id: string; ne_name: string; ne_ip: string; vendor: string };
type BatchOpt = { id: string; status: string; row_count: number; started_at?: string | null };
type Template = { id: string; name: string; metric_id: string; key_fields: string[]; iface_fields: string[] };
type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] };
type Job = {
id: string;
name: string;
template_id: string;
mapping_id: string;
before_task_id: string;
after_task_id: string;
before_batch_id: string;
after_batch_id: string;
mode: string;
status: string;
};
export function BizComparePage() {
const [tasks, setTasks] = useState<TaskOpt[]>([]);
const [templates, setTemplates] = useState<Template[]>([]);
const [mappings, setMappings] = useState<Mapping[]>([]);
const [jobs, setJobs] = useState<Job[]>([]);
const [selectedJobId, setSelectedJobId] = useState("");
const [runs, setRuns] = useState<any[]>([]);
const [runDetail, setRunDetail] = useState<any>(null);
const [err, setErr] = useState("");
const [busy, setBusy] = useState(false);
// create job form
const [name, setName] = useState("割接比对");
const [templateId, setTemplateId] = useState("");
const [mappingId, setMappingId] = useState("");
const [beforeTaskId, setBeforeTaskId] = useState("");
const [afterTaskId, setAfterTaskId] = useState("");
const [beforeBatches, setBeforeBatches] = useState<BatchOpt[]>([]);
const [afterBatches, setAfterBatches] = useState<BatchOpt[]>([]);
const [beforeBatchId, setBeforeBatchId] = useState("");
const [afterBatchId, setAfterBatchId] = useState("");
const [mode, setMode] = useState<"manual" | "auto">("manual");
// mapping editor
const [mapName, setMapName] = useState("端口映射");
const [mapText, setMapText] = useState("old-if,new-if\n");
const [validateOut, setValidateOut] = useState<any>(null);
const refresh = useCallback(async () => {
const [t, tpl, maps, j] = await Promise.all([
bizStateListTasks(),
bizCompareListTemplates(),
bizCompareListMappings(),
bizCompareListJobs(),
]);
setTasks((t.items || []) as TaskOpt[]);
setTemplates((tpl.items || []) as Template[]);
setMappings((maps.items || []) as Mapping[]);
setJobs((j.items || []) as Job[]);
const items = (tpl.items || []) as Template[];
if (!templateId && items[0]) setTemplateId(items[0].id);
}, [templateId]);
useEffect(() => {
void (async () => {
try {
await refresh();
} catch (e: any) {
setErr(String(e?.message || e));
}
})();
}, [refresh]);
useEffect(() => {
void (async () => {
if (!beforeTaskId) {
setBeforeBatches([]);
return;
}
const b = await bizStateListBatches(beforeTaskId);
setBeforeBatches((b.items || []) as BatchOpt[]);
})();
}, [beforeTaskId]);
useEffect(() => {
void (async () => {
if (!afterTaskId) {
setAfterBatches([]);
return;
}
const b = await bizStateListBatches(afterTaskId);
setAfterBatches((b.items || []) as BatchOpt[]);
})();
}, [afterTaskId]);
const parseMapRows = () => {
const rows: { before_if: string; after_if: string }[] = [];
for (const line of mapText.split(/\r?\n/)) {
const s = line.trim();
if (!s || s.startsWith("#") || s.toLowerCase().startsWith("old")) continue;
const parts = s.split(/[,|\t]+/).map((x) => x.trim());
if (parts.length >= 2 && parts[0] && parts[1]) {
rows.push({ before_if: parts[0], after_if: parts[1] });
}
}
return rows;
};
const saveMapping = async () => {
setBusy(true);
setErr("");
try {
const rows = parseMapRows();
const m = await bizCompareCreateMapping({ name: mapName, rows });
setMappingId(String(m.id));
await refresh();
} catch (e: any) {
setErr(String(e?.message || e));
} finally {
setBusy(false);
}
};
const doValidate = async () => {
if (!mappingId || !beforeBatchId || !afterBatchId) {
setErr("请先选择映射与前后批次");
return;
}
setBusy(true);
setErr("");
try {
const v = await bizCompareValidateMapping({
mapping_id: mappingId,
before_batch_id: beforeBatchId,
after_batch_id: afterBatchId,
template_id: templateId,
});
setValidateOut(v);
} catch (e: any) {
setErr(String(e?.message || e));
} finally {
setBusy(false);
}
};
const createJob = async () => {
setBusy(true);
setErr("");
try {
const j = await bizCompareCreateJob({
name,
template_id: templateId,
mapping_id: mappingId,
before_task_id: beforeTaskId,
after_task_id: afterTaskId || beforeTaskId,
before_batch_id: beforeBatchId,
after_batch_id: mode === "manual" ? afterBatchId : "",
mode,
});
setSelectedJobId(String(j.id));
await refresh();
const r = await bizCompareListRuns(String(j.id));
setRuns(r.items || []);
} catch (e: any) {
setErr(String(e?.message || e));
} finally {
setBusy(false);
}
};
const openJob = async (id: string) => {
setSelectedJobId(id);
setRunDetail(null);
const r = await bizCompareListRuns(id);
setRuns(r.items || []);
const job = jobs.find((x) => x.id === id);
if (job) {
setName(job.name);
setTemplateId(job.template_id);
setMappingId(job.mapping_id);
setBeforeTaskId(job.before_task_id);
setAfterTaskId(job.after_task_id);
setBeforeBatchId(job.before_batch_id);
setAfterBatchId(job.after_batch_id);
setMode(job.mode === "auto" ? "auto" : "manual");
}
};
const runNow = async () => {
if (!selectedJobId) return;
setBusy(true);
setErr("");
try {
// sync latest batch selection onto job before run
await bizCompareUpdateJob(selectedJobId, {
before_batch_id: beforeBatchId,
after_batch_id: mode === "manual" ? afterBatchId : "",
mode,
mapping_id: mappingId,
template_id: templateId,
});
const run = await bizCompareRunJob(selectedJobId);
setRunDetail(run);
const r = await bizCompareListRuns(selectedJobId);
setRuns(r.items || []);
} catch (e: any) {
setErr(String(e?.message || e));
} finally {
setBusy(false);
}
};
const loadRun = async (runId: string) => {
const d = await bizCompareGetRun(runId);
setRunDetail(d);
};
const loadMappingText = (id: string) => {
const m = mappings.find((x) => x.id === id);
if (!m) return;
setMappingId(id);
setMapName(m.name);
setMapText(["before_if,after_if", ...m.rows.map((r) => `${r.before_if},${r.after_if}`)].join("\n"));
};
const updateExistingMapping = async () => {
if (!mappingId) {
await saveMapping();
return;
}
setBusy(true);
try {
await bizCompareUpdateMapping(mappingId, { name: mapName, rows: parseMapRows() });
await refresh();
} catch (e: any) {
setErr(String(e?.message || e));
} finally {
setBusy(false);
}
};
const taskLabel = (t: TaskOpt) => `${t.ne_name || t.ne_ip || t.id} (${t.vendor || "-"})`;
return (
<div className="wb-page">
<div className="wb-page__header">
<h1>业务状态比对</h1>
<p className="wb-muted">Phase2:模板 + 端口映射 + 前后批次 diff(支持 auto 追比)</p>
</div>
{err ? <div className="wb-alert wb-alert--error">{err}</div> : null}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: 16 }}>
<div className="wb-card">
<h3>端口映射</h3>
<div style={{ display: "flex", gap: 8, marginBottom: 8, flexWrap: "wrap" }}>
<select
value={mappingId}
onChange={(e) => {
const id = e.target.value;
if (id) loadMappingText(id);
else {
setMappingId("");
setMapText("before_if,after_if\n");
}
}}
>
<option value="">新建映射…</option>
{mappings.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</select>
<input value={mapName} onChange={(e) => setMapName(e.target.value)} placeholder="映射名称" />
<button type="button" disabled={busy} onClick={() => void updateExistingMapping()}>
保存映射
</button>
<button type="button" disabled={busy} onClick={() => void doValidate()}>
校验映射
</button>
</div>
<textarea
value={mapText}
onChange={(e) => setMapText(e.target.value)}
rows={8}
style={{ width: "100%", fontFamily: "monospace" }}
placeholder="before_if,after_if 每行一对"
/>
{validateOut ? (
<pre className="wb-muted" style={{ fontSize: 12, maxHeight: 160, overflow: "auto" }}>
{JSON.stringify(validateOut, null, 2)}
</pre>
) : null}
</div>
<div className="wb-card">
<h3>建立比对任务</h3>
<div style={{ display: "grid", gap: 8 }}>
<label>
名称{" "}
<input value={name} onChange={(e) => setName(e.target.value)} style={{ width: "70%" }} />
</label>
<label>
模板{" "}
<select value={templateId} onChange={(e) => setTemplateId(e.target.value)}>
{templates.map((t) => (
<option key={t.id} value={t.id}>
{t.name} ({t.metric_id})
</option>
))}
</select>
</label>
<label>
模式{" "}
<select value={mode} onChange={(e) => setMode(e.target.value as "manual" | "auto")}>
<option value="manual">手工选批次</option>
<option value="auto">自动(钉死操作前,操作后取最新批次)</option>
</select>
</label>
<label>
操作前任务{" "}
<select value={beforeTaskId} onChange={(e) => setBeforeTaskId(e.target.value)}>
<option value="">选择…</option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>
{taskLabel(t)}
</option>
))}
</select>
</label>
<label>
操作前批次{" "}
<select value={beforeBatchId} onChange={(e) => setBeforeBatchId(e.target.value)}>
<option value="">选择…</option>
{beforeBatches.map((b) => (
<option key={b.id} value={b.id}>
{b.started_at} · {b.status} · rows={b.row_count}
</option>
))}
</select>
</label>
<label>
操作后任务{" "}
<select value={afterTaskId} onChange={(e) => setAfterTaskId(e.target.value)}>
<option value="">同操作前 / 选择…</option>
{tasks.map((t) => (
<option key={t.id} value={t.id}>
{taskLabel(t)}
</option>
))}
</select>
</label>
{mode === "manual" ? (
<label>
操作后批次{" "}
<select value={afterBatchId} onChange={(e) => setAfterBatchId(e.target.value)}>
<option value="">选择…</option>
{afterBatches.map((b) => (
<option key={b.id} value={b.id}>
{b.started_at} · {b.status} · rows={b.row_count}
</option>
))}
</select>
</label>
) : (
<p className="wb-muted">auto:每次操作后任务新批次到达会自动跑比对</p>
)}
<div style={{ display: "flex", gap: 8 }}>
<button type="button" disabled={busy} onClick={() => void createJob()}>
创建比对任务
</button>
<button type="button" disabled={busy || !selectedJobId} onClick={() => void runNow()}>
立即比对
</button>
</div>
</div>
</div>
</div>
<div style={{ display: "grid", gridTemplateColumns: "1fr 1.6fr", gap: 16, marginTop: 16 }}>
<div className="wb-card">
<h3>比对任务列表</h3>
<table className="wb-table">
<thead>
<tr>
<th>名称</th>
<th>模式</th>
<th>状态</th>
</tr>
</thead>
<tbody>
{jobs.map((j) => (
<tr
key={j.id}
style={{ cursor: "pointer", background: selectedJobId === j.id ? "var(--wb-row-active, #eef)" : undefined }}
onClick={() => void openJob(j.id)}
>
<td>{j.name}</td>
<td>{j.mode}</td>
<td>{j.status}</td>
</tr>
))}
{!jobs.length ? (
<tr>
<td colSpan={3} className="wb-muted">
暂无
</td>
</tr>
) : null}
</tbody>
</table>
<h4>历史 Run</h4>
<ul>
{runs.map((r) => (
<li key={r.id}>
<button type="button" onClick={() => void loadRun(r.id)}>
{r.created_at} · added={r.summary?.added} removed={r.summary?.removed} changed={r.summary?.changed}
</button>
</li>
))}
</ul>
</div>
<div className="wb-card">
<h3>比对结果</h3>
{!runDetail ? (
<p className="wb-muted">选择 Run 或点「立即比对」</p>
) : (
<>
<p>
before={runDetail.before_batch_id?.slice(0, 8)}… after={runDetail.after_batch_id?.slice(0, 8)}… ·{" "}
<strong>
+{runDetail.summary?.added || 0} / -{runDetail.summary?.removed || 0} / ~{runDetail.summary?.changed || 0} / ={runDetail.summary?.unchanged || 0}
</strong>
</p>
{runDetail.mapping_stats ? (
<p className="wb-muted" style={{ fontSize: 12 }}>
映射校验 ok={String(runDetail.mapping_stats.ok)} miss_before={(runDetail.mapping_stats.miss_before || []).length}{" "}
miss_after={(runDetail.mapping_stats.miss_after || []).length}
</p>
) : null}
<table className="wb-table">
<thead>
<tr>
<th>类型</th>
<th>Key</th>
<th>变更</th>
</tr>
</thead>
<tbody>
{(runDetail.diffs || [])
.filter((d: any) => d.kind !== "unchanged")
.slice(0, 300)
.map((d: any, i: number) => (
<tr key={i}>
<td>{d.kind}</td>
<td>
<code style={{ fontSize: 11 }}>{JSON.stringify(d.key)}</code>
</td>
<td>
<code style={{ fontSize: 11 }}>{d.kind === "changed" ? JSON.stringify(d.changes) : "-"}</code>
</td>
</tr>
))}
</tbody>
</table>
</>
)}
</div>
</div>
</div>
);
}

View file

@ -1809,3 +1809,52 @@ export const bizStateDownloadExport = async (batchId: string): Promise<void> =>
URL.revokeObjectURL(url);
}
};
/* ---- biz_state compare (Phase2) ---- */
export const bizCompareListTemplates = () =>
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-state/compare/templates");
export const bizCompareCreateTemplate = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-state/compare/templates", body);
export const bizCompareListMappings = () =>
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-state/compare/mappings");
export const bizCompareCreateMapping = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-state/compare/mappings", body);
export const bizCompareUpdateMapping = (mappingId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(
`/v1/biz-state/compare/mappings/${encodeURIComponent(mappingId)}`,
body,
);
export const bizCompareValidateMapping = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-state/compare/mappings/validate", body);
export const bizCompareListJobs = () =>
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-state/compare/jobs");
export const bizCompareCreateJob = (body: Record<string, unknown>) =>
apiPost<Record<string, unknown>>("/v1/biz-state/compare/jobs", body);
export const bizCompareUpdateJob = (jobId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(
`/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}`,
body,
);
export const bizCompareRunJob = (jobId: string) =>
apiPost<Record<string, unknown>>(
`/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}/run`,
{},
);
export const bizCompareListRuns = (jobId: string, limit = 20) =>
apiGet<{ items: Record<string, unknown>[] }>(
`/v1/biz-state/compare/jobs/${encodeURIComponent(jobId)}/runs?limit=${limit}`,
);
export const bizCompareGetRun = (runId: string) =>
apiGet<Record<string, unknown>>(`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`);