From 1e2cc98d49ff6118427ebc1e129adbdc600611e8 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 17 Sep 2026 16:25:52 +0800 Subject: [PATCH] Add biz_state Phase2 cutover compare (templates, port maps, jobs, UI). Co-authored-by: Cursor --- netx_api/biz_state/collect_runner.py | 7 + netx_api/biz_state/compare_engine.py | 166 ++++++++ netx_api/biz_state/compare_service.py | 503 +++++++++++++++++++++++ netx_api/biz_state/schema_ensure.py | 2 + netx_api/biz_state_router.py | 138 +++++++ netx_api/models/__init__.py | 10 + netx_api/models/biz_state.py | 82 ++++ tests/test_biz_state_compare.py | 67 +++ web/WEB.md | 3 +- web/src/App.tsx | 4 + web/src/config/networkNav.ts | 6 + web/src/i18n/en.ts | 1 + web/src/i18n/zh.ts | 1 + web/src/pages/network/BizComparePage.tsx | 487 ++++++++++++++++++++++ web/src/services/api.ts | 49 +++ 15 files changed, 1525 insertions(+), 1 deletion(-) create mode 100644 netx_api/biz_state/compare_engine.py create mode 100644 netx_api/biz_state/compare_service.py create mode 100644 tests/test_biz_state_compare.py create mode 100644 web/src/pages/network/BizComparePage.tsx diff --git a/netx_api/biz_state/collect_runner.py b/netx_api/biz_state/collect_runner.py index 4d01dca..3125c66 100644 --- a/netx_api/biz_state/collect_runner.py +++ b/netx_api/biz_state/collect_runner.py @@ -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: diff --git a/netx_api/biz_state/compare_engine.py b/netx_api/biz_state/compare_engine.py new file mode 100644 index 0000000..ffa60c7 --- /dev/null +++ b/netx_api/biz_state/compare_engine.py @@ -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, + } diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py new file mode 100644 index 0000000..910cd77 --- /dev/null +++ b/netx_api/biz_state/compare_service.py @@ -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 diff --git a/netx_api/biz_state/schema_ensure.py b/netx_api/biz_state/schema_ensure.py index 8155098..d3b87f5 100644 --- a/netx_api/biz_state/schema_ensure.py +++ b/netx_api/biz_state/schema_ensure.py @@ -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) diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index 6a004c4..77d6d80 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -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) diff --git a/netx_api/models/__init__.py b/netx_api/models/__init__.py index 932406a..cda7c6c 100644 --- a/netx_api/models/__init__.py +++ b/netx_api/models/__init__.py @@ -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", ] diff --git a/netx_api/models/biz_state.py b/netx_api/models/biz_state.py index 6210d70..418f485 100644 --- a/netx_api/models/biz_state.py +++ b/netx_api/models/biz_state.py @@ -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) diff --git a/tests/test_biz_state_compare.py b/tests/test_biz_state_compare.py new file mode 100644 index 0000000..d8e5fbe --- /dev/null +++ b/tests/test_biz_state_compare.py @@ -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() diff --git a/web/WEB.md b/web/WEB.md index d25ff6b..9bfc4d4 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -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 + 站点目录,对齐厂商) diff --git a/web/src/App.tsx b/web/src/App.tsx index e949005..943f404 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> diff --git a/web/src/config/networkNav.ts b/web/src/config/networkNav.ts index 2964c29..0892c88 100644 --- a/web/src/config/networkNav.ts +++ b/web/src/config/networkNav.ts @@ -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; diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index d062ef3..ee83202 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -157,6 +157,7 @@ const en = { portTraffic: "Port traffic", portTrafficWall: "Traffic wall", bizState: "Business state", + bizCompare: "Business compare", }, collapseNav: "Collapse sidebar", expandNav: "Expand sidebar", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index c1bc95c..faf72d9 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -157,6 +157,7 @@ const zh = { portTraffic: "流量监控", portTrafficWall: "流量大屏", bizState: "业务状态监控", + bizCompare: "业务状态比对", }, collapseNav: "折叠侧栏", expandNav: "展开侧栏", diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx new file mode 100644 index 0000000..049b5fe --- /dev/null +++ b/web/src/pages/network/BizComparePage.tsx @@ -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([]); + const [templates, setTemplates] = useState([]); + const [mappings, setMappings] = useState([]); + const [jobs, setJobs] = useState([]); + const [selectedJobId, setSelectedJobId] = useState(""); + const [runs, setRuns] = useState([]); + const [runDetail, setRunDetail] = useState(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([]); + const [afterBatches, setAfterBatches] = useState([]); + 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(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 ( +
+
+

业务状态比对

+

Phase2:模板 + 端口映射 + 前后批次 diff(支持 auto 追比)

+
+ {err ?
{err}
: null} + +
+
+

端口映射

+
+ + setMapName(e.target.value)} placeholder="映射名称" /> + + +
+