Add biz_state Phase3 VRF discover-select and parameterized collect.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-17 16:37:31 +08:00
parent 1e2cc98d49
commit 06331ca763
18 changed files with 1051 additions and 87 deletions

View file

@ -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:

View file

@ -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

View file

@ -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(

View 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],
}

View file

@ -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,
}

View 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

View file

@ -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:

View file

@ -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)

View file

@ -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()

View file

@ -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)}

View file

@ -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]] .+

View 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

View file

@ -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",

View file

@ -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"