diff --git a/netx_api/biz_state/command_match.py b/netx_api/biz_state/command_match.py index 3780c7c..b71e57c 100644 --- a/netx_api/biz_state/command_match.py +++ b/netx_api/biz_state/command_match.py @@ -62,7 +62,12 @@ def normalize_binding_dicts( *, placeholders: list | None = None, ) -> 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 []) converted: list[dict[str, str]] = [] for b in raw: @@ -85,6 +90,33 @@ def normalize_binding_dicts( if val: out.append({name: val}) 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 @@ -100,23 +132,32 @@ def _optional_discover_placeholders(profile: ParseProfile) -> list[PlaceholderDe 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( records: list[dict[str, Any]] | None, ph: PlaceholderDef, ) -> list[str]: """Apply placeholder discover filter; return unique values for ``ph``.""" 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] = [] seen: set[str] = set() for rec in records or []: if not isinstance(rec, dict): continue - if filt_field and filt_contains: - hay = str(rec.get(filt_field) or "").strip().lower() - if filt_contains not in hay: - continue + if not _record_passes_discover_filter(rec, ph): + continue val = str(rec.get(value_field) or "").strip() if not val or val in seen: continue @@ -125,6 +166,17 @@ def filter_discover_records( 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( *, profile: ParseProfile, @@ -176,12 +228,52 @@ def expand_bindings_from_discover_records( profile: ParseProfile, records: list[dict[str, Any]] | None, ) -> 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) if not discover_phs: 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: - 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] values = filter_discover_records(records, ph) if not values: diff --git a/netx_api/biz_state/discover.py b/netx_api/biz_state/discover.py index f87a9b1..4f6f894 100644 --- a/netx_api/biz_state/discover.py +++ b/netx_api/biz_state/discover.py @@ -14,6 +14,10 @@ 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 .command_match import ( + _record_passes_discover_filter, + shared_discover_placeholders, +) from .parsers import get_parser, run_parser from .profiles import get_profile @@ -71,17 +75,24 @@ def discover_params( ) 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 + collect = get_profile(collect_profile_id) if collect_profile_id else None + pair_phs = shared_discover_placeholders(collect) if collect else [] + active_ph = None + 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 + 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: if src == "managed": @@ -148,36 +159,78 @@ def discover_params( candidates = [] seen: set[str] = set() - filt_field = "" - filt_contains = "" - if collect_profile_id: - collect = get_profile(collect_profile_id) - if collect: - for ph in collect.placeholders: - 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: + # Shared discover profile → one candidate per (placeholder fields) tuple. + if pair_phs and len(pair_phs) >= 2: + filter_ph = pair_phs[0] + for rec in records: + if not _record_passes_discover_filter(rec, filter_ph): + # Also require every paired field non-empty. 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 rec.get("address_families") or ""), - "extra": rec, - } - ) + bind: dict[str, str] = {} + ok = True + for ph in pair_phs: + if not _record_passes_discover_filter(rec, ph): + ok = False + break + vf = str(ph.discover_value_field or ph.name or "").strip() + val = str(rec.get(vf) or "").strip() + 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 { "ok": True, @@ -186,6 +239,7 @@ def discover_params( "command": command, "vendor_key": resolve_vendor_key(vendor, device_type), "value_field": value_field, + "pair_mode": bool(pair_phs and len(pair_phs) >= 2), "candidates": candidates, "raw_preview": str(raw or "")[:4000], } diff --git a/netx_api/biz_state/parsers/zte/ip_route.py b/netx_api/biz_state/parsers/zte/ip_route.py index e50dc27..70ea005 100644 --- a/netx_api/biz_state/parsers/zte/ip_route.py +++ b/netx_api/biz_state/parsers/zte/ip_route.py @@ -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], "metric": row_get(r, "METRIC", "metric")[:32], "flags": row_get(r, "FLAGS", "flags")[:16], + "address_families": "ipv4", } ) 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], "metric": m.group("metric")[:32], "flags": (m.group("flags") or "").strip()[:16], + "address_families": "ipv4", } ) return out diff --git a/netx_api/biz_state/parsers/zte/ipv6_route.py b/netx_api/biz_state/parsers/zte/ipv6_route.py index 605f993..a2bb934 100644 --- a/netx_api/biz_state/parsers/zte/ipv6_route.py +++ b/netx_api/biz_state/parsers/zte/ipv6_route.py @@ -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], "metric": row_get(r, "METRIC", "metric")[:32], "flags": row_get(r, "FLAGS", "flags")[:16], + "address_families": "ipv6", } if gw or iface: 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], "metric": m.group("metric")[:32], "flags": (m.group("flags") or "")[:16], + "address_families": "ipv6", } continue m2 = _NH_RE.match(line) diff --git a/netx_api/biz_state/profiles.py b/netx_api/biz_state/profiles.py index 0d4ce27..cf88351 100644 --- a/netx_api/biz_state/profiles.py +++ b/netx_api/biz_state/profiles.py @@ -38,6 +38,8 @@ class PlaceholderDef: # When required=False and no bindings: collect may expand all discover values. discover_filter_field: 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) @@ -280,17 +282,7 @@ _BGP_PEER_FIELDS: list[FieldDef] = [ FieldDef("address_families", length=64, role="meta", display_name="AF"), ] -# Neighbor / dual-placeholder profiles still require an explicit VRF bind. -_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", -) - +# BGP VRF summary still binds VRF from config VRF intent. _VRF_PLACEHOLDER_IPV4 = PlaceholderDef( name="vrf", schema_field="vrf", @@ -315,11 +307,82 @@ _VRF_PLACEHOLDER_IPV6 = PlaceholderDef( 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", schema_field="neighbor", 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 . +_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] = [ @@ -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\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv4 unicast neighbor in", description="Routes learned from VPNv4 neighbor; summary aux for peer state.", - placeholders=[_BGP_NEIGHBOR_PLACEHOLDER], + placeholders=[_BGP_NEIGHBOR_VPNV4], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv4", "route"], 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\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv4 unicast neighbor out", description="Routes advertised to VPNv4 neighbor (large; bind neighbor).", - placeholders=[_BGP_NEIGHBOR_PLACEHOLDER], + placeholders=[_BGP_NEIGHBOR_VPNV4], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv4", "route"], 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\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv6 unicast neighbor in", description="Routes learned from VPNv6 neighbor; summary aux for peer state.", - placeholders=[_BGP_NEIGHBOR_PLACEHOLDER], + placeholders=[_BGP_NEIGHBOR_VPNV6], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv6", "route"], 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\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv6 unicast neighbor out", description="Routes advertised to VPNv6 neighbor (large; bind neighbor).", - placeholders=[_BGP_NEIGHBOR_PLACEHOLDER], + placeholders=[_BGP_NEIGHBOR_VPNV6], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv6", "route"], sort_order=427, @@ -1007,8 +1070,8 @@ def _zte_status_profiles() -> list[ParseProfile]: r"neighbor\s+in\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv4 unicast neighbor in", - description="Per-VRF routes learned from neighbor; VRF summary aux.", - placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER], + description="Per-VRF CE peer routes; discover (vrf,neighbor) from BGP peer intent.", + placeholders=[_BGP_VRF_PEER_IPV4, _BGP_NEIGHBOR_IPV4_VRF], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv4", "vrf", "route"], sort_order=430, @@ -1035,8 +1098,8 @@ def _zte_status_profiles() -> list[ParseProfile]: r"neighbor\s+out\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv4 unicast neighbor out", - description="Per-VRF routes advertised to neighbor (may be large).", - placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER], + description="Per-VRF CE peer advertised routes; discover pairs from BGP peer intent.", + placeholders=[_BGP_VRF_PEER_IPV4, _BGP_NEIGHBOR_IPV4_VRF], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv4", "vrf", "route"], sort_order=435, @@ -1063,8 +1126,8 @@ def _zte_status_profiles() -> list[ParseProfile]: r"neighbor\s+in\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv6 unicast neighbor in", - description="Per-VRF VPNv6 routes learned from neighbor.", - placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER], + description="Per-VRF IPv6 CE peer routes; discover pairs from BGP peer intent.", + placeholders=[_BGP_VRF_PEER_IPV6, _BGP_NEIGHBOR_IPV6_VRF], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv6", "vrf", "route"], sort_order=440, @@ -1091,8 +1154,8 @@ def _zte_status_profiles() -> list[ParseProfile]: r"neighbor\s+out\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$" ), textfsm_command="show bgp vpnv6 unicast neighbor out", - description="Per-VRF VPNv6 routes advertised to neighbor.", - placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER], + description="Per-VRF IPv6 CE advertised routes; discover pairs from BGP peer intent.", + placeholders=[_BGP_VRF_PEER_IPV6, _BGP_NEIGHBOR_IPV6_VRF], fields=list(_BGP_ROUTE_FIELDS), tags=["bgp", "vpnv6", "vrf", "route"], sort_order=445, @@ -1130,7 +1193,7 @@ def _zte_status_profiles() -> list[ParseProfile]: from_aux="config_vrf", left_on="vrf", right_on="vrf_name", - take=("rd", "address_families"), + take=("rd",), ), ], ), @@ -1172,7 +1235,7 @@ def _zte_status_profiles() -> list[ParseProfile]: from_aux="config_vrf", left_on="vrf", 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_filter_field": ph.discover_filter_field, "discover_filter_contains": ph.discover_filter_contains, + "discover_require_nonempty": ph.discover_require_nonempty, } for ph in p.placeholders ], diff --git a/tests/test_zte_extended_parsers.py b/tests/test_zte_extended_parsers.py index b12db95..336d85f 100644 --- a/tests/test_zte_extended_parsers.py +++ b/tests/test_zte_extended_parsers.py @@ -380,6 +380,7 @@ class ZteExtendedParserTests(unittest.TestCase): self.assertEqual(len(ip), 2) self.assertEqual(ip[0]["dest"], "0.0.0.0/0") self.assertEqual(ip[0]["vrf"], "CUST_A") + self.assertEqual(ip[0]["address_families"], "ipv4") v6 = normalize_ipv6_route( raw_text=_IPV6_ROUTE, @@ -388,6 +389,7 @@ class ZteExtendedParserTests(unittest.TestCase): ) self.assertGreaterEqual(len(v6), 1) 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") self.assertEqual(len(pw), 3) @@ -536,6 +538,71 @@ class ZteExtendedParserTests(unittest.TestCase): 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"}) + # 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: from netx_api.biz_state.parsers.zte import normalize_interface_detail diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 3a4a687..3c1bbb1 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -233,13 +233,14 @@ const en = { command: "Command template", params: "Bindings", discoverVrf: "Discover VRFs", + discoverBind: "Discover / Bind", saveBindings: "Save bindings", cancel: "Close", - bindTitle: "Select VRF bindings", + bindTitle: "Select bindings", unbound: "Not bound", - bindHintRequired: "Bind VRF params before collect", - discoverLoading: "Discovering VRFs…", - discoverEmpty: "No VRFs discovered", + bindHintRequired: "Bind params before collect", + discoverLoading: "Discovering…", + discoverEmpty: "No candidates discovered", selectAllVrfs: "Select all", deselectAllVrfs: "Deselect all", batches: "Batches", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 45b88c5..69e8400 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -233,13 +233,14 @@ const zh = { command: "命令模板", params: "参数关联", discoverVrf: "发现 VRF", + discoverBind: "发现 / 关联", saveBindings: "保存关联", cancel: "关闭", - bindTitle: "选择 VRF 绑定", + bindTitle: "选择参数关联", unbound: "未关联", - bindHintRequired: "需关联 VRF 参数后才可采集", - discoverLoading: "正在发现 VRF…", - discoverEmpty: "未发现可用 VRF", + bindHintRequired: "需关联参数后才可采集", + discoverLoading: "正在发现…", + discoverEmpty: "未发现可用选项", selectAllVrfs: "全选", deselectAllVrfs: "取消全选", batches: "采集批次", diff --git a/web/src/index.css b/web/src/index.css index 9a1a9eb..95a3e4a 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -11274,24 +11274,74 @@ html.login-page--paused .login-page__flare { } .bs-profiles-table-wrap { - overflow-x: auto !important; + overflow-x: scroll !important; overflow-y: auto; 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 { - 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 { + display: block; white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; font-size: 12px; + max-width: 100%; } .bs-profiles-table .bs-params-cell { - min-width: 140px; - max-width: 280px; + vertical-align: top; +} + +.bs-profiles-table .bs-params-scroll { + max-width: 100%; + max-height: 4.5em; + overflow: auto; + white-space: normal; 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 { diff --git a/web/src/pages/network/BizStatePage.tsx b/web/src/pages/network/BizStatePage.tsx index 3b240c4..7ce9303 100644 --- a/web/src/pages/network/BizStatePage.tsx +++ b/web/src/pages/network/BizStatePage.tsx @@ -78,7 +78,12 @@ type BatchRow = { protect_reasons?: string[]; }; -type Candidate = { value: string; label: string; rd?: string }; +type Candidate = { + value: string; + label: string; + rd?: string; + bindings?: Record; +}; type SheetCol = { key: string; header: string }; @@ -770,7 +775,8 @@ export function BizStatePage() { const startDiscover = async (item: any) => { if (!taskId) return; 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) { showError(t("bizState.noNeedBind")); return; @@ -783,10 +789,12 @@ export function BizStatePage() { setDiscoverLoading(true); setBusy(true); 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({ task_id: taskId, collect_profile_id: item.source_profile_id, - placeholder: ph.name, + placeholder: sharedDisc ? "" : ph.name, }); if (!res.ok) { const err = res.error || t("bizState.discoverFailed"); @@ -797,11 +805,34 @@ export function BizStatePage() { 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)); - // Keep prior bindings if still in candidates; otherwise start with none selected. - const keep = existing.filter((v: string) => cand.some((c) => c.value === v)); + const existing = (item.bindings || []) as { placeholder?: string; value?: string }[]; + let keep: string[] = []; + if (res.pair_mode || sharedDisc) { + const phNames = phs.map((p) => p.name); + const byPh: Record = {}; + 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(); + 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); if (!cand.length) { setDiscoverError(t("bizState.discoverEmpty")); @@ -823,13 +854,22 @@ export function BizStatePage() { const phName = (prof?.placeholders || [])[0]?.name || "vrf"; const picked = values !== undefined ? values : selectedVrfs; 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); try { - await bizStateSetBindings( - taskId, - bindItemId, - picked.map((v) => ({ placeholder: phName, value: v })), - ); + await bizStateSetBindings(taskId, bindItemId, rows); showOk(t("bizState.bindingsSaved")); await loadTask(taskId); closeBindModal(); @@ -1341,8 +1381,8 @@ export function BizStatePage() { {t("bizState.enable")} {t("bizState.profiles")} {t("bizState.params")} + {t("bizState.colActions")} {t("bizState.command")} - @@ -1377,17 +1417,17 @@ export function BizStatePage() { {needsBind ? ( - +
{bindHint} - +
) : ( "—" )} - - {prof.command_template} - - + {needsBind && enabled && it ? ( - ) : null} + ) : needsBind ? ( + — + ) : ( + "—" + )} + + + {prof.command_template} );