From 06331ca763f3c30cdf9a88d0b770039ee268c940 Mon Sep 17 00:00:00 2001 From: oliver Date: Thu, 17 Sep 2026 16:37:31 +0800 Subject: [PATCH] Add biz_state Phase3 VRF discover-select and parameterized collect. Co-authored-by: Cursor --- netx_api/biz_state/collect_runner.py | 58 +++- netx_api/biz_state/command_match.py | 44 ++- netx_api/biz_state/compare_service.py | 46 ++- netx_api/biz_state/discover.py | 175 +++++++++++ netx_api/biz_state/parsers/__init__.py | 3 + netx_api/biz_state/parsers/vrf.py | 109 +++++++ netx_api/biz_state/profiles.py | 115 ++++++- netx_api/biz_state/schema_ensure.py | 1 + netx_api/biz_state/service.py | 77 ++++- netx_api/biz_state_router.py | 44 ++- netx_api/cli_templates/index | 1 + .../zte/zte_zxros_show_ip_vrf.textfsm | 21 ++ netx_api/models/__init__.py | 2 + netx_api/models/biz_state.py | 19 ++ tests/test_biz_state_vrf.py | 80 +++++ web/WEB.md | 12 +- web/src/pages/network/BizStatePage.tsx | 293 +++++++++++++++--- web/src/services/api.ts | 38 ++- 18 files changed, 1051 insertions(+), 87 deletions(-) create mode 100644 netx_api/biz_state/discover.py create mode 100644 netx_api/biz_state/parsers/vrf.py create mode 100644 netx_api/cli_templates/zte/zte_zxros_show_ip_vrf.textfsm create mode 100644 tests/test_biz_state_vrf.py diff --git a/netx_api/biz_state/collect_runner.py b/netx_api/biz_state/collect_runner.py index 3125c66..c70ad96 100644 --- a/netx_api/biz_state/collect_runner.py +++ b/netx_api/biz_state/collect_runner.py @@ -23,6 +23,7 @@ from ..models import ( BizStateTask, BizStateTaskItem, BizStateTaskItemBinding, + BizStateVrfRouteSummary, ) from ..ne_netmiko import disable_target_paging, send_show_command from ..ne_session_factory import close_netmiko_connection, open_netmiko_connection @@ -62,13 +63,12 @@ def _bindings_for_item(db, item_id: str) -> list[dict[str, str]]: .filter(BizStateTaskItemBinding.item_id == item_id) .all() ) - params: dict[str, str] = {} - for r in rows: - ph = str(r.placeholder or "").strip() - val = str(r.value or "").strip() - if ph and val: - params[ph] = val - return [params] if params else [] + # Keep one row per binding value (same placeholder may appear many times). + return [ + {"placeholder": str(r.placeholder or "").strip(), "value": str(r.value or "").strip()} + for r in rows + if str(r.placeholder or "").strip() and str(r.value or "").strip() + ] def _persist_lldp_rows( @@ -109,6 +109,45 @@ def _persist_lldp_rows( return n +def _persist_vrf_route_summary( + db, + *, + batch: BizStateBatch, + cmd_row: BizStateBatchCommand, + records: list[dict[str, Any]], +) -> int: + n = 0 + seen: set[tuple[str, str]] = set() + for rec in records: + vrf = str(rec.get("vrf") or "").strip()[:128] + source = str(rec.get("source") or "").strip()[:64] + if not vrf and not source: + continue + key = (vrf, source) + if key in seen: + continue + seen.add(key) + try: + networks = int(rec.get("networks") or 0) + except (TypeError, ValueError): + networks = 0 + db.add( + BizStateVrfRouteSummary( + id=uuid4().hex, + batch_id=batch.id, + batch_command_id=cmd_row.id, + task_id=batch.task_id, + ne_id=batch.ne_id, + vrf=vrf, + source=source, + networks=networks, + collected_at=_utcnow(), + ) + ) + n += 1 + return n + + def _finish_task(task_id: str, *, error: str = "") -> None: db = SessionLocal() try: @@ -411,6 +450,10 @@ def _run_collect_session( n = _persist_lldp_rows( sdb, batch=batch_row, cmd_row=cmd_row, records=records ) + elif hit.profile.metric_id == "vrf_route_summary": + n = _persist_vrf_route_summary( + sdb, batch=batch_row, cmd_row=cmd_row, records=records + ) cmd_row.row_count = n cmd_row.parse_status = "ok" total_rows += n @@ -473,6 +516,7 @@ def _purge_old_batches(db, *, task_id: str, keep: int) -> None: for b in drop: bid = b.id db.query(BizStateLldpNeighbor).filter(BizStateLldpNeighbor.batch_id == bid).delete() + db.query(BizStateVrfRouteSummary).filter(BizStateVrfRouteSummary.batch_id == bid).delete() db.query(BizStateBatchCommand).filter(BizStateBatchCommand.batch_id == bid).delete() db.delete(b) if drop: diff --git a/netx_api/biz_state/command_match.py b/netx_api/biz_state/command_match.py index 3fa668c..0f4d1d3 100644 --- a/netx_api/biz_state/command_match.py +++ b/netx_api/biz_state/command_match.py @@ -33,7 +33,11 @@ def match_command( return None key = str(vendor_key or "").strip().lower() cands = profiles if profiles is not None else [ - p for p in all_profiles() if p.enabled and (p.vendor_key == key or p.vendor_key == "*") + p + for p in all_profiles() + if p.enabled + and p.kind == "collect" + and (p.vendor_key == key or p.vendor_key == "*") ] best: MatchResult | None = None for p in cands: @@ -50,6 +54,37 @@ def match_command( return best +def normalize_binding_dicts( + bindings: list[dict[str, str]] | None, + *, + placeholders: list | None = None, +) -> list[dict[str, str]]: + """Accept ``{vrf: X}`` or ``{placeholder, value}`` rows; expand multi-value placeholders.""" + raw = list(bindings or []) + converted: list[dict[str, str]] = [] + for b in raw: + d = dict(b or {}) + if "placeholder" in d or ("name" in d and "value" in d): + ph = str(d.get("placeholder") or d.get("name") or "").strip() + val = str(d.get("value") or "").strip() + if ph and val: + converted.append({ph: val}) + continue + converted.append({str(k): str(v).strip() for k, v in d.items() if str(v).strip()}) + + phs = list(placeholders or []) + if len(phs) == 1: + name = str(getattr(phs[0], "name", "") or "") + schema = str(getattr(phs[0], "schema_field", "") or name) + out: list[dict[str, str]] = [] + for c in converted: + val = c.get(name) or c.get(schema) or "" + if val: + out.append({name: val}) + return out + return converted + + def expand_from_bindings( *, profile: ParseProfile, @@ -70,13 +105,12 @@ def expand_from_bindings( raise ValueError(f"template has placeholders but profile defines none: {concrete}") return [(concrete, {})] - binds = list(bindings or []) + binds = normalize_binding_dicts(bindings, placeholders=profile.placeholders) if not binds: raise ValueError(f"profile {profile.profile_id} requires parameter bindings") out: list[tuple[str, dict[str, str]]] = [] - for b in binds: - params = {str(k): str(v).strip() for k, v in dict(b or {}).items() if str(v).strip()} + for params in binds: rendered = tmpl for ph in profile.placeholders: val = params.get(ph.name) or params.get(ph.schema_field) or "" @@ -86,7 +120,7 @@ def expand_from_bindings( concrete = normalize_command(rendered) if re.search(r"<[^>]+>", concrete): raise ValueError(f"unresolved placeholders in: {concrete}") - out.append((concrete, params)) + out.append((concrete, dict(params))) return out diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py index 910cd77..2299128 100644 --- a/netx_api/biz_state/compare_service.py +++ b/netx_api/biz_state/compare_service.py @@ -240,23 +240,35 @@ def _port_map_dict(db: Session, mapping_id: str) -> dict[str, str]: 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 - ] + if metric_id == "lldp_neighbor": + 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 + ] + if metric_id == "vrf_route_summary": + from ..models import BizStateVrfRouteSummary + + rows = ( + db.query(BizStateVrfRouteSummary) + .filter(BizStateVrfRouteSummary.batch_id == batch_id) + .all() + ) + return [ + {"vrf": r.vrf, "source": r.source, "networks": r.networks} + for r in rows + ] + raise HTTPException(status_code=400, detail=f"unsupported_metric:{metric_id}") def validate_mapping( diff --git a/netx_api/biz_state/discover.py b/netx_api/biz_state/discover.py new file mode 100644 index 0000000..80ae9df --- /dev/null +++ b/netx_api/biz_state/discover.py @@ -0,0 +1,175 @@ +"""One-shot discover for placeholder candidates (VRF list, etc.).""" + +from __future__ import annotations + +from typing import Any + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from ..cli_creds import cli_creds_skip_reason +from ..cli_resolve import resolve_cli_target +from ..config import settings +from ..lldp_shared import resolve_vendor_key +from ..models import BizStateTask +from ..ne_netmiko import disable_target_paging, send_show_command +from ..ne_session_factory import close_netmiko_connection, open_netmiko_connection +from .parsers import get_parser +from .profiles import get_profile + + +def resolve_discover_profile( + *, + discover_profile_id: str = "", + collect_profile_id: str = "", + placeholder: str = "", +) -> Any: + if discover_profile_id: + p = get_profile(discover_profile_id) + if not p: + raise HTTPException(status_code=404, detail="discover_profile_not_found") + return p + collect = get_profile(collect_profile_id) + if not collect: + raise HTTPException(status_code=404, detail="collect_profile_not_found") + ph_name = str(placeholder or "").strip() + for ph in collect.placeholders: + if ph_name and ph.name != ph_name: + continue + if ph.discover_profile_id: + disc = get_profile(ph.discover_profile_id) + if disc: + return disc + raise HTTPException(status_code=400, detail="no_discover_profile_for_placeholder") + + +def discover_params( + db: Session, + *, + source: str = "managed", + ne_id: str = "", + task_id: str = "", + discover_profile_id: str = "", + collect_profile_id: str = "", + placeholder: str = "", +) -> dict[str, Any]: + src = str(source or "managed").strip().lower() or "managed" + nid = str(ne_id or "").strip() + if task_id and not nid: + task = db.get(BizStateTask, task_id) + if not task: + raise HTTPException(status_code=404, detail="task_not_found") + src = str(task.source or src) + nid = str(task.ne_id or "") + if not nid: + raise HTTPException(status_code=400, detail="ne_id_required") + + disc = resolve_discover_profile( + discover_profile_id=discover_profile_id, + collect_profile_id=collect_profile_id, + placeholder=placeholder, + ) + value_field = "vrf_name" + label_field = "vrf_name" + if collect_profile_id: + collect = get_profile(collect_profile_id) + if collect: + for ph in collect.placeholders: + if placeholder and ph.name != placeholder: + continue + if ph.discover_value_field: + value_field = ph.discover_value_field + if ph.discover_label_field: + label_field = ph.discover_label_field + break + + try: + if src == "managed": + creds, info = resolve_cli_target(db, managed_ne_id=nid) + elif src == "ume": + creds, info = resolve_cli_target(db, ume_ne_id=nid) + else: + raise HTTPException(status_code=400, detail="invalid_source") + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=400, detail=f"resolve_failed: {exc}") from exc + + skip = cli_creds_skip_reason(creds, interactive=False) + if skip: + raise HTTPException(status_code=400, detail=skip) + + vendor = str(info.get("vendor") or creds.get("vendor") or "") + device_type = str(info.get("device_type") or creds.get("device_type") or "") + command = str(disc.command_template or "").strip() + per_cmd = int(settings.ne_collect_read_timeout_sec or 120) + raw = "" + try: + conn = open_netmiko_connection(creds, session_timeout=per_cmd + 60) + try: + try: + disable_target_paging(conn, vendor=vendor, device_type=device_type) + except Exception: + pass + raw = send_show_command(conn, command, read_timeout=per_cmd) + finally: + close_netmiko_connection(conn) + except Exception as exc: + return { + "ok": False, + "error": f"cli_failed: {exc}", + "discover_profile_id": disc.profile_id, + "command": command, + "candidates": [], + "raw_preview": str(raw or "")[:4000], + } + + parser = get_parser(disc.parser_id) + records: list[dict[str, Any]] = [] + if parser: + try: + records = parser( + raw_text=raw, + vendor=vendor, + device_type=device_type, + command=disc.textfsm_command or command, + params={}, + ) + except Exception as exc: + return { + "ok": False, + "error": f"parse_failed: {exc}", + "discover_profile_id": disc.profile_id, + "command": command, + "candidates": [], + "raw_preview": str(raw or "")[:4000], + } + + candidates = [] + seen: set[str] = set() + for rec in records: + val = str(rec.get(value_field) or "").strip() + if not val or val in seen: + continue + seen.add(val) + label = str(rec.get(label_field) or val).strip() or val + candidates.append( + { + "value": val, + "label": label, + "rd": str(rec.get("rd") or ""), + "protocols": str(rec.get("protocols") or ""), + "extra": rec, + } + ) + + return { + "ok": True, + "error": "", + "discover_profile_id": disc.profile_id, + "command": command, + "vendor_key": resolve_vendor_key(vendor, device_type), + "value_field": value_field, + "candidates": candidates, + "raw_preview": str(raw or "")[:4000], + } diff --git a/netx_api/biz_state/parsers/__init__.py b/netx_api/biz_state/parsers/__init__.py index 95a3597..7178c3e 100644 --- a/netx_api/biz_state/parsers/__init__.py +++ b/netx_api/biz_state/parsers/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import Any, Callable from ...lldp_shared import NeighborHit, parse_neighbor_output +from .vrf import normalize_vrf_list, normalize_vrf_route_summary NormalizeFn = Callable[..., list[dict[str, Any]]] @@ -40,6 +41,8 @@ def normalize_lldp_neighbors( _REGISTRY: dict[str, NormalizeFn] = { "lldp_neighbors": normalize_lldp_neighbors, + "vrf_list": normalize_vrf_list, + "vrf_route_summary": normalize_vrf_route_summary, } diff --git a/netx_api/biz_state/parsers/vrf.py b/netx_api/biz_state/parsers/vrf.py new file mode 100644 index 0000000..d9abb8d --- /dev/null +++ b/netx_api/biz_state/parsers/vrf.py @@ -0,0 +1,109 @@ +"""VRF list + per-VRF route summary parsers.""" + +from __future__ import annotations + +import re +from typing import Any + +from ...lldp_shared import resolve_vendor_key +from ...ntc_parse import parse_cli, resolve_cli_platform, row_get + + +def normalize_vrf_list( + *, + raw_text: str, + vendor: str = "", + device_type: str = "", + command: str = "", + params: dict[str, str] | None = None, +) -> list[dict[str, Any]]: + _ = params + platform = resolve_cli_platform( + vendor=vendor, + device_type=device_type, + vendor_key=resolve_vendor_key(vendor, device_type), + ) + cmd = str(command or "").strip() + rows = parse_cli(platform=platform, command=cmd, text=raw_text) if platform and cmd else [] + out: list[dict[str, Any]] = [] + seen: set[str] = set() + for r in rows: + name = row_get(r, "NAME", "VPN_INSTANCE", "VRF", "vrf_name") + if not name or name.lower() in ("name", "vrf", "vpn-instance"): + continue + if name in seen: + continue + seen.add(name) + out.append( + { + "vrf_name": name[:128], + "rd": row_get(r, "DEFAULT_RD", "RD", "rd")[:64], + "protocols": row_get(r, "PROTOCOLS", "ADDRESS_FAMILY", "protocols")[:64], + } + ) + if out: + return out + # Fallback: loose line scrape for labs without TextFSM hit + for line in str(raw_text or "").splitlines(): + m = re.match(r"^\s*([A-Za-z0-9_./:-]+)\s+(\d+:\d+||\S+:\S+)\s*", line) + if not m: + continue + name = m.group(1) + if name.lower() in ("name", "vrf", "vpn-instance", "total"): + continue + if name in seen: + continue + seen.add(name) + out.append({"vrf_name": name[:128], "rd": m.group(2)[:64], "protocols": ""}) + return out + + +_SOURCE_PATTERNS: list[tuple[str, re.Pattern[str]]] = [ + ("connected", re.compile(r"(?i)^\s*connected\s+(\d+)\s*$")), + ("static", re.compile(r"(?i)^\s*static\s+(\d+)\s*$")), + ("local", re.compile(r"(?i)^\s*local\s+(\d+)\s*$")), + ("ospf", re.compile(r"(?i)^\s*ospf(?:\s+\S+)?\s+(\d+)\s*$")), + ("isis", re.compile(r"(?i)^\s*isis(?:\s+\S+)?\s+(\d+)\s*$")), + ("bgp", re.compile(r"(?i)^\s*bgp(?:\s+\S+)?\s+(\d+)\s*$")), + ("rip", re.compile(r"(?i)^\s*rip(?:\s+\S+)?\s+(\d+)\s*$")), + ("total", re.compile(r"(?i)^\s*(?:total|totals?)\s+(?:routes?\s+)?(\d+)\s*$")), +] + + +def normalize_vrf_route_summary( + *, + raw_text: str, + vendor: str = "", + device_type: str = "", + command: str = "", + params: dict[str, str] | None = None, +) -> list[dict[str, Any]]: + _ = (vendor, device_type, command) + vrf = str((params or {}).get("vrf") or (params or {}).get("vrf_name") or "").strip() + text = str(raw_text or "") + found: dict[str, int] = {} + for src, pat in _SOURCE_PATTERNS: + for line in text.splitlines(): + m = pat.match(line.strip()) + if m: + found[src] = int(m.group(1)) + break + + if not found: + # Count non-empty data-ish lines as opaque "routes" bucket + n = 0 + for line in text.splitlines(): + s = line.strip() + if not s or s.startswith("-") or s.lower().startswith(("code", "codes", "gateway", "routing table")): + continue + if re.match(r"^[A-Z*+]>?\s+\S+", s) or re.match(r"^\S+\s+\d+\.\d+\.\d+\.\d+", s): + n += 1 + if n: + found["routes"] = n + + rows: list[dict[str, Any]] = [] + for src, count in found.items(): + rows.append({"vrf": vrf[:128], "source": src[:64], "networks": count}) + if vrf and not rows: + rows.append({"vrf": vrf[:128], "source": "empty", "networks": 0}) + return rows diff --git a/netx_api/biz_state/profiles.py b/netx_api/biz_state/profiles.py index 9065c45..b197b70 100644 --- a/netx_api/biz_state/profiles.py +++ b/netx_api/biz_state/profiles.py @@ -121,13 +121,119 @@ def _lldp_profiles() -> list[ParseProfile]: return out +_VRF_ROUTE_FIELDS: list[FieldDef] = [ + FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True), + FieldDef("source", length=64, indexed=True, is_key=True, display_name="路由来源"), + FieldDef("networks", dtype="int", role="state", display_name="路由条数"), +] + + +def _vrf_profiles() -> list[ParseProfile]: + """Discover VRF list + parameterized route-summary collect (Phase3).""" + discover_cmds: dict[str, tuple[str, str, str]] = { + # vendor_key: (command, match, textfsm_command) + "cisco": ("show vrf", r"(?i)^\s*show\s+vrf\s*$", "show vrf"), + "huawei": ( + "display ip vpn-instance", + r"(?i)^\s*display\s+ip\s+vpn-instance\s*$", + "display ip vpn-instance", + ), + "h3c": ( + "display ip vpn-instance", + r"(?i)^\s*display\s+ip\s+vpn-instance\s*$", + "display ip vpn-instance", + ), + "zte": ("show ip vrf", r"(?i)^\s*show\s+ip\s+vrf\s*$", "show ip vrf"), + } + collect_cmds: dict[str, tuple[str, str]] = { + "cisco": ( + "show ip route vrf summary", + r"(?i)^\s*show\s+ip\s+route\s+vrf\s+(?P\S+)\s+summary\s*$", + ), + "huawei": ( + "display ip routing-table vpn-instance statistics", + r"(?i)^\s*display\s+ip\s+routing-table\s+vpn-instance\s+(?P\S+)\s+statistics\s*$", + ), + "h3c": ( + "display ip routing-table vpn-instance statistics", + r"(?i)^\s*display\s+ip\s+routing-table\s+vpn-instance\s+(?P\S+)\s+statistics\s*$", + ), + "zte": ( + "show ip route vrf summary", + r"(?i)^\s*show\s+ip\s+route\s+vrf\s+(?P\S+)\s+summary\s*$", + ), + } + out: list[ParseProfile] = [] + order = 200 + for key, (cmd, match, fsm_cmd) in discover_cmds.items(): + out.append( + ParseProfile( + profile_id=f"{key}.vrf_list", + vendor_key=key, + metric_id="vrf_list", + parser_id="vrf_list", + title="VRF / VPN-Instance List", + command_template=cmd, + match=match, + textfsm_command=fsm_cmd, + description="Discover VRF names for parameterized collect bindings.", + placeholders=[], + fields=[ + FieldDef("vrf_name", length=128, indexed=True, is_key=True, display_name="VRF"), + FieldDef("rd", length=64, role="meta", display_name="RD"), + FieldDef("protocols", length=64, role="meta", display_name="协议"), + ], + tags=["vrf", "discover"], + sort_order=order, + enabled=True, + kind="discover", + ) + ) + order += 5 + + order = 220 + for key, (tmpl, match) in collect_cmds.items(): + disc_id = f"{key}.vrf_list" + out.append( + ParseProfile( + profile_id=f"{key}.route_vrf_summary", + vendor_key=key, + metric_id="vrf_route_summary", + parser_id="vrf_route_summary", + title="VRF Route Summary", + command_template=tmpl, + match=match, + textfsm_command="", + description="Per-VRF route source counts (discover VRF → select bindings → collect).", + placeholders=[ + PlaceholderDef( + name="vrf", + schema_field="vrf", + required=True, + bind_mode="discover_select", + discover_profile_id=disc_id, + discover_value_field="vrf_name", + discover_label_field="vrf_name", + ) + ], + fields=list(_VRF_ROUTE_FIELDS), + tags=["vrf", "route", "l3"], + sort_order=order, + enabled=True, + kind="collect", + ) + ) + order += 5 + return out + + _PROFILES: list[ParseProfile] | None = None def all_profiles() -> list[ParseProfile]: global _PROFILES if _PROFILES is None: - _PROFILES = _lldp_profiles() + _PROFILES = _lldp_profiles() + _vrf_profiles() return list(_PROFILES) @@ -136,13 +242,16 @@ def reload_profiles() -> None: _PROFILES = None -def profiles_for_vendor(vendor_key: str) -> list[ParseProfile]: +def profiles_for_vendor(vendor_key: str, *, kind: str | None = None) -> list[ParseProfile]: key = str(vendor_key or "").strip().lower() - return [ + out = [ p for p in all_profiles() if p.enabled and (p.vendor_key == key or p.vendor_key == "*") ] + if kind: + out = [p for p in out if p.kind == kind] + return out def get_profile(profile_id: str) -> ParseProfile | None: diff --git a/netx_api/biz_state/schema_ensure.py b/netx_api/biz_state/schema_ensure.py index d3b87f5..bf9c905 100644 --- a/netx_api/biz_state/schema_ensure.py +++ b/netx_api/biz_state/schema_ensure.py @@ -22,6 +22,7 @@ def apply_biz_state_schema(conn: Connection) -> None: "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)", + "CREATE INDEX IF NOT EXISTS ix_biz_state_vrf_route_batch_id ON biz_state_vrf_route_summary (batch_id)", ): try: _run_sql(conn, sql) diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index 426a0c4..32d9299 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -21,6 +21,7 @@ from ..models import ( BizStateTask, BizStateTaskItem, BizStateTaskItemBinding, + BizStateVrfRouteSummary, ManagedNE, ) from ..timeutil import utcnow_naive @@ -224,17 +225,71 @@ def update_task(db: Session, task_id: str, body: dict[str, Any]) -> dict[str, An task.interval_sec = max(60, int(body.get("interval_sec") or 300)) if "retention_batches" in body: task.retention_batches = max(1, int(body.get("retention_batches") or 30)) + if "items" in body: + _replace_items(db, task.id, list(body.get("items") or [])) if "status" in body: st = str(body.get("status") or "").strip() if st in ("draft", "running", "paused", "stopped"): + if st == "running": + _assert_bindings_ready(db, task.id) task.status = st - if "items" in body: - _replace_items(db, task.id, list(body.get("items") or [])) task.updated_at = _utcnow() db.commit() return get_task(db, task_id) +def _assert_bindings_ready(db: Session, task_id: str) -> None: + items = ( + db.query(BizStateTaskItem) + .filter(BizStateTaskItem.task_id == task_id, BizStateTaskItem.enabled.is_(True)) + .all() + ) + for it in items: + if it.kind != "catalog": + continue + profile = get_profile(it.source_profile_id) + if not profile or not profile.placeholders: + continue + binds = ( + db.query(BizStateTaskItemBinding) + .filter(BizStateTaskItemBinding.item_id == it.id) + .all() + ) + if not binds: + raise HTTPException( + status_code=400, + detail=f"bindings_required:{profile.profile_id}", + ) + + +def set_item_bindings( + db: Session, task_id: str, item_id: str, bindings: list[dict[str, Any]] +) -> dict[str, Any]: + item = db.get(BizStateTaskItem, item_id) + if not item or item.task_id != task_id: + raise HTTPException(status_code=404, detail="item_not_found") + db.query(BizStateTaskItemBinding).filter(BizStateTaskItemBinding.item_id == item_id).delete() + for b in bindings: + ph = str(b.get("placeholder") or b.get("name") or "").strip() + val = str(b.get("value") or "").strip() + if not ph or not val: + continue + db.add( + BizStateTaskItemBinding( + id=uuid4().hex, + item_id=item_id, + placeholder=ph[:64], + value=val[:256], + created_at=_utcnow(), + ) + ) + task = db.get(BizStateTask, task_id) + if task: + task.updated_at = _utcnow() + db.commit() + return get_task(db, task_id) + + def get_task(db: Session, task_id: str) -> dict[str, Any]: task = db.get(BizStateTask, task_id) if not task: @@ -317,6 +372,7 @@ def delete_task(db: Session, task_id: str) -> None: batches = db.query(BizStateBatch).filter(BizStateBatch.task_id == task_id).all() for b in batches: db.query(BizStateLldpNeighbor).filter(BizStateLldpNeighbor.batch_id == b.id).delete() + db.query(BizStateVrfRouteSummary).filter(BizStateVrfRouteSummary.batch_id == b.id).delete() db.query(BizStateBatchCommand).filter(BizStateBatchCommand.batch_id == b.id).delete() db.delete(b) items = db.query(BizStateTaskItem).filter(BizStateTaskItem.task_id == task_id).all() @@ -367,6 +423,13 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]: .limit(5000) .all() ) + vrf_rows = ( + db.query(BizStateVrfRouteSummary) + .filter(BizStateVrfRouteSummary.batch_id == batch_id) + .order_by(BizStateVrfRouteSummary.vrf.asc(), BizStateVrfRouteSummary.source.asc()) + .limit(5000) + .all() + ) return { "id": b.id, "task_id": b.task_id, @@ -401,6 +464,9 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]: } for n in neighbors ], + "vrf_route_summary": [ + {"vrf": r.vrf, "source": r.source, "networks": r.networks} for r in vrf_rows + ], } @@ -448,6 +514,13 @@ def export_batch_zip(db: Session, batch_id: str) -> bytes: ) ) zf.writestr("tables/lldp_neighbor.csv", "\n".join(csv_lines) + "\n") + + vrf_csv = ["vrf,source,networks"] + for r in detail.get("vrf_route_summary") or []: + vrf_csv.append( + ",".join([_csv(r["vrf"]), _csv(r["source"]), _csv(str(r["networks"]))]) + ) + zf.writestr("tables/vrf_route_summary.csv", "\n".join(vrf_csv) + "\n") return buf.getvalue() diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index 77d6d80..443fa56 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -63,14 +63,31 @@ class PreviewIn(BaseModel): items: list[TaskItemIn] = Field(default_factory=list) +class DiscoverIn(BaseModel): + source: str = "managed" + ne_id: str = "" + task_id: str = "" + discover_profile_id: str = "" + collect_profile_id: str = "" + placeholder: str = "" + + +class BindingsIn(BaseModel): + bindings: list[dict[str, str]] = Field(default_factory=list) + + @router.get("/profiles") def api_list_profiles( vendor: str = "", device_type: str = "", + kind: str = "", db: Session = Depends(get_db), ) -> dict[str, Any]: vkey = resolve_vendor_key(vendor, device_type) if (vendor or device_type) else "" - return {"items": svc.list_profiles_public(db, vendor_key=vkey)} + items = svc.list_profiles_public(db, vendor_key=vkey) + if kind: + items = [p for p in items if str(p.get("kind") or "") == kind] + return {"items": items} @router.patch("/profiles/{profile_id}") @@ -95,6 +112,31 @@ def api_preview_items( } +@router.post("/discover") +def api_discover(body: DiscoverIn, db: Session = Depends(get_db)) -> dict[str, Any]: + from .biz_state.discover import discover_params + + return discover_params( + db, + source=body.source, + ne_id=body.ne_id, + task_id=body.task_id, + discover_profile_id=body.discover_profile_id, + collect_profile_id=body.collect_profile_id, + placeholder=body.placeholder, + ) + + +@router.put("/tasks/{task_id}/items/{item_id}/bindings") +def api_set_bindings( + task_id: str, + item_id: str, + body: BindingsIn, + db: Session = Depends(get_db), +) -> dict[str, Any]: + return svc.set_item_bindings(db, task_id, item_id, list(body.bindings or [])) + + @router.get("/tasks") def api_list_tasks(db: Session = Depends(get_db)) -> dict[str, Any]: return {"items": svc.list_tasks(db)} diff --git a/netx_api/cli_templates/index b/netx_api/cli_templates/index index 1006415..37c292a 100644 --- a/netx_api/cli_templates/index +++ b/netx_api/cli_templates/index @@ -6,6 +6,7 @@ Template, Hostname, Platform, Command zte/zte_zxros_show_lldp_neighbor_brief.textfsm, .*, zte_zxros, sh[[ow]] lld[[p]] nei[[ghbor]] br[[ief]] zte/zte_zxros_show_interface_brief.textfsm, .*, zte_zxros, sh[[ow]] int[[erface]] br[[ief]] zte/zte_zxros_show_interface.textfsm, .*, zte_zxros, sh[[ow]] int[[erface]] .+ +zte/zte_zxros_show_ip_vrf.textfsm, .*, zte_zxros, sh[[ow]] ip vrf$ # --- cisco --- cisco/cisco_ios_show_interfaces.textfsm, .*, cisco_ios, sh[[ow]] int[[erfaces]] .+ diff --git a/netx_api/cli_templates/zte/zte_zxros_show_ip_vrf.textfsm b/netx_api/cli_templates/zte/zte_zxros_show_ip_vrf.textfsm new file mode 100644 index 0000000..3aa87b4 --- /dev/null +++ b/netx_api/cli_templates/zte/zte_zxros_show_ip_vrf.textfsm @@ -0,0 +1,21 @@ +Value Required NAME (\S+) +Value RD (\S+) +Value INTERFACES (.*) + +Start + ^Name\s+Default\s+RD -> Records + ^VRF\s+Name\s+RD -> Records + ^-+ + ^\s*$$ + ^.* -> Start + +Records + ^${NAME}\s+${RD}\s+${INTERFACES}\s*$$ -> Record + ^${NAME}\s+${RD}\s*$$ -> Record + ^${NAME}\s*$$ -> Record + ^-+ + ^Total + ^\s*$$ + ^\S+\s*#\s*$$ -> End + ^.*#\s*$$ -> End + ^.* -> Records diff --git a/netx_api/models/__init__.py b/netx_api/models/__init__.py index cda7c6c..57806a1 100644 --- a/netx_api/models/__init__.py +++ b/netx_api/models/__init__.py @@ -35,6 +35,7 @@ from .biz_state import ( BizStateCommandOverride, BizStateEvent, BizStateLldpNeighbor, + BizStateVrfRouteSummary, BizStateTask, BizStateTaskItem, BizStateTaskItemBinding, @@ -133,6 +134,7 @@ __all__ = [ "BizStateBatch", "BizStateBatchCommand", "BizStateLldpNeighbor", + "BizStateVrfRouteSummary", "BizStateEvent", "BizStateCommandOverride", "BizCompareTemplate", diff --git a/netx_api/models/biz_state.py b/netx_api/models/biz_state.py index 418f485..f86ce55 100644 --- a/netx_api/models/biz_state.py +++ b/netx_api/models/biz_state.py @@ -126,6 +126,25 @@ class BizStateLldpNeighbor(Base): collected_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True) +class BizStateVrfRouteSummary(Base): + """Per-VRF route source counts for a batch.""" + + __tablename__ = "biz_state_vrf_route_summary" + __table_args__ = ( + UniqueConstraint("batch_id", "vrf", "source", name="uq_biz_vrf_route_row"), + ) + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + batch_id: Mapped[str] = mapped_column(String(64), default="", index=True) + batch_command_id: Mapped[str] = mapped_column(String(64), default="", index=True) + task_id: Mapped[str] = mapped_column(String(64), default="", index=True) + ne_id: Mapped[str] = mapped_column(String(128), default="", index=True) + vrf: Mapped[str] = mapped_column(String(128), default="", index=True) + source: Mapped[str] = mapped_column(String(64), default="", index=True) + networks: Mapped[int] = mapped_column(Integer, default=0) + collected_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True) + + class BizStateEvent(Base): __tablename__ = "biz_state_event" diff --git a/tests/test_biz_state_vrf.py b/tests/test_biz_state_vrf.py new file mode 100644 index 0000000..f7e8073 --- /dev/null +++ b/tests/test_biz_state_vrf.py @@ -0,0 +1,80 @@ +"""Unit tests for Phase3 VRF binding expand / route summary parse.""" + +from __future__ import annotations + +import unittest + +from netx_api.biz_state.command_match import expand_from_bindings, match_command, preview_task_item +from netx_api.biz_state.parsers.vrf import normalize_vrf_list, normalize_vrf_route_summary +from netx_api.biz_state.profiles import get_profile, profiles_for_vendor, reload_profiles + + +class BizStateVrfTests(unittest.TestCase): + def setUp(self) -> None: + reload_profiles() + + def test_vrf_profiles_registered(self) -> None: + cisco = profiles_for_vendor("cisco") + self.assertTrue(any(p.profile_id == "cisco.vrf_list" and p.kind == "discover" for p in cisco)) + self.assertTrue(any(p.profile_id == "cisco.route_vrf_summary" for p in cisco)) + + def test_expand_multi_vrf_bindings(self) -> None: + p = get_profile("cisco.route_vrf_summary") + assert p is not None + pairs = expand_from_bindings( + profile=p, + bindings=[ + {"placeholder": "vrf", "value": "CUST_A"}, + {"placeholder": "vrf", "value": "CUST_B"}, + ], + ) + self.assertEqual(len(pairs), 2) + self.assertEqual(pairs[0][0], "show ip route vrf CUST_A summary") + self.assertEqual(pairs[1][0], "show ip route vrf CUST_B summary") + hit = match_command(vendor_key="cisco", command=pairs[0][0]) + self.assertIsNotNone(hit) + assert hit is not None + self.assertEqual(hit.params.get("vrf"), "CUST_A") + + def test_expand_requires_bindings(self) -> None: + p = get_profile("zte.route_vrf_summary") + assert p is not None + with self.assertRaises(ValueError): + expand_from_bindings(profile=p, bindings=[]) + + def test_preview_with_bindings(self) -> None: + prev = preview_task_item( + vendor_key="huawei", + profile_id="huawei.route_vrf_summary", + bindings=[{"vrf": "VPN1"}], + ) + self.assertTrue(prev["ok"]) + self.assertEqual(len(prev["commands"]), 1) + self.assertIn("VPN1", prev["commands"][0]["command"]) + + def test_route_summary_parser(self) -> None: + raw = """ +IP routing table name is CUST_A +Route Source Networks +connected 3 +static 1 +bgp 65001 42 +Total 46 +""" + rows = normalize_vrf_route_summary(raw_text=raw, params={"vrf": "CUST_A"}) + by_src = {r["source"]: r["networks"] for r in rows} + self.assertEqual(by_src["connected"], 3) + self.assertEqual(by_src["bgp"], 42) + self.assertEqual(by_src["total"], 46) + self.assertTrue(all(r["vrf"] == "CUST_A" for r in rows)) + + def test_vrf_list_fallback(self) -> None: + raw = "CUST_A 100:1 ipv4\nCUST_B 100:2 ipv4\n" + rows = normalize_vrf_list(raw_text=raw, command="") + names = {r["vrf_name"] for r in rows} + self.assertIn("CUST_A", names) + self.assertIn("CUST_B", names) + + +if __name__ == "__main__": + unittest.main() diff --git a/web/WEB.md b/web/WEB.md index 9bfc4d4..70fdf2e 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -44,7 +44,7 @@ src/ | `/network/tasks/config-sync` | 配置同步 | `network` | | `/network/tasks/port-traffic` | 端口流量监控(设备管理) | `network` | | `/network/tasks/port-traffic/wall` | 流量大屏列表(打开独立页签) | `network` | -| `/network/tasks/biz-state` | 业务状态监控(LLDP 快照 Phase1) | `network` | +| `/network/tasks/biz-state` | 业务状态监控(LLDP / VRF 关联采集) | `network` | | `/network/tasks/biz-compare` | 业务状态比对(模板/映射/前后 diff) | `network` | | `/port-traffic/wall/:boardId` | 流量大屏专有页签(无网络侧栏) | `port-traffic-wall` | | `/topology` | 拓扑管理(模式化编辑器:选择/平移/拖动/连线、框选、自动布局、拖放添加) | `topology` | @@ -128,13 +128,13 @@ src/ ## 业务状态监控(biz_state) -- API:`/v1/biz-state/profiles`、`/tasks*`、`/batches*`、`/batches/{id}/export` +- API:`/v1/biz-state/profiles`、`/discover`、`/tasks*`、`/batches*`、`/batches/{id}/export`、`/compare/*` - **ParseProfile**:命令模板 + TextFSM + 回调 + schema;LLDP 与拓扑 **共享解析**(`lldp_shared`),业务流程写批次表,拓扑写 Fabric -- Phase1 样板:LLDP 邻居快照;建任务默认启用对应厂商 LLDP profile;支持自定义只采不解析行 -- 调度:`NETX_BIZ_STATE_SCHEDULER_ENABLED`(默认开),tick `NETX_BIZ_STATE_SCHEDULER_TICK_SEC` -- 前端:`/network/tasks/biz-state`(任务列表 / 启用项 / 批次 / 导出 zip) +- Phase1:LLDP 邻居快照;建任务默认启用对应厂商 LLDP profile;支持自定义只采不解析行 - Phase2:比对模板、端口映射、CompareJob(manual/auto)、`/network/tasks/biz-compare` -- Phase3(未做):占位符发现→人选关联(如 VRF) +- Phase3:VRF 发现→人选关联→带参采集(`*.route_vrf_summary`);`PUT …/items/{id}/bindings` +- 调度:`NETX_BIZ_STATE_SCHEDULER_ENABLED`(默认开),tick `NETX_BIZ_STATE_SCHEDULER_TICK_SEC` +- 前端:`/network/tasks/biz-state`(勾选监控项 / 发现 VRF / 批次 / 导出 zip) ## 拓扑管理(Fabric + 站点目录,对齐厂商) diff --git a/web/src/pages/network/BizStatePage.tsx b/web/src/pages/network/BizStatePage.tsx index a77d001..2b35157 100644 --- a/web/src/pages/network/BizStatePage.tsx +++ b/web/src/pages/network/BizStatePage.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { bizStateCollectNow, bizStateCreateTask, bizStateDeleteTask, + bizStateDiscover, bizStateDownloadExport, bizStateGetBatch, bizStateGetTask, @@ -10,6 +11,7 @@ import { bizStateListProfiles, bizStateListTasks, bizStatePatchTask, + bizStateSetBindings, fetchManagedNe, } from "../../services/api"; import type { ManagedNeItem } from "../../types"; @@ -25,12 +27,21 @@ type TaskRow = { last_collect_ended_at?: string | null; }; +type Placeholder = { + name: string; + bind_mode?: string; + discover_profile_id?: string; + discover_value_field?: string; +}; + type Profile = { profile_id: string; title: string; command_template: string; description: string; metric_id: string; + kind?: string; + placeholders?: Placeholder[]; }; type BatchRow = { @@ -41,6 +52,8 @@ type BatchRow = { started_at?: string | null; }; +type Candidate = { value: string; label: string; rd?: string }; + export function BizStatePage() { const [tasks, setTasks] = useState([]); const [selectedId, setSelectedId] = useState(""); @@ -52,6 +65,10 @@ export function BizStatePage() { const [neId, setNeId] = useState(""); const [busy, setBusy] = useState(false); const [err, setErr] = useState(""); + const [bindItemId, setBindItemId] = useState(""); + const [candidates, setCandidates] = useState([]); + const [selectedVrfs, setSelectedVrfs] = useState([]); + const [discoverCmd, setDiscoverCmd] = useState(""); const refreshTasks = useCallback(async () => { const res = await bizStateListTasks(); @@ -75,9 +92,18 @@ export function BizStatePage() { } })(); }, [refreshTasks]); + + const collectProfiles = useMemo( + () => profiles.filter((p) => (p.kind || "collect") === "collect"), + [profiles], + ); + const openTask = async (id: string) => { setSelectedId(id); setBatchDetail(null); + setBindItemId(""); + setCandidates([]); + setSelectedVrfs([]); setErr(""); try { const t = await bizStateGetTask(id); @@ -140,7 +166,6 @@ export function BizStatePage() { await bizStateCollectNow(selectedId); await openTask(selectedId); await refreshTasks(); - // Poll a few times while collect_running for (let i = 0; i < 20; i++) { await new Promise((r) => setTimeout(r, 1500)); const t = await bizStateGetTask(selectedId); @@ -186,11 +211,101 @@ export function BizStatePage() { } }; + const toggleProfileItem = async (profile: Profile, enable: boolean) => { + if (!selectedId || !detail) return; + const items = [...(detail.items || [])]; + const idx = items.findIndex((it: any) => it.source_profile_id === profile.profile_id); + if (enable) { + if (idx >= 0) { + items[idx] = { ...items[idx], enabled: true }; + } else { + items.push({ + source_profile_id: profile.profile_id, + kind: "catalog", + enabled: true, + title: profile.title, + bindings: [], + }); + } + } else if (idx >= 0) { + items[idx] = { ...items[idx], enabled: false }; + } + setBusy(true); + try { + await bizStatePatchTask(selectedId, { items }); + await openTask(selectedId); + } catch (e: any) { + setErr(String(e?.message || e)); + } finally { + setBusy(false); + } + }; + + const startDiscover = async (item: any) => { + if (!selectedId || !detail) return; + const prof = profiles.find((p) => p.profile_id === item.source_profile_id); + const ph = (prof?.placeholders || [])[0]; + if (!ph) { + setErr("该监控项无需参数关联"); + return; + } + setBusy(true); + setErr(""); + setBindItemId(item.id); + try { + const res = await bizStateDiscover({ + task_id: selectedId, + collect_profile_id: item.source_profile_id, + placeholder: ph.name, + }); + if (!res.ok) { + setErr(res.error || "discover failed"); + setCandidates([]); + return; + } + setDiscoverCmd(res.command || ""); + const cand = (res.candidates || []) as Candidate[]; + setCandidates(cand); + const existing = (item.bindings || []) + .filter((b: any) => b.placeholder === ph.name) + .map((b: any) => String(b.value)); + setSelectedVrfs(existing.length ? existing : cand.map((c) => c.value)); + } catch (e: any) { + setErr(String(e?.message || e)); + } finally { + setBusy(false); + } + }; + + const saveBindings = async () => { + if (!selectedId || !bindItemId) return; + const item = (detail?.items || []).find((it: any) => it.id === bindItemId); + const prof = profiles.find((p) => p.profile_id === item?.source_profile_id); + const phName = (prof?.placeholders || [])[0]?.name || "vrf"; + setBusy(true); + try { + await bizStateSetBindings( + selectedId, + bindItemId, + selectedVrfs.map((v) => ({ placeholder: phName, value: v })), + ); + await openTask(selectedId); + setBindItemId(""); + setCandidates([]); + } catch (e: any) { + setErr(String(e?.message || e)); + } finally { + setBusy(false); + } + }; + return (

