diff --git a/netx_api/biz_migration/service.py b/netx_api/biz_migration/service.py index 5eacd31..4ddf94b 100644 --- a/netx_api/biz_migration/service.py +++ b/netx_api/biz_migration/service.py @@ -347,12 +347,13 @@ def _task_brief(db: Session, task_id: str) -> dict[str, Any]: def _batch_brief(db: Session, batch_id: str) -> dict[str, Any]: b = db.get(BizStateBatch, batch_id) if batch_id else None if not b: - return {"id": batch_id or "", "status": "", "started_at": None} + return {"id": batch_id or "", "status": "", "started_at": None, "alias": ""} return { "id": b.id, "status": b.status, "started_at": b.started_at.isoformat() if b.started_at else None, "row_count": b.row_count, + "alias": str(getattr(b, "alias", "") or ""), } diff --git a/netx_api/biz_state/parsers/zte/bgp_route.py b/netx_api/biz_state/parsers/zte/bgp_route.py index 17483db..6f4e64a 100644 --- a/netx_api/biz_state/parsers/zte/bgp_route.py +++ b/netx_api/biz_state/parsers/zte/bgp_route.py @@ -20,6 +20,24 @@ _ROUTE_RE = re.compile( ) _DIR_RE = re.compile(r"(?i)\bneighbor\s+(in|out)\s+") _NEI_RE = re.compile(r"(?i)\bneighbor\s+(?:in|out)\s+(\S+)") +_TOTAL_RE = re.compile(r"(?i)total\s+number\s+of\s+routes\s*:\s*(\d+)") +_HEADER_NETS = frozenset( + { + "network", + "dest", + "destination", + "next", + "hop", + "metric", + "locprf", + "loc_prf", + "intag", + "rtprf", + "tag", + "path", + "from", + } +) def _detect_direction(command: str, params: dict[str, str] | None) -> str: @@ -36,15 +54,24 @@ def _detect_neighbor(command: str, params: dict[str, str] | None) -> str: return m.group(1).strip() if m else "" +def _looks_like_prefix(net: str) -> bool: + tok = str(net or "").strip() + if not tok or tok.lower() in _HEADER_NETS: + return False + if re.fullmatch(r"\d{1,3}(?:\.\d{1,3}){3}/\d{1,2}", tok): + return True + # IPv6 prefix / bare address + if ":" in tok and re.search(r"[0-9A-Fa-f]:", tok): + return True + return False + + def _split_rest(rest: str) -> tuple[str, str, str, str]: """Parse trailing Metric LocPrf Tag/RtPrf Path columns (some may be blank).""" parts = str(rest or "").split() if not parts: return "", "", "", "" - # Last token(s) are AS path + origin; path ends with i|e|? - path = " ".join(parts) metric = loc = tag = "" - # Heuristic: numeric-only leading fields are metric/loc/tag when present nums: list[str] = [] path_parts: list[str] = [] for p in parts: @@ -62,6 +89,12 @@ def _split_rest(rest: str) -> tuple[str, str, str, str]: return metric, loc, tag, path +def _empty_if_total_zero(raw_text: str) -> bool: + """True when device reports Total number of routes: 0.""" + m = _TOTAL_RE.search(str(raw_text or "")) + return bool(m and int(m.group(1)) == 0) + + def _map_fsm_rows( rows: list[dict[str, Any]], *, @@ -74,7 +107,10 @@ def _map_fsm_rows( seen: set[str] = set() for r in rows: net = row_get(r, "NETWORK", "network") - if not net or net.lower() == "network" or net in seen: + if not _looks_like_prefix(net) or net in seen: + continue + nh = row_get(r, "NEXT_HOP", "next_hop") + if str(nh or "").strip().lower() in _HEADER_NETS: continue seen.add(net) path = row_get(r, "PATH", "path") @@ -85,7 +121,7 @@ def _map_fsm_rows( "neighbor": neighbor[:128], "direction": direction[:8], "network": net[:128], - "next_hop": row_get(r, "NEXT_HOP", "next_hop")[:128], + "next_hop": nh[:128], "metric": row_get(r, "METRIC", "metric")[:32], "loc_prf": row_get(r, "LOC_PRF", "loc_prf")[:32], "tag": row_get(r, "TAG", "RT_PRF", "tag")[:32], @@ -115,19 +151,21 @@ def _hand_parse( if not line.strip(): continue low = line.strip().lower() - if low.startswith("network") or "next hop" in low: + if low.startswith(("network", "dest ", "destination")): continue - if low.startswith("status") or low.startswith("origin") or low.startswith("routes "): + if "next hop" in low or low.startswith("status") or low.startswith("origin"): + continue + if low.startswith("routes ") or low.startswith("current as"): continue if low.startswith("local ") or low.startswith("remote ") or low.startswith("total "): continue - if low.startswith("route distinguisher"): + if low.startswith("route distinguisher") or low.startswith("valid ") or low.startswith("invalid "): continue m = _ROUTE_RE.match(line) if not m: continue net = m.group("net") - if net in seen: + if not _looks_like_prefix(net) or net in seen: continue seen.add(net) metric, loc, tag, path = _split_rest(m.group("rest")) @@ -165,6 +203,9 @@ def normalize_bgp_route( vrf = _detect_vrf(command, params) neighbor = _detect_neighbor(command, params) direction = _detect_direction(command, params) + if _empty_if_total_zero(raw_text): + return [] + tables = dict(fsm_tables or {}) if not any(tables.get(k) for k in RULE_KEYS): platform = resolve_cli_platform( diff --git a/netx_api/biz_state/profiles.py b/netx_api/biz_state/profiles.py index d0ef0a0..08580d0 100644 --- a/netx_api/biz_state/profiles.py +++ b/netx_api/biz_state/profiles.py @@ -965,7 +965,7 @@ def _zte_status_profiles() -> list[ParseProfile]: parser_id="bgp_route", title="BGP VPNv4 Neighbor In", command_template="show bgp vpnv4 unicast neighbor in | one-line", - match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+in\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", + match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?Pin)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv4 unicast neighbor in", description="Routes learned from VPNv4 neighbor; summary aux for peer state.", placeholders=[_BGP_NEIGHBOR_VPNV4], @@ -990,7 +990,7 @@ def _zte_status_profiles() -> list[ParseProfile]: parser_id="bgp_route", title="BGP VPNv4 Neighbor Out", command_template="show bgp vpnv4 unicast neighbor out | one-line", - match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+out\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", + match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?Pout)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv4 unicast neighbor out", description="Routes advertised to VPNv4 neighbor (large; bind neighbor).", placeholders=[_BGP_NEIGHBOR_VPNV4], @@ -1016,7 +1016,7 @@ def _zte_status_profiles() -> list[ParseProfile]: parser_id="bgp_route", title="BGP VPNv6 Neighbor In", command_template="show bgp vpnv6 unicast neighbor in | one-line", - match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+in\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", + match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?Pin)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv6 unicast neighbor in", description="Routes learned from VPNv6 neighbor; summary aux for peer state.", placeholders=[_BGP_NEIGHBOR_VPNV6], @@ -1041,7 +1041,7 @@ def _zte_status_profiles() -> list[ParseProfile]: parser_id="bgp_route", title="BGP VPNv6 Neighbor Out", command_template="show bgp vpnv6 unicast neighbor out | one-line", - match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+out\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", + match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?Pout)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv6 unicast neighbor out", description="Routes advertised to VPNv6 neighbor (large; bind neighbor).", placeholders=[_BGP_NEIGHBOR_VPNV6], @@ -1069,7 +1069,7 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show bgp vpnv4 unicast vrf neighbor in | one-line", match=( r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P\S+)\s+" - r"neighbor\s+in\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" + r"neighbor\s+(?Pin)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv4 unicast neighbor in", description="Per-VRF CE peer routes; discover (vrf,neighbor) from BGP peer intent.", @@ -1097,7 +1097,7 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show bgp vpnv4 unicast vrf neighbor out | one-line", match=( r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P\S+)\s+" - r"neighbor\s+out\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" + r"neighbor\s+(?Pout)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv4 unicast neighbor out", description="Per-VRF CE peer advertised routes; discover pairs from BGP peer intent.", @@ -1126,7 +1126,7 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show bgp vpnv6 unicast vrf neighbor in | one-line", match=( r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P\S+)\s+" - r"neighbor\s+in\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" + r"neighbor\s+(?Pin)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv6 unicast neighbor in", description="Per-VRF IPv6 CE peer routes; discover pairs from BGP peer intent.", @@ -1154,7 +1154,7 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show bgp vpnv6 unicast vrf neighbor out | one-line", match=( r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P\S+)\s+" - r"neighbor\s+out\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" + r"neighbor\s+(?Pout)\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv6 unicast neighbor out", description="Per-VRF IPv6 CE advertised routes; discover pairs from BGP peer intent.", diff --git a/netx_api/biz_state/schema_ensure.py b/netx_api/biz_state/schema_ensure.py index 7ba1aff..1436b46 100644 --- a/netx_api/biz_state/schema_ensure.py +++ b/netx_api/biz_state/schema_ensure.py @@ -44,6 +44,7 @@ def apply_biz_state_schema(conn: Connection) -> None: "ALTER TABLE biz_state_task ADD COLUMN IF NOT EXISTS daily_keep_count INTEGER DEFAULT 10", "ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS is_baseline BOOLEAN DEFAULT FALSE", "ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS baseline_marked_at TIMESTAMP", + "ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS alias VARCHAR(128) DEFAULT ''", "CREATE INDEX IF NOT EXISTS ix_biz_state_batch_is_baseline ON biz_state_batch (is_baseline)", "ALTER TABLE biz_migration_batch ADD COLUMN IF NOT EXISTS accept_status VARCHAR(32) DEFAULT 'none'", "ALTER TABLE biz_migration_batch ADD COLUMN IF NOT EXISTS accept_run_id VARCHAR(64) DEFAULT ''", diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index c8f661b..68a2de6 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -466,6 +466,7 @@ def _batch_list_item(b: BizStateBatch, protect: dict[str, Any]) -> dict[str, Any "message": b.message, "ne_name": b.ne_name or "", "ne_id": b.ne_id or "", + "alias": str(getattr(b, "alias", "") or ""), "started_at": b.started_at.isoformat() + "Z" if b.started_at else None, "ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None, "is_baseline": bool(getattr(b, "is_baseline", False)), @@ -510,6 +511,15 @@ def set_batch_baseline(db: Session, batch_id: str, *, marked: bool) -> dict[str, return _batch_list_item(b, batch_protect_info(db, batch_id)) +def set_batch_alias(db: Session, batch_id: str, *, alias: str) -> dict[str, Any]: + b = db.get(BizStateBatch, batch_id) + if not b: + raise HTTPException(status_code=404, detail="batch_not_found") + b.alias = str(alias or "").strip()[:128] + db.commit() + return _batch_list_item(b, batch_protect_info(db, batch_id)) + + def delete_batch(db: Session, batch_id: str) -> dict[str, Any]: b = db.get(BizStateBatch, batch_id) if not b: @@ -702,6 +712,8 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]: } for mid in sheets_order ] + sheet_count = len(sheets) + sheets_with_data = sum(1 for s in sheets if int(s.get("row_count") or 0) > 0) return { "id": b.id, @@ -710,6 +722,7 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]: "command_count": b.command_count, "row_count": b.row_count, "message": b.message, + "alias": str(getattr(b, "alias", "") or ""), "started_at": b.started_at.isoformat() + "Z" if b.started_at else None, "ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None, "is_baseline": bool(getattr(b, "is_baseline", False)), @@ -718,6 +731,10 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]: else None, "protected": bool(protect.get("protected")), "protect_reasons": list(protect.get("reasons") or []), + # Workbook header: 对比项 / 有数据 / 表格 + "compare_item_count": sheet_count, + "sheets_with_data": sheets_with_data, + "sheet_count": sheet_count, "commands": cmd_payload, "sheets": sheets, } diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index 0b070d3..6ce09cf 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -68,6 +68,10 @@ class BatchBaselineIn(BaseModel): marked: bool = True +class BatchAliasIn(BaseModel): + alias: str = "" + + class BatchBulkDeleteIn(BaseModel): batch_ids: list[str] = Field(default_factory=list) @@ -242,6 +246,13 @@ def api_set_batch_baseline( return svc.set_batch_baseline(db, batch_id, marked=bool(body.marked)) +@router.patch("/batches/{batch_id}/alias") +def api_set_batch_alias( + batch_id: str, body: BatchAliasIn, db: Session = Depends(get_db) +) -> dict[str, Any]: + return svc.set_batch_alias(db, batch_id, alias=str(body.alias or "")) + + @router.delete("/batches/{batch_id}") def api_delete_batch(batch_id: str, db: Session = Depends(get_db)) -> dict[str, Any]: return svc.delete_batch(db, batch_id) diff --git a/netx_api/cli_templates/zte/zte_zxros_show_bgp_neighbor_routes.textfsm b/netx_api/cli_templates/zte/zte_zxros_show_bgp_neighbor_routes.textfsm index b37e4af..da6af80 100644 --- a/netx_api/cli_templates/zte/zte_zxros_show_bgp_neighbor_routes.textfsm +++ b/netx_api/cli_templates/zte/zte_zxros_show_bgp_neighbor_routes.textfsm @@ -7,8 +7,9 @@ Value PATH (.*) Start ^Routes\s+(Learned|Sent) -> Routes - ^\s*\*?\s*Network -> Routes - ^Network\s+ -> Routes + ^\s*\*?\s*Network\s+Next -> Routes + ^Network\s+Next -> Routes + ^\s*Dest\s+Next -> Routes ^\s*$$ ^.* -> Start @@ -16,11 +17,15 @@ Routes ^Route\s+Distinguisher ^Status\s+codes ^Origin\s+codes + ^Current\s+AS ^Total\s+number ^\s+Valid ^\s+Invalid ^Local\s+router ^Remote\s+router + ^\s*\*?\s*Network\s+Next + ^Network\s+Next + ^\s*Dest\s+Next ^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record ^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record ^\s*$$ diff --git a/netx_api/models/biz_state.py b/netx_api/models/biz_state.py index 520d454..8e8b5ef 100644 --- a/netx_api/models/biz_state.py +++ b/netx_api/models/biz_state.py @@ -97,6 +97,8 @@ class BizStateBatch(Base): # Manual pin: excluded from auto-purge until revoked is_baseline: Mapped[bool] = mapped_column(Boolean, default=False, index=True) baseline_marked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + # Optional display name for compare / cutover pickers + alias: Mapped[str] = mapped_column(String(128), default="") class BizStateBatchCommand(Base): diff --git a/tests/test_zte_extended_parsers.py b/tests/test_zte_extended_parsers.py index 336d85f..c30385c 100644 --- a/tests/test_zte_extended_parsers.py +++ b/tests/test_zte_extended_parsers.py @@ -108,6 +108,29 @@ Routes Learned From This Neighbor: * 10.2.0.0/24 10.0.0.1 20 65001 65009 ? """ +_BGP_ROUTE_EMPTY_DEST = """ +Current AS: 100. Other AS: 24208, 3.45420 + +Routes Learned From This Neighbor: +Status codes: * valid, < last valid, i - internal, s - stale +Origin codes: i - IGP, e - EGP, ? - incomplete +Local router ID 1.11.1.1, Local AS 100, Local port 0 +Remote router ID 0.0.0.0, Remote AS 100, Remote port 0 +Total number of routes: 0 + Valid routes : 0 + Invalid routes : 0 + Dest Next Hop Metric LocPrf InTag Path +""" + +_BGP_ROUTE_EMPTY_OUT = """ +Current AS: 100. Other AS: 24208, 3.45420 + +Routes Sent To This Neighbor: +Origin codes: i - IGP, e - EGP, ? - incomplete +Total number of routes: 0 +Network Next Hop From Metric LocPrf Tag Path +""" + _IP_ROUTE = """ Dest Gw Interface Owner Pri Metric *> 0.0.0.0/0 10.0.0.1 smartgroup1 BGP 200 1 @@ -371,6 +394,48 @@ class ZteExtendedParserTests(unittest.TestCase): ) self.assertEqual(ra.command, "show bgp vpnv4 unicast vrf CUST_A summary | one-line") + def test_bgp_route_empty_total_skips_header(self) -> None: + empty_in = normalize_bgp_route( + raw_text=_BGP_ROUTE_EMPTY_DEST, + command="show bgp vpnv4 unicast neighbor in 10.22.9.2 | one-line", + params={"neighbor": "10.22.9.2", "direction": "in"}, + ) + self.assertEqual(empty_in, []) + + empty_out = normalize_bgp_route( + raw_text=_BGP_ROUTE_EMPTY_OUT, + command="show bgp vpnv4 unicast neighbor out 10.22.9.2 | one-line", + ) + self.assertEqual(empty_out, []) + + hit_in = match_command( + vendor_key="zte", + command="show bgp vpnv4 unicast neighbor in 10.22.9.2 | one-line", + ) + self.assertIsNotNone(hit_in) + assert hit_in is not None + self.assertEqual(hit_in.profile.profile_id, "zte.bgp_vpnv4_neighbor_in") + self.assertEqual(hit_in.params.get("direction"), "in") + self.assertEqual(hit_in.params.get("neighbor"), "10.22.9.2") + + hit_out = match_command( + vendor_key="zte", + command="show bgp vpnv4 unicast neighbor out 10.22.9.2 | one-line", + ) + self.assertIsNotNone(hit_out) + assert hit_out is not None + self.assertEqual(hit_out.profile.profile_id, "zte.bgp_vpnv4_neighbor_out") + self.assertEqual(hit_out.params.get("direction"), "out") + self.assertEqual(hit_out.params.get("neighbor"), "10.22.9.2") + + # Direction detected from command when params omit it + parsed = normalize_bgp_route( + raw_text=_BGP_ROUTE_IN, + command="show bgp vpnv4 unicast neighbor out 10.0.0.1", + ) + self.assertEqual(parsed[0]["direction"], "out") + self.assertEqual(parsed[0]["neighbor"], "10.0.0.1") + def test_ip_ipv6_route_and_pw(self) -> None: ip = normalize_ip_route( raw_text=_IP_ROUTE, @@ -705,6 +770,7 @@ gei-0/0/0/2 is up, ifindex: 2 assert hit6 is not None self.assertEqual(hit6.profile.profile_id, "zte.bgp_vpnv6_neighbor_in") self.assertEqual(hit6.params.get("neighbor"), "FC00:1::1") + self.assertEqual(hit6.params.get("direction"), "in") routes = normalize_bgp_route( raw_text=_BGP_ROUTE_IN, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index aee1d86..5701025 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -215,6 +215,12 @@ const en = { unmarkBaseline: "Unmark baseline", baseline: "Baseline", protected: "Protected", + colAlias: "Alias", + setAlias: "Set alias", + aliasPrompt: "Batch alias (preferred in compare/cutover pickers; empty clears)", + aliasSaved: "Alias saved", + aliasEmpty: "Untitled", + workbookStats: "Compare {{compare}} · With data {{withData}} · Sheets {{sheets}}", deleteBatch: "Delete", bulkDelete: "Bulk delete", confirmDeleteBatch: "Delete this batch? Protected batches cannot be deleted.", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index a85b490..3cbef60 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -215,6 +215,12 @@ const zh = { unmarkBaseline: "撤销基线", baseline: "基线", protected: "已保护", + colAlias: "别名", + setAlias: "设别名", + aliasPrompt: "批次别名(比对/割接选择时优先显示;留空清除)", + aliasSaved: "别名已保存", + aliasEmpty: "未命名", + workbookStats: "对比项 {{compare}} · 有数据 {{withData}} · 表格 {{sheets}}", deleteBatch: "删除", bulkDelete: "批量删除", confirmDeleteBatch: "删除该采集批次?受保护的批次无法删除。", diff --git a/web/src/index.css b/web/src/index.css index f42510c..6661feb 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -9953,6 +9953,31 @@ html.login-page--paused .login-page__flare { gap: 8px 12px; } +.bs-alias-link { + appearance: none; + border: 0; + background: transparent; + padding: 0; + margin: 0; + color: #93c5fd; + cursor: pointer; + text-align: left; + max-width: 160px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.bs-alias-link:hover { + text-decoration: underline; +} + +.bs-alias-link:disabled { + cursor: default; + opacity: 0.6; + text-decoration: none; +} + .bs-id-row { display: flex; flex-wrap: wrap; @@ -14297,8 +14322,8 @@ option { } .app-heroui-modal .pt-list-table.bs-profiles-table { - width: max-content !important; - min-width: max(100%, 1280px) !important; + width: 100% !important; + min-width: 960px !important; table-layout: fixed !important; } diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index 4ed6043..5e16516 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -40,7 +40,13 @@ type CreateJobStep = 0 | 1 | 2 | 3; const CREATE_JOB_STEPS = 4; 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 BatchOpt = { + id: string; + status: string; + row_count: number; + started_at?: string | null; + alias?: string; +}; type MetricField = { name: string; display_name: string; @@ -306,6 +312,14 @@ function taskLabel(row: TaskOpt) { return `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`; } +function batchOptLabel(b: BatchOpt) { + const alias = String(b.alias || "").trim(); + const when = fmtTime(b.started_at); + const tail = `${b.status} · rows=${b.row_count}`; + if (alias) return `${alias} · ${when} · ${tail}`; + return `${when} · ${tail}`; +} + function sheetIdentity(s: { sheet_id?: string; metric_id?: string } | null | undefined): string { return String(s?.sheet_id || s?.metric_id || "").trim(); } @@ -1929,7 +1943,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage {beforeBatches.map((b) => ( ))} @@ -1956,7 +1970,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage {afterBatches.map((b) => ( ))} diff --git a/web/src/pages/network/BizMigrationPage.tsx b/web/src/pages/network/BizMigrationPage.tsx index 7e1360b..002ad3a 100644 --- a/web/src/pages/network/BizMigrationPage.tsx +++ b/web/src/pages/network/BizMigrationPage.tsx @@ -382,8 +382,12 @@ export function BizMigrationPage() { const [createHfEndAt, setCreateHfEndAt] = useState(""); const [createOldBaselineId, setCreateOldBaselineId] = useState(""); const [createNewBaselineId, setCreateNewBaselineId] = useState(""); - const [createOldBatches, setCreateOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]); - const [createNewBatches, setCreateNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]); + const [createOldBatches, setCreateOldBatches] = useState< + { id: string; started_at?: string | null; alias?: string }[] + >([]); + const [createNewBatches, setCreateNewBatches] = useState< + { id: string; started_at?: string | null; alias?: string }[] + >([]); const [createOldHasPortrait, setCreateOldHasPortrait] = useState(false); const [createNewHasPortrait, setCreateNewHasPortrait] = useState(false); const [monitorTpls, setMonitorTpls] = useState([]); @@ -402,10 +406,10 @@ export function BizMigrationPage() { const [hfEndAt, setHfEndAt] = useState(""); const [oldBaselineId, setOldBaselineId] = useState(""); const [newBaselineId, setNewBaselineId] = useState(""); - const [oldBatches, setOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]); - const [newBatches, setNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]); - const [hfOldBatches, setHfOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]); - const [hfNewBatches, setHfNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]); + const [oldBatches, setOldBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]); + const [newBatches, setNewBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]); + const [hfOldBatches, setHfOldBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]); + const [hfNewBatches, setHfNewBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]); const [batches, setBatches] = useState([]); const [batchId, setBatchId] = useState(""); const [board, setBoard] = useState<{ @@ -751,6 +755,7 @@ export function BizMigrationPage() { (r.batches || []).map((x) => ({ id: String(x.id || ""), started_at: x.started_at || null, + alias: String(x.alias || ""), })), ); }); @@ -773,6 +778,7 @@ export function BizMigrationPage() { (r.batches || []).map((x) => ({ id: String(x.id || ""), started_at: x.started_at || null, + alias: String(x.alias || ""), })), ); }); @@ -788,6 +794,7 @@ export function BizMigrationPage() { ((r.items || []) as Record[]).map((x) => ({ id: String(x.id || ""), started_at: (x.started_at as string) || null, + alias: String(x.alias || ""), })), ); }); @@ -803,6 +810,7 @@ export function BizMigrationPage() { ((r.items || []) as Record[]).map((x) => ({ id: String(x.id || ""), started_at: (x.started_at as string) || null, + alias: String(x.alias || ""), })), ); }); @@ -820,13 +828,17 @@ export function BizMigrationPage() { } void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => { const seen = new Set(); - const items: { id: string; started_at?: string | null }[] = []; + const items: { id: string; started_at?: string | null; alias?: string }[] = []; for (const r of results) { for (const x of (r.items || []) as Record[]) { const id = String(x.id || ""); if (!id || seen.has(id)) continue; seen.add(id); - items.push({ id, started_at: (x.started_at as string) || null }); + items.push({ + id, + started_at: (x.started_at as string) || null, + alias: String(x.alias || ""), + }); } } setHfOldBatches(items); @@ -845,13 +857,17 @@ export function BizMigrationPage() { } void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => { const seen = new Set(); - const items: { id: string; started_at?: string | null }[] = []; + const items: { id: string; started_at?: string | null; alias?: string }[] = []; for (const r of results) { for (const x of (r.items || []) as Record[]) { const id = String(x.id || ""); if (!id || seen.has(id)) continue; seen.add(id); - items.push({ id, started_at: (x.started_at as string) || null }); + items.push({ + id, + started_at: (x.started_at as string) || null, + alias: String(x.alias || ""), + }); } } setHfNewBatches(items); @@ -1424,12 +1440,19 @@ export function BizMigrationPage() { return [...set]; }, [monitorTpls, createMonitorTplId, createCollectMetricIds]); - const batchOptions = (items: { id: string; started_at?: string | null }[]) => ( + const batchOptions = (items: { id: string; started_at?: string | null; alias?: string }[]) => ( <> {items.map((x) => { + const alias = String(x.alias || "").trim(); const when = fmtTime(x.started_at); - const label = when !== "—" ? `${when} · ${x.id}` : x.id; + const label = alias + ? when !== "—" + ? `${alias} · ${when}` + : alias + : when !== "—" + ? `${when} · ${x.id}` + : x.id; return (