diff --git a/netx_api/biz_state/collect_runner.py b/netx_api/biz_state/collect_runner.py index 5476b31..211633b 100644 --- a/netx_api/biz_state/collect_runner.py +++ b/netx_api/biz_state/collect_runner.py @@ -35,7 +35,13 @@ from .collect_session import ( resolve_aux_command, run_primary_with_bundle, ) -from .command_match import expand_from_bindings, match_command, normalize_command +from .command_match import ( + EXPAND_ALL_COMMAND, + expand_bindings_from_discover_records, + expand_from_bindings, + match_command, + normalize_command, +) from .parsers import get_parser from .profiles import get_profile @@ -404,7 +410,69 @@ def _run_collect_lane( batch_row = sdb.get(BizStateBatch, batch_id) if not batch_row: return 0, 0, True, False + + # Resolve expand_all → concrete per-VRF commands via discover profile. + flat_work: list[WorkItem] = [] + aux_persisted: set[tuple[str, str]] = set() for concrete, params, profile_id, item_id, mode in work: + if mode != "expand_all": + flat_work.append((concrete, params, profile_id, item_id, mode)) + continue + profile = get_profile(profile_id) + if profile is None or not profile.placeholders: + any_fail = True + _append_event( + sdb, + task_id=str(batch_row.task_id or ""), + message=f"expand_all missing profile {profile_id}", + level="error", + ) + continue + ph = profile.placeholders[0] + disc = get_profile(str(ph.discover_profile_id or "").strip()) + if disc is None: + any_fail = True + _append_event( + sdb, + task_id=str(batch_row.task_id or ""), + message=f"expand_all discover profile missing for {profile_id}", + level="error", + ) + continue + disc_cmd = normalize_command(disc.command_template) + entry, _ = session.fetch_and_parse( + disc_cmd, + parser_id=disc.parser_id, + textfsm_command=disc.textfsm_command or disc_cmd, + params={}, + ) + if not entry.ok: + any_fail = True + _append_event( + sdb, + task_id=str(batch_row.task_id or ""), + message=f"expand_all discover failed: {entry.error}", + level="error", + ) + continue + try: + pairs = expand_bindings_from_discover_records( + profile=profile, + records=entry.records, + ) + except ValueError as exc: + any_fail = True + _append_event( + sdb, + task_id=str(batch_row.task_id or ""), + message=str(exc), + level="error", + ) + continue + for cmd, p in pairs: + flat_work.append((cmd, p, profile_id, item_id, "normal")) + + for concrete, params, profile_id, item_id, mode in flat_work: if holder.get("timed_out"): raise TimeoutError(f"{label}_aborted") cmd_count += 1 @@ -530,6 +598,9 @@ def _run_collect_lane( cmd_row_id=aux_row.id, ) aux_results[ra.key] = entry + aux_mid = str(getattr(ra.profile, "metric_id", "") or "").strip() + if aux_mid: + aux_row.metric_id = aux_mid if cache_hit: aux_row.parse_status = "aux_cached" aux_row.message = ( @@ -549,6 +620,26 @@ def _run_collect_lane( aux_row.raw_text = entry.raw aux_row.row_count = len(entry.records or []) entry.cmd_row_id = aux_row.id + # Persist aux metrics once per CLI (config_vrf / FIB shared across VRFs). + if ( + entry.ok + and entry.records + and aux_mid in _GENERIC_METRICS + ): + persist_key = (normalize_command(ra.command), aux_mid) + if persist_key not in aux_persisted: + n_aux = _persist_metric_rows( + sdb, + batch=batch_row, + cmd_row=aux_row, + metric_id=aux_mid, + records=entry.records, + ) + aux_row.row_count = n_aux + total_rows += n_aux + aux_persisted.add(persist_key) + if n_aux: + _bump_batch_progress(batch_id, add_rows=n_aux) sdb.add(aux_row) sdb.commit() @@ -786,6 +877,10 @@ def _run_collect_session( _append_event(db, task_id=task_id, message=str(exc), level="error") continue for concrete, params in pairs: + if concrete == EXPAND_ALL_COMMAND: + # Defer VRF list expansion until CollectSession is open. + work.append(("", dict(params or {}), profile.profile_id, item.id, "expand_all")) + continue cmd = normalize_command(concrete) # Prefer match_command metric so remapped if_intf shares key with config_interface hit = match_command(vendor_key=vendor_key, command=cmd) diff --git a/netx_api/biz_state/command_match.py b/netx_api/biz_state/command_match.py index 0f4d1d3..34f0e3f 100644 --- a/netx_api/biz_state/command_match.py +++ b/netx_api/biz_state/command_match.py @@ -6,7 +6,10 @@ import re from dataclasses import dataclass from typing import Any -from .profiles import ParseProfile, all_profiles, get_profile +from .profiles import ParseProfile, PlaceholderDef, all_profiles, get_profile + +# Sentinel concrete command: collect expands all discover values at runtime. +EXPAND_ALL_COMMAND = "__expand_all__" @dataclass(frozen=True) @@ -85,13 +88,52 @@ def normalize_binding_dicts( return converted +def _optional_discover_placeholders(profile: ParseProfile) -> list[PlaceholderDef]: + return [ + ph + for ph in (profile.placeholders or []) + if (not ph.required) + and ph.bind_mode == "discover_select" + and str(ph.discover_profile_id or "").strip() + ] + + +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 + val = str(rec.get(value_field) or "").strip() + if not val or val in seen: + continue + seen.add(val) + out.append(val) + return out + + def expand_from_bindings( *, profile: ParseProfile, bindings: list[dict[str, str]] | None = None, command_override: str = "", ) -> list[tuple[str, dict[str, str]]]: - """Return list of (concrete_command, params). Reject leftover placeholders.""" + """Return list of (concrete_command, params). Reject leftover placeholders. + + When all placeholders are optional discover_select and bindings are empty, + returns a single ``(EXPAND_ALL_COMMAND, {})`` sentinel for collect-time expansion. + """ override = normalize_command(command_override) if override: if "<" in override and ">" in override: @@ -107,6 +149,9 @@ def expand_from_bindings( binds = normalize_binding_dicts(bindings, placeholders=profile.placeholders) if not binds: + optional = _optional_discover_placeholders(profile) + if optional and len(optional) == len(profile.placeholders): + return [(EXPAND_ALL_COMMAND, {"__expand_all__": "1"})] raise ValueError(f"profile {profile.profile_id} requires parameter bindings") out: list[tuple[str, dict[str, str]]] = [] @@ -124,6 +169,25 @@ def expand_from_bindings( return out +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).""" + optional = _optional_discover_placeholders(profile) + if not optional: + raise ValueError(f"profile {profile.profile_id} has no optional discover placeholders") + if len(optional) != 1 or len(profile.placeholders) != 1: + raise ValueError(f"expand-all only supports a single optional placeholder: {profile.profile_id}") + ph = optional[0] + values = filter_discover_records(records, ph) + if not values: + raise ValueError(f"no discover values for {profile.profile_id} ({ph.discover_profile_id})") + bindings = [{ph.name: v} for v in values] + return expand_from_bindings(profile=profile, bindings=bindings) + + def preview_task_item( *, vendor_key: str, @@ -173,6 +237,16 @@ def preview_task_item( "message": str(exc), } + if pairs and pairs[0][0] == EXPAND_ALL_COMMAND: + return { + "ok": True, + "kind": kind, + "profile_id": profile.profile_id, + "commands": [], + "parse": "expand_all", + "message": "no bindings: collect will expand all discover VRFs", + } + previews = [] for concrete, params in pairs: hit = match_command(vendor_key=vendor_key, command=concrete) diff --git a/netx_api/biz_state/discover.py b/netx_api/biz_state/discover.py index 7c7b8bc..f87a9b1 100644 --- a/netx_api/biz_state/discover.py +++ b/netx_api/biz_state/discover.py @@ -148,10 +148,25 @@ 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: + continue seen.add(val) label = str(rec.get(label_field) or val).strip() or val candidates.append( @@ -159,7 +174,7 @@ def discover_params( "value": val, "label": label, "rd": str(rec.get("rd") or ""), - "protocols": str(rec.get("protocols") or ""), + "protocols": str(rec.get("protocols") or rec.get("address_families") or ""), "extra": rec, } ) diff --git a/netx_api/biz_state/profiles.py b/netx_api/biz_state/profiles.py index 3012ffa..3df317a 100644 --- a/netx_api/biz_state/profiles.py +++ b/netx_api/biz_state/profiles.py @@ -35,6 +35,9 @@ class PlaceholderDef: discover_profile_id: str = "" discover_value_field: str = "" discover_label_field: str = "" + # When required=False and no bindings: collect expands all discover values. + discover_filter_field: str = "" + discover_filter_contains: str = "" @dataclass(frozen=True) @@ -272,18 +275,57 @@ _BGP_PEER_FIELDS: list[FieldDef] = [ FieldDef("msg_rcvd", length=32, role="meta", display_name="MsgRcvd"), FieldDef("msg_send", length=32, role="meta", display_name="MsgSend"), FieldDef("up_down", length=32, role="meta", display_name="Up/Down"), + # From config_vrf enrich (VRF summary aux). + FieldDef("rd", length=64, role="meta", display_name="RD"), + 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.vrf_list", + discover_profile_id="zte.config_vrf", discover_value_field="vrf_name", discover_label_field="vrf_name", ) +# Single-VRF collect: bind selected VRFs, or leave empty → all (from config_vrf). +_VRF_PLACEHOLDER_ALL = PlaceholderDef( + name="vrf", + schema_field="vrf", + required=False, + bind_mode="discover_select", + discover_profile_id="zte.config_vrf", + discover_value_field="vrf_name", + discover_label_field="vrf_name", +) + +_VRF_PLACEHOLDER_IPV4 = PlaceholderDef( + name="vrf", + schema_field="vrf", + required=False, + bind_mode="discover_select", + discover_profile_id="zte.config_vrf", + discover_value_field="vrf_name", + discover_label_field="vrf_name", + discover_filter_field="address_families", + discover_filter_contains="ipv4", +) + +_VRF_PLACEHOLDER_IPV6 = PlaceholderDef( + name="vrf", + schema_field="vrf", + required=False, + bind_mode="discover_select", + discover_profile_id="zte.config_vrf", + discover_value_field="vrf_name", + discover_label_field="vrf_name", + discover_filter_field="address_families", + discover_filter_contains="ipv6", +) + _BGP_NEIGHBOR_PLACEHOLDER = PlaceholderDef( name="neighbor", schema_field="neighbor", @@ -351,6 +393,8 @@ _IP_ROUTE_FIELDS: list[FieldDef] = [ FieldDef("pri", length=16, role="meta", display_name="Pri"), FieldDef("metric", length=32, role="meta", display_name="Metric"), FieldDef("flags", length=16, role="meta", display_name="Flags"), + FieldDef("rd", length=64, role="meta", display_name="RD"), + FieldDef("address_families", length=64, role="meta", display_name="AF"), ] _IPV6_ROUTE_FIELDS: list[FieldDef] = [ @@ -362,6 +406,8 @@ _IPV6_ROUTE_FIELDS: list[FieldDef] = [ FieldDef("pri", length=16, role="meta", display_name="Pri"), FieldDef("metric", length=32, role="meta", display_name="Metric"), FieldDef("flags", length=16, role="meta", display_name="Flags"), + FieldDef("rd", length=64, role="meta", display_name="RD"), + FieldDef("address_families", length=64, role="meta", display_name="AF"), ] _L2VPN_PW_FIELDS: list[FieldDef] = [ @@ -707,13 +753,28 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show bgp vpnv4 unicast vrf summary | one-line", match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P\S+)\s+summary(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv4 unicast summary", - description="Per-VRF BGP VPNv4 peer summary.", - placeholders=[_BGP_VRF_PLACEHOLDER], + description=( + "Per-VRF BGP VPNv4 peer summary. Bind VRFs or leave empty for all " + "(from config VRF intent); aux: IPv4 FIB + config_vrf." + ), + placeholders=[_VRF_PLACEHOLDER_IPV4], fields=list(_BGP_PEER_FIELDS), tags=["bgp", "vpnv4", "vrf", "status"], sort_order=370, enabled=True, kind="collect", + aux_commands=[ + AuxCommand(key="config_vrf", profile_id="zte.config_vrf"), + AuxCommand(key="ip_route", profile_id="zte.ip_route_vrf"), + ], + enrich_joins=[ + EnrichJoin( + from_aux="config_vrf", + left_on="vrf", + right_on="vrf_name", + take=("rd", "address_families"), + ), + ], ), ParseProfile( profile_id="zte.bgp_vpnv6_vrf_summary", @@ -724,13 +785,28 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show bgp vpnv6 unicast vrf summary | one-line", match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P\S+)\s+summary(?:\s*\|\s*one-line)?\s*$", textfsm_command="show bgp vpnv6 unicast summary", - description="Per-VRF BGP VPNv6 peer summary.", - placeholders=[_BGP_VRF_PLACEHOLDER], + description=( + "Per-VRF BGP VPNv6 peer summary. Bind VRFs or leave empty for all " + "(from config VRF intent); aux: IPv6 FIB + config_vrf." + ), + placeholders=[_VRF_PLACEHOLDER_IPV6], fields=list(_BGP_PEER_FIELDS), tags=["bgp", "vpnv6", "vrf", "status"], sort_order=375, enabled=True, kind="collect", + aux_commands=[ + AuxCommand(key="config_vrf", profile_id="zte.config_vrf"), + AuxCommand(key="ipv6_route", profile_id="zte.ipv6_route_vrf"), + ], + enrich_joins=[ + EnrichJoin( + from_aux="config_vrf", + left_on="vrf", + right_on="vrf_name", + take=("rd", "address_families"), + ), + ], ), ParseProfile( profile_id="zte.bgp_evpn_summary", @@ -1052,13 +1128,25 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show ip forwarding route vrf | one-line", match=r"(?i)^\s*show\s+ip\s+forwarding\s+route\s+vrf\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show ip forwarding route", - description="IPv4 FIB for one VRF.", - placeholders=[_BGP_VRF_PLACEHOLDER], + description=( + "IPv4 FIB per VRF. Bind VRFs or leave empty for all ipv4 VRFs " + "(config VRF intent aux)." + ), + placeholders=[_VRF_PLACEHOLDER_IPV4], fields=list(_IP_ROUTE_FIELDS), tags=["route", "ipv4", "vrf"], sort_order=450, enabled=True, kind="collect", + aux_commands=[AuxCommand(key="config_vrf", profile_id="zte.config_vrf")], + enrich_joins=[ + EnrichJoin( + from_aux="config_vrf", + left_on="vrf", + right_on="vrf_name", + take=("rd", "address_families"), + ), + ], ), ParseProfile( profile_id="zte.ip_route", @@ -1085,13 +1173,25 @@ def _zte_status_profiles() -> list[ParseProfile]: command_template="show ipv6 forwarding route vrf | one-line", match=r"(?i)^\s*show\s+ipv6\s+forwarding\s+route\s+vrf\s+(?P\S+)(?:\s*\|\s*one-line)?\s*$", textfsm_command="show ipv6 forwarding route", - description="IPv6 FIB for one VRF.", - placeholders=[_BGP_VRF_PLACEHOLDER], + description=( + "IPv6 FIB per VRF. Bind VRFs or leave empty for all ipv6 VRFs " + "(config VRF intent aux)." + ), + placeholders=[_VRF_PLACEHOLDER_IPV6], fields=list(_IPV6_ROUTE_FIELDS), tags=["route", "ipv6", "vrf"], sort_order=460, enabled=True, kind="collect", + aux_commands=[AuxCommand(key="config_vrf", profile_id="zte.config_vrf")], + enrich_joins=[ + EnrichJoin( + from_aux="config_vrf", + left_on="vrf", + right_on="vrf_name", + take=("rd", "address_families"), + ), + ], ), ParseProfile( profile_id="zte.ipv6_route", @@ -1416,6 +1516,8 @@ def profile_to_public_dict(p: ParseProfile, *, overrides: dict[str, Any] | None "discover_profile_id": ph.discover_profile_id, "discover_value_field": ph.discover_value_field, "discover_label_field": ph.discover_label_field, + "discover_filter_field": ph.discover_filter_field, + "discover_filter_contains": ph.discover_filter_contains, } for ph in p.placeholders ], diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index df12922..c8f661b 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -290,6 +290,9 @@ def _assert_bindings_ready(db: Session, task_id: str) -> None: profile = get_profile(it.source_profile_id) if not profile or not profile.placeholders: continue + # Optional discover placeholders: empty bindings → expand all at collect. + if all(not ph.required for ph in profile.placeholders): + continue binds = ( db.query(BizStateTaskItemBinding) .filter(BizStateTaskItemBinding.item_id == it.id) diff --git a/tests/test_zte_extended_parsers.py b/tests/test_zte_extended_parsers.py index 481c810..0e03011 100644 --- a/tests/test_zte_extended_parsers.py +++ b/tests/test_zte_extended_parsers.py @@ -492,6 +492,53 @@ class ZteExtendedParserTests(unittest.TestCase): self.assertEqual(hit.params.get("vrf"), "CUST_A") self.assertEqual(hit.params.get("neighbor"), "10.0.0.1") + # BGP VRF summary / Forwarding VRF: optional bind + config_vrf aux + from netx_api.biz_state.command_match import ( + EXPAND_ALL_COMMAND, + expand_bindings_from_discover_records, + ) + from netx_api.biz_state.collect_session import resolve_aux_command + + for pid in ( + "zte.bgp_vpnv4_vrf_summary", + "zte.bgp_vpnv6_vrf_summary", + "zte.ip_route_vrf", + "zte.ipv6_route_vrf", + ): + prof = get_profile(pid) + assert prof is not None + self.assertTrue(prof.placeholders) + self.assertFalse(prof.placeholders[0].required) + self.assertEqual(prof.placeholders[0].discover_profile_id, "zte.config_vrf") + self.assertTrue(any(a.profile_id == "zte.config_vrf" for a in prof.aux_commands)) + + v4 = get_profile("zte.bgp_vpnv4_vrf_summary") + assert v4 is not None + self.assertTrue(any(a.key == "ip_route" for a in v4.aux_commands)) + sentinel = expand_from_bindings(profile=v4, bindings=[]) + self.assertEqual(sentinel[0][0], EXPAND_ALL_COMMAND) + bound = expand_from_bindings(profile=v4, bindings=[{"vrf": "CUST_A"}]) + self.assertEqual(bound[0][0], "show bgp vpnv4 unicast vrf CUST_A summary | one-line") + ra_ip = resolve_aux_command( + next(a for a in v4.aux_commands if a.key == "ip_route"), + params={"vrf": "CUST_A"}, + ) + self.assertEqual(ra_ip.command, "show ip forwarding route vrf CUST_A | one-line") + + v6 = get_profile("zte.bgp_vpnv6_vrf_summary") + assert v6 is not None + self.assertTrue(any(a.key == "ipv6_route" for a in v6.aux_commands)) + + records = [ + {"vrf_name": "CUST_A", "address_families": "ipv4,ipv6", "rd": "100:1"}, + {"vrf_name": "CUST_B", "address_families": "ipv4", "rd": "100:2"}, + {"vrf_name": "CUST_C", "address_families": "ipv6", "rd": "100:3"}, + ] + ipv4_pairs = expand_bindings_from_discover_records(profile=v4, records=records) + self.assertEqual({p[1]["vrf"] for p in ipv4_pairs}, {"CUST_A", "CUST_B"}) + 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"}) + 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 eaf600c..ee144cc 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -237,6 +237,7 @@ const en = { cancel: "Close", bindTitle: "Select VRF bindings", unbound: "Not bound", + allVrfsDefault: "All VRFs (default)", batches: "Batches", viewBatch: "Open", export: "Export", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1520ba7..0963198 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -237,6 +237,7 @@ const zh = { cancel: "关闭", bindTitle: "选择 VRF 绑定", unbound: "未关联", + allVrfsDefault: "默认全部 VRF", batches: "采集批次", viewBatch: "查看", export: "导出", diff --git a/web/src/pages/network/BizStatePage.tsx b/web/src/pages/network/BizStatePage.tsx index 19ec901..ddd819f 100644 --- a/web/src/pages/network/BizStatePage.tsx +++ b/web/src/pages/network/BizStatePage.tsx @@ -51,7 +51,12 @@ type TaskRow = { interval_sec?: number; }; -type Placeholder = { name: string }; +type Placeholder = { + name: string; + required?: boolean; + bind_mode?: string; + discover_profile_id?: string; +}; type Profile = { profile_id: string; title: string; @@ -786,17 +791,20 @@ export function BizStatePage() { } }; - const saveBindings = async () => { + const saveBindings = async (values?: string[]) => { if (!taskId || !bindItemId) return; const item = (detail?.items || []).find((it: any) => it.id === bindItemId); const prof = profiles.find((p) => p.profile_id === item?.source_profile_id); const phName = (prof?.placeholders || [])[0]?.name || "vrf"; + const bindOptional = (prof?.placeholders || []).every((ph) => ph.required === false); + const picked = values !== undefined ? values : selectedVrfs; + if (!picked.length && !bindOptional) return; setBusy(true); try { await bizStateSetBindings( taskId, bindItemId, - selectedVrfs.map((v) => ({ placeholder: phName, value: v })), + picked.map((v) => ({ placeholder: phName, value: v })), ); showOk(t("bizState.bindingsSaved")); await loadTask(taskId); @@ -1322,6 +1330,9 @@ export function BizStatePage() { const enabled = Boolean(it?.enabled); const binds = it?.bindings || []; const needsBind = (prof.placeholders || []).length > 0; + const bindOptional = (prof.placeholders || []).every( + (ph) => ph.required === false, + ); return ( @@ -1343,7 +1354,9 @@ export function BizStatePage() { {needsBind ? binds.length ? binds.map((b: any) => b.value).join(", ") - : t("bizState.unbound") + : bindOptional + ? t("bizState.allVrfsDefault") + : t("bizState.unbound") : "—"} @@ -1394,11 +1407,32 @@ export function BizStatePage() { +