业务状态监控

-

Phase1:LLDP 邻居快照采集(共享解析,独立批次;比对 Phase2)

+

+ LLDP 快照 + VRF 发现关联(选 VRF 后采路由摘要)+ 割接比对见「业务状态比对」 +

{err ?
{err}
: null} @@ -227,7 +342,10 @@ export function BizStatePage() { void openTask(t.id)} - style={{ cursor: "pointer", background: selectedId === t.id ? "var(--wb-row-active, #eef)" : undefined }} + style={{ + cursor: "pointer", + background: selectedId === t.id ? "var(--wb-row-active, #eef)" : undefined, + }} > {t.ne_name || t.ne_ip} @@ -275,40 +393,107 @@ export function BizStatePage() {
{detail.last_error ?
{detail.last_error}
: null} -

启用项(勾选表)

+

可用监控项

- - + + + - {(detail.items || []).map((it: any) => { - const prof = profiles.find((p) => p.profile_id === it.source_profile_id); + {collectProfiles.map((prof) => { + const it = (detail.items || []).find( + (x: any) => x.source_profile_id === prof.profile_id, + ); + const enabled = Boolean(it?.enabled); + const binds = it?.bindings || []; + const needsBind = (prof.placeholders || []).length > 0; return ( - - + + + + + - - ); })}
启用 项命令类型模板参数
{it.enabled ? "✓" : ""}
- {it.title || prof?.title || it.source_profile_id} - {prof?.description ? ( -
- {prof.description} -
+ void toggleProfileItem(prof, e.target.checked)} + /> +
+ {prof.title} +
+ {prof.description} +
+
+ {prof.command_template} + + {needsBind + ? binds.length + ? binds.map((b: any) => b.value).join(", ") + : "未关联" + : "—"} + + {needsBind && enabled && it ? ( + ) : null} - {it.command_override || prof?.command_template || "-"} - {it.kind}
+ {bindItemId && candidates.length ? ( +
+

选择 VRF 绑定

+

+ 命令 {discoverCmd} · 已选 {selectedVrfs.length} +

+
+ {candidates.map((c) => ( + + ))} +
+
+ + +
+
+ ) : null} +

采集批次

@@ -351,26 +536,54 @@ export function BizStatePage() {

命令 {batchDetail.command_count} · 行 {batchDetail.row_count} · {batchDetail.status}

-
- - - - - - - - - - {(batchDetail.lldp_neighbors || []).slice(0, 200).map((n: any, i: number) => ( - - - - - - - ))} - -
本端口对端系统对端口管理IP
{n.local_if}{n.remote_sys}{n.remote_if}{n.remote_ip}
+ {(batchDetail.lldp_neighbors || []).length ? ( + <> +
LLDP
+ + + + + + + + + + + {(batchDetail.lldp_neighbors || []).slice(0, 200).map((n: any, i: number) => ( + + + + + + + ))} + +
本端口对端系统对端口管理IP
{n.local_if}{n.remote_sys}{n.remote_if}{n.remote_ip}
+ + ) : null} + {(batchDetail.vrf_route_summary || []).length ? ( + <> +
VRF 路由摘要
+ + + + + + + + + + {(batchDetail.vrf_route_summary || []).map((r: any, i: number) => ( + + + + + + ))} + +
VRF来源条数
{r.vrf}{r.source}{r.networks}
+ + ) : null} ) : null} diff --git a/web/src/services/api.ts b/web/src/services/api.ts index ca4017e..db6908d 100644 --- a/web/src/services/api.ts +++ b/web/src/services/api.ts @@ -1756,14 +1756,40 @@ export const deletePortTrafficBoard = (boardId: string) => /* ---- biz_state (business state monitoring) ---- */ -export const bizStateListProfiles = (params?: { vendor?: string; device_type?: string }) => { - const p = new URLSearchParams(); - if (params?.vendor?.trim()) p.set("vendor", params.vendor.trim()); - if (params?.device_type?.trim()) p.set("device_type", params.device_type.trim()); - const q = p.toString(); - return apiGet<{ items: Record[] }>(`/v1/biz-state/profiles${q ? `?${q}` : ""}`); +export const bizStateListProfiles = (params?: { + vendor?: string; + device_type?: string; + kind?: string; +}) => { + const q = new URLSearchParams(); + if (params?.vendor) q.set("vendor", params.vendor); + if (params?.device_type) q.set("device_type", params.device_type); + if (params?.kind) q.set("kind", params.kind); + const qs = q.toString(); + return apiGet<{ items: Record[] }>( + `/v1/biz-state/profiles${qs ? `?${qs}` : ""}`, + ); }; +export const bizStateDiscover = (body: Record) => + apiPost<{ + ok: boolean; + error?: string; + command?: string; + candidates?: Array<{ value: string; label: string; rd?: string; protocols?: string }>; + raw_preview?: string; + }>("/v1/biz-state/discover", body); + +export const bizStateSetBindings = ( + taskId: string, + itemId: string, + bindings: Array<{ placeholder: string; value: string }>, +) => + apiPut>( + `/v1/biz-state/tasks/${encodeURIComponent(taskId)}/items/${encodeURIComponent(itemId)}/bindings`, + { bindings }, + ); + export const bizStateListTasks = () => apiGet<{ items: Record[] }>("/v1/biz-state/tasks");