mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Add biz_state Phase3 VRF discover-select and parameterized collect.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
1e2cc98d49
commit
06331ca763
18 changed files with 1051 additions and 87 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
175
netx_api/biz_state/discover.py
Normal file
175
netx_api/biz_state/discover.py
Normal file
|
|
@ -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],
|
||||
}
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
109
netx_api/biz_state/parsers/vrf.py
Normal file
109
netx_api/biz_state/parsers/vrf.py
Normal file
|
|
@ -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+|<not set>|\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
|
||||
|
|
@ -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 <vrf> summary",
|
||||
r"(?i)^\s*show\s+ip\s+route\s+vrf\s+(?P<vrf>\S+)\s+summary\s*$",
|
||||
),
|
||||
"huawei": (
|
||||
"display ip routing-table vpn-instance <vrf> statistics",
|
||||
r"(?i)^\s*display\s+ip\s+routing-table\s+vpn-instance\s+(?P<vrf>\S+)\s+statistics\s*$",
|
||||
),
|
||||
"h3c": (
|
||||
"display ip routing-table vpn-instance <vrf> statistics",
|
||||
r"(?i)^\s*display\s+ip\s+routing-table\s+vpn-instance\s+(?P<vrf>\S+)\s+statistics\s*$",
|
||||
),
|
||||
"zte": (
|
||||
"show ip route vrf <vrf> summary",
|
||||
r"(?i)^\s*show\s+ip\s+route\s+vrf\s+(?P<vrf>\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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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]] .+
|
||||
|
|
|
|||
21
netx_api/cli_templates/zte/zte_zxros_show_ip_vrf.textfsm
Normal file
21
netx_api/cli_templates/zte/zte_zxros_show_ip_vrf.textfsm
Normal file
|
|
@ -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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
80
tests/test_biz_state_vrf.py
Normal file
80
tests/test_biz_state_vrf.py
Normal file
|
|
@ -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()
|
||||
12
web/WEB.md
12
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 + 站点目录,对齐厂商)
|
||||
|
||||
|
|
|
|||
|
|
@ -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<TaskRow[]>([]);
|
||||
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<Candidate[]>([]);
|
||||
const [selectedVrfs, setSelectedVrfs] = useState<string[]>([]);
|
||||
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 (
|
||||
<div className="wb-page">
|
||||
<div className="wb-page__header">
|
||||
<h1>业务状态监控</h1>
|
||||
<p className="wb-muted">Phase1:LLDP 邻居快照采集(共享解析,独立批次;比对 Phase2)</p>
|
||||
<p className="wb-muted">
|
||||
LLDP 快照 + VRF 发现关联(选 VRF 后采路由摘要)+ 割接比对见「业务状态比对」
|
||||
</p>
|
||||
</div>
|
||||
{err ? <div className="wb-alert wb-alert--error">{err}</div> : null}
|
||||
|
||||
|
|
@ -227,7 +342,10 @@ export function BizStatePage() {
|
|||
<tr
|
||||
key={t.id}
|
||||
onClick={() => 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,
|
||||
}}
|
||||
>
|
||||
<td>
|
||||
{t.ne_name || t.ne_ip}
|
||||
|
|
@ -275,40 +393,107 @@ export function BizStatePage() {
|
|||
</div>
|
||||
{detail.last_error ? <div className="wb-alert wb-alert--error">{detail.last_error}</div> : null}
|
||||
|
||||
<h4>启用项(勾选表)</h4>
|
||||
<h4>可用监控项</h4>
|
||||
<table className="wb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>启用</th>
|
||||
<th>项</th>
|
||||
<th>命令</th>
|
||||
<th>类型</th>
|
||||
<th>模板</th>
|
||||
<th>参数</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(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 (
|
||||
<tr key={it.id}>
|
||||
<td>{it.enabled ? "✓" : ""}</td>
|
||||
<tr key={prof.profile_id}>
|
||||
<td>
|
||||
{it.title || prof?.title || it.source_profile_id}
|
||||
{prof?.description ? (
|
||||
<div className="wb-muted" style={{ fontSize: 12 }}>
|
||||
{prof.description}
|
||||
</div>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={enabled}
|
||||
disabled={busy}
|
||||
onChange={(e) => void toggleProfileItem(prof, e.target.checked)}
|
||||
/>
|
||||
</td>
|
||||
<td>
|
||||
{prof.title}
|
||||
<div className="wb-muted" style={{ fontSize: 12 }}>
|
||||
{prof.description}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<code>{prof.command_template}</code>
|
||||
</td>
|
||||
<td>
|
||||
{needsBind
|
||||
? binds.length
|
||||
? binds.map((b: any) => b.value).join(", ")
|
||||
: "未关联"
|
||||
: "—"}
|
||||
</td>
|
||||
<td>
|
||||
{needsBind && enabled && it ? (
|
||||
<button type="button" disabled={busy} onClick={() => void startDiscover(it)}>
|
||||
发现 VRF
|
||||
</button>
|
||||
) : null}
|
||||
</td>
|
||||
<td>
|
||||
<code>{it.command_override || prof?.command_template || "-"}</code>
|
||||
</td>
|
||||
<td>{it.kind}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{bindItemId && candidates.length ? (
|
||||
<div style={{ marginTop: 12, padding: 12, border: "1px solid var(--wb-border, #ddd)" }}>
|
||||
<h4>选择 VRF 绑定</h4>
|
||||
<p className="wb-muted">
|
||||
命令 <code>{discoverCmd}</code> · 已选 {selectedVrfs.length}
|
||||
</p>
|
||||
<div style={{ maxHeight: 220, overflow: "auto", marginBottom: 8 }}>
|
||||
{candidates.map((c) => (
|
||||
<label key={c.value} style={{ display: "block", marginBottom: 4 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedVrfs.includes(c.value)}
|
||||
onChange={(e) => {
|
||||
setSelectedVrfs((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, c.value]
|
||||
: prev.filter((x) => x !== c.value),
|
||||
);
|
||||
}}
|
||||
/>{" "}
|
||||
{c.label}
|
||||
{c.rd ? <span className="wb-muted"> · RD {c.rd}</span> : null}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button type="button" disabled={busy || !selectedVrfs.length} onClick={() => void saveBindings()}>
|
||||
保存关联
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
setBindItemId("");
|
||||
setCandidates([]);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<h4 style={{ marginTop: 16 }}>采集批次</h4>
|
||||
<table className="wb-table">
|
||||
<thead>
|
||||
|
|
@ -351,26 +536,54 @@ export function BizStatePage() {
|
|||
<p className="wb-muted">
|
||||
命令 {batchDetail.command_count} · 行 {batchDetail.row_count} · {batchDetail.status}
|
||||
</p>
|
||||
<table className="wb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>本端口</th>
|
||||
<th>对端系统</th>
|
||||
<th>对端口</th>
|
||||
<th>管理IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(batchDetail.lldp_neighbors || []).slice(0, 200).map((n: any, i: number) => (
|
||||
<tr key={i}>
|
||||
<td>{n.local_if}</td>
|
||||
<td>{n.remote_sys}</td>
|
||||
<td>{n.remote_if}</td>
|
||||
<td>{n.remote_ip}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
{(batchDetail.lldp_neighbors || []).length ? (
|
||||
<>
|
||||
<h5>LLDP</h5>
|
||||
<table className="wb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>本端口</th>
|
||||
<th>对端系统</th>
|
||||
<th>对端口</th>
|
||||
<th>管理IP</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(batchDetail.lldp_neighbors || []).slice(0, 200).map((n: any, i: number) => (
|
||||
<tr key={i}>
|
||||
<td>{n.local_if}</td>
|
||||
<td>{n.remote_sys}</td>
|
||||
<td>{n.remote_if}</td>
|
||||
<td>{n.remote_ip}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : null}
|
||||
{(batchDetail.vrf_route_summary || []).length ? (
|
||||
<>
|
||||
<h5>VRF 路由摘要</h5>
|
||||
<table className="wb-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>VRF</th>
|
||||
<th>来源</th>
|
||||
<th>条数</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(batchDetail.vrf_route_summary || []).map((r: any, i: number) => (
|
||||
<tr key={i}>
|
||||
<td>{r.vrf}</td>
|
||||
<td>{r.source}</td>
|
||||
<td>{r.networks}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>[] }>(`/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<string, unknown>[] }>(
|
||||
`/v1/biz-state/profiles${qs ? `?${qs}` : ""}`,
|
||||
);
|
||||
};
|
||||
|
||||
export const bizStateDiscover = (body: Record<string, unknown>) =>
|
||||
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<Record<string, unknown>>(
|
||||
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/items/${encodeURIComponent(itemId)}/bindings`,
|
||||
{ bindings },
|
||||
);
|
||||
|
||||
export const bizStateListTasks = () =>
|
||||
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-state/tasks");
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue