Wire BGP neighbor in/out discover to config BGP peer intent.

Filter peers by AFI (and VRF pairs for CE), support zip/pair bindings in discover UI, and tag FIB rows with address_families.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-21 20:19:29 +08:00
parent 3c3013dd73
commit dd8d46aebc
10 changed files with 490 additions and 110 deletions

View file

@ -62,7 +62,12 @@ def normalize_binding_dicts(
*, *,
placeholders: list | None = None, placeholders: list | None = None,
) -> list[dict[str, str]]: ) -> list[dict[str, str]]:
"""Accept ``{vrf: X}`` or ``{placeholder, value}`` rows; expand multi-value placeholders.""" """Accept ``{vrf: X}`` or ``{placeholder, value}`` rows; expand multi-value placeholders.
Multi-placeholder rows stored as interleaved ``{placeholder,value}`` pairs are
zipped in order (e.g. vrf/neighbor/vrf/neighbor → combined dicts). Already
combined dicts (multiple keys) are kept as-is.
"""
raw = list(bindings or []) raw = list(bindings or [])
converted: list[dict[str, str]] = [] converted: list[dict[str, str]] = []
for b in raw: for b in raw:
@ -85,6 +90,33 @@ def normalize_binding_dicts(
if val: if val:
out.append({name: val}) out.append({name: val})
return out return out
if len(phs) >= 2:
ph_names = [str(getattr(ph, "name", "") or "").strip() for ph in phs]
ph_names = [n for n in ph_names if n]
combined: list[dict[str, str]] = []
singles: list[dict[str, str]] = []
for c in converted:
if len(c) > 1:
combined.append(dict(c))
else:
singles.append(c)
if combined and not singles:
return combined
if singles and ph_names:
from collections import defaultdict
by_ph: dict[str, list[str]] = defaultdict(list)
for c in singles:
k, v = next(iter(c.items()))
by_ph[k].append(v)
lists = [by_ph.get(n, []) for n in ph_names]
n0 = len(lists[0]) if lists else 0
if n0 and all(len(L) == n0 for L in lists):
zipped = [dict(zip(ph_names, vals)) for vals in zip(*lists)]
return combined + zipped if combined else zipped
if combined:
return combined
return converted return converted
@ -100,23 +132,32 @@ def _optional_discover_placeholders(profile: ParseProfile) -> list[PlaceholderDe
return [ph for ph in _discover_placeholders(profile) if not ph.required] return [ph for ph in _discover_placeholders(profile) if not ph.required]
def _record_passes_discover_filter(rec: dict[str, Any], ph: PlaceholderDef) -> bool:
filt_field = str(ph.discover_filter_field or "").strip()
filt_contains = str(ph.discover_filter_contains or "").strip().lower()
if filt_field and filt_contains:
hay = str(rec.get(filt_field) or "").strip().lower()
if filt_contains not in hay:
return False
require = str(ph.discover_require_nonempty or "").strip()
if require and not str(rec.get(require) or "").strip():
return False
return True
def filter_discover_records( def filter_discover_records(
records: list[dict[str, Any]] | None, records: list[dict[str, Any]] | None,
ph: PlaceholderDef, ph: PlaceholderDef,
) -> list[str]: ) -> list[str]:
"""Apply placeholder discover filter; return unique values for ``ph``.""" """Apply placeholder discover filter; return unique values for ``ph``."""
value_field = str(ph.discover_value_field or ph.name or "").strip() or "vrf_name" value_field = str(ph.discover_value_field or ph.name or "").strip() or "vrf_name"
filt_field = str(ph.discover_filter_field or "").strip()
filt_contains = str(ph.discover_filter_contains or "").strip().lower()
out: list[str] = [] out: list[str] = []
seen: set[str] = set() seen: set[str] = set()
for rec in records or []: for rec in records or []:
if not isinstance(rec, dict): if not isinstance(rec, dict):
continue continue
if filt_field and filt_contains: if not _record_passes_discover_filter(rec, ph):
hay = str(rec.get(filt_field) or "").strip().lower() continue
if filt_contains not in hay:
continue
val = str(rec.get(value_field) or "").strip() val = str(rec.get(value_field) or "").strip()
if not val or val in seen: if not val or val in seen:
continue continue
@ -125,6 +166,17 @@ def filter_discover_records(
return out return out
def shared_discover_placeholders(profile: ParseProfile) -> list[PlaceholderDef]:
"""Placeholders that share one discover_profile_id (pair/tuple bind)."""
discover_phs = _discover_placeholders(profile)
if len(discover_phs) < 2:
return []
disc_ids = {str(ph.discover_profile_id or "").strip() for ph in discover_phs}
if len(disc_ids) != 1 or not next(iter(disc_ids)):
return []
return discover_phs
def expand_from_bindings( def expand_from_bindings(
*, *,
profile: ParseProfile, profile: ParseProfile,
@ -176,12 +228,52 @@ def expand_bindings_from_discover_records(
profile: ParseProfile, profile: ParseProfile,
records: list[dict[str, Any]] | None, records: list[dict[str, Any]] | None,
) -> list[tuple[str, dict[str, str]]]: ) -> list[tuple[str, dict[str, str]]]:
"""Build concrete commands from discover/parser records (e.g. config_vrf).""" """Build concrete commands from discover/parser records (e.g. config_vrf).
Supports a single discover placeholder, or multiple placeholders that share
the same discover_profile_id (zipped row-wise from each record).
"""
discover_phs = _discover_placeholders(profile) discover_phs = _discover_placeholders(profile)
if not discover_phs: if not discover_phs:
raise ValueError(f"profile {profile.profile_id} has no discover placeholders") raise ValueError(f"profile {profile.profile_id} has no discover placeholders")
shared = shared_discover_placeholders(profile)
if shared and len(shared) == len(profile.placeholders):
ph0 = shared[0]
bindings: list[dict[str, str]] = []
seen: set[tuple[str, ...]] = set()
for rec in records or []:
if not isinstance(rec, dict):
continue
if not _record_passes_discover_filter(rec, ph0):
continue
params: dict[str, str] = {}
ok = True
for ph in shared:
vf = str(ph.discover_value_field or ph.name or "").strip()
val = str(rec.get(vf) or "").strip()
if not val:
ok = False
break
params[ph.name] = val
if not ok:
continue
key = tuple(params.get(ph.name, "") for ph in shared)
if key in seen:
continue
seen.add(key)
bindings.append(params)
if not bindings:
raise ValueError(
f"no discover values for {profile.profile_id} ({ph0.discover_profile_id})"
)
return expand_from_bindings(profile=profile, bindings=bindings)
if len(discover_phs) != 1 or len(profile.placeholders) != 1: if len(discover_phs) != 1 or len(profile.placeholders) != 1:
raise ValueError(f"expand-from-discover only supports a single placeholder: {profile.profile_id}") raise ValueError(
f"expand-from-discover only supports a single placeholder "
f"or shared multi-placeholder: {profile.profile_id}"
)
ph = discover_phs[0] ph = discover_phs[0]
values = filter_discover_records(records, ph) values = filter_discover_records(records, ph)
if not values: if not values:

View file

@ -14,6 +14,10 @@ from ..lldp_shared import resolve_vendor_key
from ..models import BizStateTask from ..models import BizStateTask
from ..ne_netmiko import disable_target_paging, send_show_command from ..ne_netmiko import disable_target_paging, send_show_command
from ..ne_session_factory import close_netmiko_connection, open_netmiko_connection from ..ne_session_factory import close_netmiko_connection, open_netmiko_connection
from .command_match import (
_record_passes_discover_filter,
shared_discover_placeholders,
)
from .parsers import get_parser, run_parser from .parsers import get_parser, run_parser
from .profiles import get_profile from .profiles import get_profile
@ -71,17 +75,24 @@ def discover_params(
) )
value_field = "vrf_name" value_field = "vrf_name"
label_field = "vrf_name" label_field = "vrf_name"
if collect_profile_id: collect = get_profile(collect_profile_id) if collect_profile_id else None
collect = get_profile(collect_profile_id) pair_phs = shared_discover_placeholders(collect) if collect else []
if collect: active_ph = None
for ph in collect.placeholders: if collect:
if placeholder and ph.name != placeholder: for ph in collect.placeholders:
continue if placeholder and ph.name != placeholder:
if ph.discover_value_field: continue
value_field = ph.discover_value_field if ph.discover_value_field:
if ph.discover_label_field: value_field = ph.discover_value_field
label_field = ph.discover_label_field if ph.discover_label_field:
break label_field = ph.discover_label_field
active_ph = ph
break
# Pair discover: filter with the first shared placeholder's AF rules.
if pair_phs and not placeholder:
active_ph = pair_phs[0]
value_field = str(active_ph.discover_value_field or active_ph.name or value_field)
label_field = str(active_ph.discover_label_field or label_field)
try: try:
if src == "managed": if src == "managed":
@ -148,36 +159,78 @@ def discover_params(
candidates = [] candidates = []
seen: set[str] = set() seen: set[str] = set()
filt_field = "" # Shared discover profile → one candidate per (placeholder fields) tuple.
filt_contains = "" if pair_phs and len(pair_phs) >= 2:
if collect_profile_id: filter_ph = pair_phs[0]
collect = get_profile(collect_profile_id) for rec in records:
if collect: if not _record_passes_discover_filter(rec, filter_ph):
for ph in collect.placeholders: # Also require every paired field non-empty.
if placeholder and ph.name != placeholder:
continue
filt_field = str(ph.discover_filter_field or "").strip()
filt_contains = str(ph.discover_filter_contains or "").strip().lower()
break
for rec in records:
val = str(rec.get(value_field) or "").strip()
if not val or val in seen:
continue
if filt_field and filt_contains:
hay = str(rec.get(filt_field) or "").strip().lower()
if filt_contains not in hay:
continue continue
seen.add(val) bind: dict[str, str] = {}
label = str(rec.get(label_field) or val).strip() or val ok = True
candidates.append( for ph in pair_phs:
{ if not _record_passes_discover_filter(rec, ph):
"value": val, ok = False
"label": label, break
"rd": str(rec.get("rd") or ""), vf = str(ph.discover_value_field or ph.name or "").strip()
"protocols": str(rec.get("protocols") or rec.get("address_families") or ""), val = str(rec.get(vf) or "").strip()
"extra": rec, if not val:
} ok = False
) break
bind[ph.name] = val
if not ok:
continue
key = "|".join(f"{k}={bind[k]}" for k in sorted(bind))
if key in seen:
continue
seen.add(key)
as_num = str(rec.get("remote_as") or "").strip()
label_parts = [bind.get(ph.name, "") for ph in pair_phs]
label = " / ".join(p for p in label_parts if p)
if as_num:
label = f"{label} (AS {as_num})"
candidates.append(
{
"value": key,
"label": label,
"rd": str(rec.get("rd") or ""),
"protocols": str(
rec.get("protocols")
or rec.get("address_families")
or rec.get("afi")
or ""
),
"bindings": bind,
"extra": rec,
}
)
else:
for rec in records:
if active_ph and not _record_passes_discover_filter(rec, active_ph):
continue
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
as_num = str(rec.get("remote_as") or "").strip()
if as_num and value_field == "neighbor":
label = f"{label} (AS {as_num})"
candidates.append(
{
"value": val,
"label": label,
"rd": str(rec.get("rd") or ""),
"protocols": str(
rec.get("protocols")
or rec.get("address_families")
or rec.get("afi")
or ""
),
"bindings": {str(active_ph.name if active_ph else value_field): val},
"extra": rec,
}
)
return { return {
"ok": True, "ok": True,
@ -186,6 +239,7 @@ def discover_params(
"command": command, "command": command,
"vendor_key": resolve_vendor_key(vendor, device_type), "vendor_key": resolve_vendor_key(vendor, device_type),
"value_field": value_field, "value_field": value_field,
"pair_mode": bool(pair_phs and len(pair_phs) >= 2),
"candidates": candidates, "candidates": candidates,
"raw_preview": str(raw or "")[:4000], "raw_preview": str(raw or "")[:4000],
} }

View file

@ -38,6 +38,7 @@ def _map_fsm_rows(rows: list[dict[str, Any]], *, vrf: str) -> list[dict[str, Any
"pri": row_get(r, "PRI", "pri")[:16], "pri": row_get(r, "PRI", "pri")[:16],
"metric": row_get(r, "METRIC", "metric")[:32], "metric": row_get(r, "METRIC", "metric")[:32],
"flags": row_get(r, "FLAGS", "flags")[:16], "flags": row_get(r, "FLAGS", "flags")[:16],
"address_families": "ipv4",
} }
) )
return out return out
@ -67,6 +68,7 @@ def _hand_parse(*, raw_text: str, vrf: str = "", **_kw: Any) -> list[dict[str, A
"pri": m.group("pri")[:16], "pri": m.group("pri")[:16],
"metric": m.group("metric")[:32], "metric": m.group("metric")[:32],
"flags": (m.group("flags") or "").strip()[:16], "flags": (m.group("flags") or "").strip()[:16],
"address_families": "ipv4",
} }
) )
return out return out

View file

@ -37,6 +37,7 @@ def _map_fsm_rows(rows: list[dict[str, Any]], *, vrf: str) -> list[dict[str, Any
"pri": row_get(r, "PRI", "pri")[:16], "pri": row_get(r, "PRI", "pri")[:16],
"metric": row_get(r, "METRIC", "metric")[:32], "metric": row_get(r, "METRIC", "metric")[:32],
"flags": row_get(r, "FLAGS", "flags")[:16], "flags": row_get(r, "FLAGS", "flags")[:16],
"address_families": "ipv6",
} }
if gw or iface: if gw or iface:
cur["gateway"] = gw[:128] cur["gateway"] = gw[:128]
@ -77,6 +78,7 @@ def _hand_parse(*, raw_text: str, vrf: str = "", **_kw: Any) -> list[dict[str, A
"pri": m.group("pri")[:16], "pri": m.group("pri")[:16],
"metric": m.group("metric")[:32], "metric": m.group("metric")[:32],
"flags": (m.group("flags") or "")[:16], "flags": (m.group("flags") or "")[:16],
"address_families": "ipv6",
} }
continue continue
m2 = _NH_RE.match(line) m2 = _NH_RE.match(line)

View file

@ -38,6 +38,8 @@ class PlaceholderDef:
# When required=False and no bindings: collect may expand all discover values. # When required=False and no bindings: collect may expand all discover values.
discover_filter_field: str = "" discover_filter_field: str = ""
discover_filter_contains: str = "" discover_filter_contains: str = ""
# Skip rows where this field is empty (e.g. CE peers require non-empty vrf).
discover_require_nonempty: str = ""
@dataclass(frozen=True) @dataclass(frozen=True)
@ -280,17 +282,7 @@ _BGP_PEER_FIELDS: list[FieldDef] = [
FieldDef("address_families", length=64, role="meta", display_name="AF"), FieldDef("address_families", length=64, role="meta", display_name="AF"),
] ]
# Neighbor / dual-placeholder profiles still require an explicit VRF bind. # BGP VRF summary still binds VRF from config VRF intent.
_BGP_VRF_PLACEHOLDER = PlaceholderDef(
name="vrf",
schema_field="vrf",
required=True,
bind_mode="discover_select",
discover_profile_id="zte.config_vrf",
discover_value_field="vrf_name",
discover_label_field="vrf_name",
)
_VRF_PLACEHOLDER_IPV4 = PlaceholderDef( _VRF_PLACEHOLDER_IPV4 = PlaceholderDef(
name="vrf", name="vrf",
schema_field="vrf", schema_field="vrf",
@ -315,11 +307,82 @@ _VRF_PLACEHOLDER_IPV6 = PlaceholderDef(
discover_filter_contains="ipv6", discover_filter_contains="ipv6",
) )
_BGP_NEIGHBOR_PLACEHOLDER = PlaceholderDef( # BGP neighbor in/out: discover peers from Config BGP Peer Intent, filtered by AF.
_BGP_NEIGHBOR_VPNV4 = PlaceholderDef(
name="neighbor", name="neighbor",
schema_field="neighbor", schema_field="neighbor",
required=True, required=True,
bind_mode="manual_text", bind_mode="discover_select",
discover_profile_id="zte.config_bgp_peer",
discover_value_field="neighbor",
discover_label_field="neighbor",
discover_filter_field="afi",
discover_filter_contains="vpnv4",
)
_BGP_NEIGHBOR_VPNV6 = PlaceholderDef(
name="neighbor",
schema_field="neighbor",
required=True,
bind_mode="discover_select",
discover_profile_id="zte.config_bgp_peer",
discover_value_field="neighbor",
discover_label_field="neighbor",
discover_filter_field="afi",
discover_filter_contains="vpnv6",
)
# Per-VRF CE peers live under address-family ipv4/ipv6 vrf <name>.
_BGP_VRF_PEER_IPV4 = PlaceholderDef(
name="vrf",
schema_field="vrf",
required=True,
bind_mode="discover_select",
discover_profile_id="zte.config_bgp_peer",
discover_value_field="vrf",
discover_label_field="vrf",
discover_filter_field="afi",
discover_filter_contains="ipv4",
discover_require_nonempty="neighbor",
)
_BGP_NEIGHBOR_IPV4_VRF = PlaceholderDef(
name="neighbor",
schema_field="neighbor",
required=True,
bind_mode="discover_select",
discover_profile_id="zte.config_bgp_peer",
discover_value_field="neighbor",
discover_label_field="neighbor",
discover_filter_field="afi",
discover_filter_contains="ipv4",
discover_require_nonempty="vrf",
)
_BGP_VRF_PEER_IPV6 = PlaceholderDef(
name="vrf",
schema_field="vrf",
required=True,
bind_mode="discover_select",
discover_profile_id="zte.config_bgp_peer",
discover_value_field="vrf",
discover_label_field="vrf",
discover_filter_field="afi",
discover_filter_contains="ipv6",
discover_require_nonempty="neighbor",
)
_BGP_NEIGHBOR_IPV6_VRF = PlaceholderDef(
name="neighbor",
schema_field="neighbor",
required=True,
bind_mode="discover_select",
discover_profile_id="zte.config_bgp_peer",
discover_value_field="neighbor",
discover_label_field="neighbor",
discover_filter_field="afi",
discover_filter_contains="ipv6",
discover_require_nonempty="vrf",
) )
_OSPF_FIELDS: list[FieldDef] = [ _OSPF_FIELDS: list[FieldDef] = [
@ -905,7 +968,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$", match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv4 unicast neighbor in", textfsm_command="show bgp vpnv4 unicast neighbor in",
description="Routes learned from VPNv4 neighbor; summary aux for peer state.", description="Routes learned from VPNv4 neighbor; summary aux for peer state.",
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER], placeholders=[_BGP_NEIGHBOR_VPNV4],
fields=list(_BGP_ROUTE_FIELDS), fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv4", "route"], tags=["bgp", "vpnv4", "route"],
sort_order=420, sort_order=420,
@ -930,7 +993,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$", match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv4 unicast neighbor out", textfsm_command="show bgp vpnv4 unicast neighbor out",
description="Routes advertised to VPNv4 neighbor (large; bind neighbor).", description="Routes advertised to VPNv4 neighbor (large; bind neighbor).",
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER], placeholders=[_BGP_NEIGHBOR_VPNV4],
fields=list(_BGP_ROUTE_FIELDS), fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv4", "route"], tags=["bgp", "vpnv4", "route"],
sort_order=425, sort_order=425,
@ -955,7 +1018,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$", match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv6 unicast neighbor in", textfsm_command="show bgp vpnv6 unicast neighbor in",
description="Routes learned from VPNv6 neighbor; summary aux for peer state.", description="Routes learned from VPNv6 neighbor; summary aux for peer state.",
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER], placeholders=[_BGP_NEIGHBOR_VPNV6],
fields=list(_BGP_ROUTE_FIELDS), fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv6", "route"], tags=["bgp", "vpnv6", "route"],
sort_order=426, sort_order=426,
@ -980,7 +1043,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$", match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv6 unicast neighbor out", textfsm_command="show bgp vpnv6 unicast neighbor out",
description="Routes advertised to VPNv6 neighbor (large; bind neighbor).", description="Routes advertised to VPNv6 neighbor (large; bind neighbor).",
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER], placeholders=[_BGP_NEIGHBOR_VPNV6],
fields=list(_BGP_ROUTE_FIELDS), fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv6", "route"], tags=["bgp", "vpnv6", "route"],
sort_order=427, sort_order=427,
@ -1007,8 +1070,8 @@ def _zte_status_profiles() -> list[ParseProfile]:
r"neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$" r"neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
), ),
textfsm_command="show bgp vpnv4 unicast neighbor in", textfsm_command="show bgp vpnv4 unicast neighbor in",
description="Per-VRF routes learned from neighbor; VRF summary aux.", description="Per-VRF CE peer routes; discover (vrf,neighbor) from BGP peer intent.",
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER], placeholders=[_BGP_VRF_PEER_IPV4, _BGP_NEIGHBOR_IPV4_VRF],
fields=list(_BGP_ROUTE_FIELDS), fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv4", "vrf", "route"], tags=["bgp", "vpnv4", "vrf", "route"],
sort_order=430, sort_order=430,
@ -1035,8 +1098,8 @@ def _zte_status_profiles() -> list[ParseProfile]:
r"neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$" r"neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
), ),
textfsm_command="show bgp vpnv4 unicast neighbor out", textfsm_command="show bgp vpnv4 unicast neighbor out",
description="Per-VRF routes advertised to neighbor (may be large).", description="Per-VRF CE peer advertised routes; discover pairs from BGP peer intent.",
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER], placeholders=[_BGP_VRF_PEER_IPV4, _BGP_NEIGHBOR_IPV4_VRF],
fields=list(_BGP_ROUTE_FIELDS), fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv4", "vrf", "route"], tags=["bgp", "vpnv4", "vrf", "route"],
sort_order=435, sort_order=435,
@ -1063,8 +1126,8 @@ def _zte_status_profiles() -> list[ParseProfile]:
r"neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$" r"neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
), ),
textfsm_command="show bgp vpnv6 unicast neighbor in", textfsm_command="show bgp vpnv6 unicast neighbor in",
description="Per-VRF VPNv6 routes learned from neighbor.", description="Per-VRF IPv6 CE peer routes; discover pairs from BGP peer intent.",
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER], placeholders=[_BGP_VRF_PEER_IPV6, _BGP_NEIGHBOR_IPV6_VRF],
fields=list(_BGP_ROUTE_FIELDS), fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv6", "vrf", "route"], tags=["bgp", "vpnv6", "vrf", "route"],
sort_order=440, sort_order=440,
@ -1091,8 +1154,8 @@ def _zte_status_profiles() -> list[ParseProfile]:
r"neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$" r"neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
), ),
textfsm_command="show bgp vpnv6 unicast neighbor out", textfsm_command="show bgp vpnv6 unicast neighbor out",
description="Per-VRF VPNv6 routes advertised to neighbor.", description="Per-VRF IPv6 CE advertised routes; discover pairs from BGP peer intent.",
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER], placeholders=[_BGP_VRF_PEER_IPV6, _BGP_NEIGHBOR_IPV6_VRF],
fields=list(_BGP_ROUTE_FIELDS), fields=list(_BGP_ROUTE_FIELDS),
tags=["bgp", "vpnv6", "vrf", "route"], tags=["bgp", "vpnv6", "vrf", "route"],
sort_order=445, sort_order=445,
@ -1130,7 +1193,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
from_aux="config_vrf", from_aux="config_vrf",
left_on="vrf", left_on="vrf",
right_on="vrf_name", right_on="vrf_name",
take=("rd", "address_families"), take=("rd",),
), ),
], ],
), ),
@ -1172,7 +1235,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
from_aux="config_vrf", from_aux="config_vrf",
left_on="vrf", left_on="vrf",
right_on="vrf_name", right_on="vrf_name",
take=("rd", "address_families"), take=("rd",),
), ),
], ],
), ),
@ -1501,6 +1564,7 @@ def profile_to_public_dict(p: ParseProfile, *, overrides: dict[str, Any] | None
"discover_label_field": ph.discover_label_field, "discover_label_field": ph.discover_label_field,
"discover_filter_field": ph.discover_filter_field, "discover_filter_field": ph.discover_filter_field,
"discover_filter_contains": ph.discover_filter_contains, "discover_filter_contains": ph.discover_filter_contains,
"discover_require_nonempty": ph.discover_require_nonempty,
} }
for ph in p.placeholders for ph in p.placeholders
], ],

View file

@ -380,6 +380,7 @@ class ZteExtendedParserTests(unittest.TestCase):
self.assertEqual(len(ip), 2) self.assertEqual(len(ip), 2)
self.assertEqual(ip[0]["dest"], "0.0.0.0/0") self.assertEqual(ip[0]["dest"], "0.0.0.0/0")
self.assertEqual(ip[0]["vrf"], "CUST_A") self.assertEqual(ip[0]["vrf"], "CUST_A")
self.assertEqual(ip[0]["address_families"], "ipv4")
v6 = normalize_ipv6_route( v6 = normalize_ipv6_route(
raw_text=_IPV6_ROUTE, raw_text=_IPV6_ROUTE,
@ -388,6 +389,7 @@ class ZteExtendedParserTests(unittest.TestCase):
) )
self.assertGreaterEqual(len(v6), 1) self.assertGreaterEqual(len(v6), 1)
self.assertTrue(any(r["dest"].startswith("2001:db8") for r in v6)) self.assertTrue(any(r["dest"].startswith("2001:db8") for r in v6))
self.assertTrue(all(r.get("address_families") == "ipv6" for r in v6))
pw = normalize_l2vpn_pw(raw_text=_L2VPN_PW, command="show l2vpn forwardinfo") pw = normalize_l2vpn_pw(raw_text=_L2VPN_PW, command="show l2vpn forwardinfo")
self.assertEqual(len(pw), 3) self.assertEqual(len(pw), 3)
@ -536,6 +538,71 @@ class ZteExtendedParserTests(unittest.TestCase):
ipv6_pairs = expand_bindings_from_discover_records(profile=v6, records=records) ipv6_pairs = expand_bindings_from_discover_records(profile=v6, records=records)
self.assertEqual({p[1]["vrf"] for p in ipv6_pairs}, {"CUST_A", "CUST_C"}) self.assertEqual({p[1]["vrf"] for p in ipv6_pairs}, {"CUST_A", "CUST_C"})
# BGP neighbor in/out: discover from config_bgp_peer with AF filters
from netx_api.biz_state.command_match import (
filter_discover_records,
normalize_binding_dicts,
shared_discover_placeholders,
)
glob_v4 = get_profile("zte.bgp_vpnv4_neighbor_in")
assert glob_v4 is not None
self.assertEqual(glob_v4.placeholders[0].discover_profile_id, "zte.config_bgp_peer")
self.assertEqual(glob_v4.placeholders[0].discover_filter_contains, "vpnv4")
self.assertEqual(glob_v4.placeholders[0].bind_mode, "discover_select")
glob_v6 = get_profile("zte.bgp_vpnv6_neighbor_in")
assert glob_v6 is not None
self.assertEqual(glob_v6.placeholders[0].discover_filter_contains, "vpnv6")
peer_recs = [
{"afi": "vpnv4", "vrf": "", "neighbor": "10.0.0.1", "remote_as": "65001"},
{"afi": "vpnv4", "vrf": "", "neighbor": "", "peer_group": "CORE_RR", "remote_as": "65009"},
{"afi": "vpnv6", "vrf": "", "neighbor": "FC00::1", "remote_as": "65002"},
{"afi": "ipv4", "vrf": "CUST_A", "neighbor": "10.0.0.2", "remote_as": "65003"},
{"afi": "ipv4", "vrf": "", "neighbor": "10.0.0.9", "remote_as": "65004"},
{"afi": "ipv6", "vrf": "CUST_B", "neighbor": "FC00::2", "remote_as": "65005"},
]
self.assertEqual(
filter_discover_records(peer_recs, glob_v4.placeholders[0]),
["10.0.0.1"],
)
self.assertEqual(
filter_discover_records(peer_recs, glob_v6.placeholders[0]),
["FC00::1"],
)
vrf_nei = get_profile("zte.bgp_vpnv4_vrf_neighbor_in")
assert vrf_nei is not None
shared = shared_discover_placeholders(vrf_nei)
self.assertEqual(len(shared), 2)
self.assertTrue(all(ph.discover_profile_id == "zte.config_bgp_peer" for ph in shared))
pair_cmds = expand_bindings_from_discover_records(profile=vrf_nei, records=peer_recs)
self.assertEqual(len(pair_cmds), 1)
self.assertEqual(pair_cmds[0][1], {"vrf": "CUST_A", "neighbor": "10.0.0.2"})
self.assertIn("vrf CUST_A", pair_cmds[0][0])
self.assertIn("10.0.0.2", pair_cmds[0][0])
# Interleaved placeholder/value rows zip into combined bindings
zipped = normalize_binding_dicts(
[
{"placeholder": "vrf", "value": "CUST_A"},
{"placeholder": "neighbor", "value": "10.0.0.2"},
{"placeholder": "vrf", "value": "CUST_B"},
{"placeholder": "neighbor", "value": "10.0.0.3"},
],
placeholders=vrf_nei.placeholders,
)
self.assertEqual(
zipped,
[
{"vrf": "CUST_A", "neighbor": "10.0.0.2"},
{"vrf": "CUST_B", "neighbor": "10.0.0.3"},
],
)
expanded = expand_from_bindings(profile=vrf_nei, bindings=zipped)
self.assertEqual(len(expanded), 2)
def test_interface_detail_and_vpnv6_neighbor(self) -> None: def test_interface_detail_and_vpnv6_neighbor(self) -> None:
from netx_api.biz_state.parsers.zte import normalize_interface_detail from netx_api.biz_state.parsers.zte import normalize_interface_detail

View file

@ -233,13 +233,14 @@ const en = {
command: "Command template", command: "Command template",
params: "Bindings", params: "Bindings",
discoverVrf: "Discover VRFs", discoverVrf: "Discover VRFs",
discoverBind: "Discover / Bind",
saveBindings: "Save bindings", saveBindings: "Save bindings",
cancel: "Close", cancel: "Close",
bindTitle: "Select VRF bindings", bindTitle: "Select bindings",
unbound: "Not bound", unbound: "Not bound",
bindHintRequired: "Bind VRF params before collect", bindHintRequired: "Bind params before collect",
discoverLoading: "Discovering VRFs…", discoverLoading: "Discovering…",
discoverEmpty: "No VRFs discovered", discoverEmpty: "No candidates discovered",
selectAllVrfs: "Select all", selectAllVrfs: "Select all",
deselectAllVrfs: "Deselect all", deselectAllVrfs: "Deselect all",
batches: "Batches", batches: "Batches",

View file

@ -233,13 +233,14 @@ const zh = {
command: "命令模板", command: "命令模板",
params: "参数关联", params: "参数关联",
discoverVrf: "发现 VRF", discoverVrf: "发现 VRF",
discoverBind: "发现 / 关联",
saveBindings: "保存关联", saveBindings: "保存关联",
cancel: "关闭", cancel: "关闭",
bindTitle: "选择 VRF 绑定", bindTitle: "选择参数关联",
unbound: "未关联", unbound: "未关联",
bindHintRequired: "需关联 VRF 参数后才可采集", bindHintRequired: "需关联参数后才可采集",
discoverLoading: "正在发现 VRF…", discoverLoading: "正在发现…",
discoverEmpty: "未发现可用 VRF", discoverEmpty: "未发现可用选项",
selectAllVrfs: "全选", selectAllVrfs: "全选",
deselectAllVrfs: "取消全选", deselectAllVrfs: "取消全选",
batches: "采集批次", batches: "采集批次",

View file

@ -11274,24 +11274,74 @@ html.login-page--paused .login-page__flare {
} }
.bs-profiles-table-wrap { .bs-profiles-table-wrap {
overflow-x: auto !important; overflow-x: scroll !important;
overflow-y: auto; overflow-y: auto;
max-height: min(58vh, 560px); max-height: min(58vh, 560px);
/* Keep horizontal scrollbar visible (WebKit). */
scrollbar-gutter: stable both-edges;
}
.bs-profiles-table-wrap::-webkit-scrollbar {
height: 10px;
width: 10px;
}
.bs-profiles-table-wrap::-webkit-scrollbar-thumb {
background: rgba(148, 163, 184, 0.45);
border-radius: 6px;
} }
.bs-profiles-table { .bs-profiles-table {
min-width: 960px; min-width: 1100px;
table-layout: fixed;
}
.bs-profiles-table th:nth-child(1),
.bs-profiles-table td:nth-child(1) {
width: 56px;
}
.bs-profiles-table th:nth-child(2),
.bs-profiles-table td:nth-child(2) {
width: 28%;
}
.bs-profiles-table th:nth-child(3),
.bs-profiles-table td:nth-child(3) {
width: 180px;
}
.bs-profiles-table th:nth-child(4),
.bs-profiles-table td:nth-child(4) {
width: 110px;
} }
.bs-profiles-table .bs-cmd-cell { .bs-profiles-table .bs-cmd-cell {
display: block;
white-space: nowrap; white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
font-size: 12px; font-size: 12px;
max-width: 100%;
} }
.bs-profiles-table .bs-params-cell { .bs-profiles-table .bs-params-cell {
min-width: 140px; vertical-align: top;
max-width: 280px; }
.bs-profiles-table .bs-params-scroll {
max-width: 100%;
max-height: 4.5em;
overflow: auto;
white-space: normal;
word-break: break-word; word-break: break-word;
line-height: 1.35;
font-size: 12px;
}
.bs-profiles-table .bs-actions-cell {
white-space: nowrap;
vertical-align: middle;
} }
.bs-bind-hint { .bs-bind-hint {

View file

@ -78,7 +78,12 @@ type BatchRow = {
protect_reasons?: string[]; protect_reasons?: string[];
}; };
type Candidate = { value: string; label: string; rd?: string }; type Candidate = {
value: string;
label: string;
rd?: string;
bindings?: Record<string, string>;
};
type SheetCol = { key: string; header: string }; type SheetCol = { key: string; header: string };
@ -770,7 +775,8 @@ export function BizStatePage() {
const startDiscover = async (item: any) => { const startDiscover = async (item: any) => {
if (!taskId) return; if (!taskId) return;
const prof = profiles.find((p) => p.profile_id === item.source_profile_id); const prof = profiles.find((p) => p.profile_id === item.source_profile_id);
const ph = (prof?.placeholders || [])[0]; const phs = prof?.placeholders || [];
const ph = phs[0];
if (!ph) { if (!ph) {
showError(t("bizState.noNeedBind")); showError(t("bizState.noNeedBind"));
return; return;
@ -783,10 +789,12 @@ export function BizStatePage() {
setDiscoverLoading(true); setDiscoverLoading(true);
setBusy(true); setBusy(true);
try { try {
// Shared discover_profile_id → omit placeholder so API returns pair candidates.
const sharedDisc = phs.length > 1 && phs.every((p) => p.discover_profile_id === ph.discover_profile_id);
const res = await bizStateDiscover({ const res = await bizStateDiscover({
task_id: taskId, task_id: taskId,
collect_profile_id: item.source_profile_id, collect_profile_id: item.source_profile_id,
placeholder: ph.name, placeholder: sharedDisc ? "" : ph.name,
}); });
if (!res.ok) { if (!res.ok) {
const err = res.error || t("bizState.discoverFailed"); const err = res.error || t("bizState.discoverFailed");
@ -797,11 +805,34 @@ export function BizStatePage() {
setDiscoverCmd(res.command || ""); setDiscoverCmd(res.command || "");
const cand = (res.candidates || []) as Candidate[]; const cand = (res.candidates || []) as Candidate[];
setCandidates(cand); setCandidates(cand);
const existing = (item.bindings || []) const existing = (item.bindings || []) as { placeholder?: string; value?: string }[];
.filter((b: any) => b.placeholder === ph.name) let keep: string[] = [];
.map((b: any) => String(b.value)); if (res.pair_mode || sharedDisc) {
// Keep prior bindings if still in candidates; otherwise start with none selected. const phNames = phs.map((p) => p.name);
const keep = existing.filter((v: string) => cand.some((c) => c.value === v)); const byPh: Record<string, string[]> = {};
for (const b of existing) {
const name = String(b.placeholder || "");
const val = String(b.value || "");
if (!name || !val) continue;
(byPh[name] ||= []).push(val);
}
const counts = phNames.map((n) => (byPh[n] || []).length);
const n = counts.length ? Math.min(...counts) : 0;
const existingKeys = new Set<string>();
for (let i = 0; i < n; i++) {
const parts = phNames
.slice()
.sort()
.map((name) => `${name}=${(byPh[name] || [])[i] || ""}`);
existingKeys.add(parts.join("|"));
}
keep = cand.filter((c) => existingKeys.has(c.value)).map((c) => c.value);
} else {
const vals = existing
.filter((b) => b.placeholder === ph.name)
.map((b) => String(b.value));
keep = vals.filter((v) => cand.some((c) => c.value === v));
}
setSelectedVrfs(keep); setSelectedVrfs(keep);
if (!cand.length) { if (!cand.length) {
setDiscoverError(t("bizState.discoverEmpty")); setDiscoverError(t("bizState.discoverEmpty"));
@ -823,13 +854,22 @@ export function BizStatePage() {
const phName = (prof?.placeholders || [])[0]?.name || "vrf"; const phName = (prof?.placeholders || [])[0]?.name || "vrf";
const picked = values !== undefined ? values : selectedVrfs; const picked = values !== undefined ? values : selectedVrfs;
if (!picked.length) return; if (!picked.length) return;
const candByVal = new Map(candidates.map((c) => [c.value, c]));
const rows: { placeholder: string; value: string }[] = [];
for (const v of picked) {
const c = candByVal.get(v);
const binds = c?.bindings;
if (binds && Object.keys(binds).length) {
for (const [k, val] of Object.entries(binds)) {
if (k && val) rows.push({ placeholder: k, value: String(val) });
}
} else {
rows.push({ placeholder: phName, value: v });
}
}
setBusy(true); setBusy(true);
try { try {
await bizStateSetBindings( await bizStateSetBindings(taskId, bindItemId, rows);
taskId,
bindItemId,
picked.map((v) => ({ placeholder: phName, value: v })),
);
showOk(t("bizState.bindingsSaved")); showOk(t("bizState.bindingsSaved"));
await loadTask(taskId); await loadTask(taskId);
closeBindModal(); closeBindModal();
@ -1341,8 +1381,8 @@ export function BizStatePage() {
<th>{t("bizState.enable")}</th> <th>{t("bizState.enable")}</th>
<th>{t("bizState.profiles")}</th> <th>{t("bizState.profiles")}</th>
<th>{t("bizState.params")}</th> <th>{t("bizState.params")}</th>
<th>{t("bizState.colActions")}</th>
<th>{t("bizState.command")}</th> <th>{t("bizState.command")}</th>
<th />
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -1377,17 +1417,17 @@ export function BizStatePage() {
</td> </td>
<td className="bs-params-cell"> <td className="bs-params-cell">
{needsBind ? ( {needsBind ? (
<span className={!binds.length ? "bs-params-warn" : undefined}> <div
className={`bs-params-scroll${!binds.length ? " bs-params-warn" : ""}`}
title={bindHint}
>
{bindHint} {bindHint}
</span> </div>
) : ( ) : (
"—" "—"
)} )}
</td> </td>
<td> <td className="bs-actions-cell">
<code className="bs-cmd-cell">{prof.command_template}</code>
</td>
<td>
{needsBind && enabled && it ? ( {needsBind && enabled && it ? (
<Button <Button
size="sm" size="sm"
@ -1395,9 +1435,16 @@ export function BizStatePage() {
isDisabled={busy || discoverLoading} isDisabled={busy || discoverLoading}
onPress={() => void startDiscover(it)} onPress={() => void startDiscover(it)}
> >
{t("bizState.discoverVrf")} {t("bizState.discoverBind")}
</Button> </Button>
) : null} ) : needsBind ? (
<span className="muted">—</span>
) : (
"—"
)}
</td>
<td>
<code className="bs-cmd-cell">{prof.command_template}</code>
</td> </td>
</tr> </tr>
); );