Require explicit VRF bindings and toggle select-all in discover.

Drop default-all-VRF mode so collects must bind VRFs manually; discover starts with none selected and select-all toggles to deselect.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-21 18:55:59 +08:00
parent 771f7e21c2
commit 3c3013dd73
6 changed files with 61 additions and 118 deletions

View file

@ -88,16 +88,18 @@ def normalize_binding_dicts(
return converted return converted
def _optional_discover_placeholders(profile: ParseProfile) -> list[PlaceholderDef]: def _discover_placeholders(profile: ParseProfile) -> list[PlaceholderDef]:
return [ return [
ph ph
for ph in (profile.placeholders or []) for ph in (profile.placeholders or [])
if (not ph.required) if ph.bind_mode == "discover_select" and str(ph.discover_profile_id or "").strip()
and ph.bind_mode == "discover_select"
and str(ph.discover_profile_id or "").strip()
] ]
def _optional_discover_placeholders(profile: ParseProfile) -> list[PlaceholderDef]:
return [ph for ph in _discover_placeholders(profile) if not ph.required]
def filter_discover_records( def filter_discover_records(
records: list[dict[str, Any]] | None, records: list[dict[str, Any]] | None,
ph: PlaceholderDef, ph: PlaceholderDef,
@ -175,12 +177,12 @@ def expand_bindings_from_discover_records(
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)."""
optional = _optional_discover_placeholders(profile) discover_phs = _discover_placeholders(profile)
if not optional: if not discover_phs:
raise ValueError(f"profile {profile.profile_id} has no optional discover placeholders") raise ValueError(f"profile {profile.profile_id} has no discover placeholders")
if len(optional) != 1 or len(profile.placeholders) != 1: if len(discover_phs) != 1 or len(profile.placeholders) != 1:
raise ValueError(f"expand-all only supports a single optional placeholder: {profile.profile_id}") raise ValueError(f"expand-from-discover only supports a single placeholder: {profile.profile_id}")
ph = optional[0] ph = discover_phs[0]
values = filter_discover_records(records, ph) values = filter_discover_records(records, ph)
if not values: if not values:
raise ValueError(f"no discover values for {profile.profile_id} ({ph.discover_profile_id})") raise ValueError(f"no discover values for {profile.profile_id} ({ph.discover_profile_id})")

View file

@ -35,7 +35,7 @@ class PlaceholderDef:
discover_profile_id: str = "" discover_profile_id: str = ""
discover_value_field: str = "" discover_value_field: str = ""
discover_label_field: str = "" discover_label_field: str = ""
# When required=False and no bindings: collect expands 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 = ""
@ -291,21 +291,10 @@ _BGP_VRF_PLACEHOLDER = PlaceholderDef(
discover_label_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( _VRF_PLACEHOLDER_IPV4 = PlaceholderDef(
name="vrf", name="vrf",
schema_field="vrf", schema_field="vrf",
required=False, required=True,
bind_mode="discover_select", bind_mode="discover_select",
discover_profile_id="zte.config_vrf", discover_profile_id="zte.config_vrf",
discover_value_field="vrf_name", discover_value_field="vrf_name",
@ -317,7 +306,7 @@ _VRF_PLACEHOLDER_IPV4 = PlaceholderDef(
_VRF_PLACEHOLDER_IPV6 = PlaceholderDef( _VRF_PLACEHOLDER_IPV6 = PlaceholderDef(
name="vrf", name="vrf",
schema_field="vrf", schema_field="vrf",
required=False, required=True,
bind_mode="discover_select", bind_mode="discover_select",
discover_profile_id="zte.config_vrf", discover_profile_id="zte.config_vrf",
discover_value_field="vrf_name", discover_value_field="vrf_name",
@ -754,8 +743,8 @@ def _zte_status_profiles() -> list[ParseProfile]:
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$", match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv4 unicast summary", textfsm_command="show bgp vpnv4 unicast summary",
description=( description=(
"Per-VRF BGP VPNv4 peer summary. Bind VRFs or leave empty for all " "Per-VRF BGP VPNv4 peer summary. Bind one or more VRFs; "
"(from config VRF intent); aux: IPv4 FIB + config_vrf." "aux: IPv4 FIB + config_vrf."
), ),
placeholders=[_VRF_PLACEHOLDER_IPV4], placeholders=[_VRF_PLACEHOLDER_IPV4],
fields=list(_BGP_PEER_FIELDS), fields=list(_BGP_PEER_FIELDS),
@ -786,8 +775,8 @@ def _zte_status_profiles() -> list[ParseProfile]:
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$", match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show bgp vpnv6 unicast summary", textfsm_command="show bgp vpnv6 unicast summary",
description=( description=(
"Per-VRF BGP VPNv6 peer summary. Bind VRFs or leave empty for all " "Per-VRF BGP VPNv6 peer summary. Bind one or more VRFs; "
"(from config VRF intent); aux: IPv6 FIB + config_vrf." "aux: IPv6 FIB + config_vrf."
), ),
placeholders=[_VRF_PLACEHOLDER_IPV6], placeholders=[_VRF_PLACEHOLDER_IPV6],
fields=list(_BGP_PEER_FIELDS), fields=list(_BGP_PEER_FIELDS),
@ -1128,10 +1117,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
command_template="show ip forwarding route vrf <vrf> | one-line", command_template="show ip forwarding route vrf <vrf> | one-line",
match=r"(?i)^\s*show\s+ip\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$", match=r"(?i)^\s*show\s+ip\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show ip forwarding route", textfsm_command="show ip forwarding route",
description=( description="IPv4 FIB per VRF. Bind one or more VRFs (config VRF intent aux).",
"IPv4 FIB per VRF. Bind VRFs or leave empty for all ipv4 VRFs "
"(config VRF intent aux)."
),
placeholders=[_VRF_PLACEHOLDER_IPV4], placeholders=[_VRF_PLACEHOLDER_IPV4],
fields=list(_IP_ROUTE_FIELDS), fields=list(_IP_ROUTE_FIELDS),
tags=["route", "ipv4", "vrf"], tags=["route", "ipv4", "vrf"],
@ -1173,10 +1159,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
command_template="show ipv6 forwarding route vrf <vrf> | one-line", command_template="show ipv6 forwarding route vrf <vrf> | one-line",
match=r"(?i)^\s*show\s+ipv6\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$", match=r"(?i)^\s*show\s+ipv6\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$",
textfsm_command="show ipv6 forwarding route", textfsm_command="show ipv6 forwarding route",
description=( description="IPv6 FIB per VRF. Bind one or more VRFs (config VRF intent aux).",
"IPv6 FIB per VRF. Bind VRFs or leave empty for all ipv6 VRFs "
"(config VRF intent aux)."
),
placeholders=[_VRF_PLACEHOLDER_IPV6], placeholders=[_VRF_PLACEHOLDER_IPV6],
fields=list(_IPV6_ROUTE_FIELDS), fields=list(_IPV6_ROUTE_FIELDS),
tags=["route", "ipv6", "vrf"], tags=["route", "ipv6", "vrf"],

View file

@ -492,11 +492,8 @@ class ZteExtendedParserTests(unittest.TestCase):
self.assertEqual(hit.params.get("vrf"), "CUST_A") self.assertEqual(hit.params.get("vrf"), "CUST_A")
self.assertEqual(hit.params.get("neighbor"), "10.0.0.1") self.assertEqual(hit.params.get("neighbor"), "10.0.0.1")
# BGP VRF summary / Forwarding VRF: optional bind + config_vrf aux # BGP VRF summary / Forwarding VRF: required bind + config_vrf aux
from netx_api.biz_state.command_match import ( from netx_api.biz_state.command_match import expand_bindings_from_discover_records
EXPAND_ALL_COMMAND,
expand_bindings_from_discover_records,
)
from netx_api.biz_state.collect_session import resolve_aux_command from netx_api.biz_state.collect_session import resolve_aux_command
for pid in ( for pid in (
@ -508,15 +505,15 @@ class ZteExtendedParserTests(unittest.TestCase):
prof = get_profile(pid) prof = get_profile(pid)
assert prof is not None assert prof is not None
self.assertTrue(prof.placeholders) self.assertTrue(prof.placeholders)
self.assertFalse(prof.placeholders[0].required) self.assertTrue(prof.placeholders[0].required)
self.assertEqual(prof.placeholders[0].discover_profile_id, "zte.config_vrf") 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)) self.assertTrue(any(a.profile_id == "zte.config_vrf" for a in prof.aux_commands))
v4 = get_profile("zte.bgp_vpnv4_vrf_summary") v4 = get_profile("zte.bgp_vpnv4_vrf_summary")
assert v4 is not None assert v4 is not None
self.assertTrue(any(a.key == "ip_route" for a in v4.aux_commands)) self.assertTrue(any(a.key == "ip_route" for a in v4.aux_commands))
sentinel = expand_from_bindings(profile=v4, bindings=[]) with self.assertRaises(ValueError):
self.assertEqual(sentinel[0][0], EXPAND_ALL_COMMAND) expand_from_bindings(profile=v4, bindings=[])
bound = expand_from_bindings(profile=v4, bindings=[{"vrf": "CUST_A"}]) 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") self.assertEqual(bound[0][0], "show bgp vpnv4 unicast vrf CUST_A summary | one-line")
ra_ip = resolve_aux_command( ra_ip = resolve_aux_command(

View file

@ -237,13 +237,11 @@ const en = {
cancel: "Close", cancel: "Close",
bindTitle: "Select VRF bindings", bindTitle: "Select VRF bindings",
unbound: "Not bound", unbound: "Not bound",
allVrfsDefault: "All VRFs (default)",
bindHintRequired: "Bind VRF params before collect", bindHintRequired: "Bind VRF params before collect",
bindHintOptional: "Bind VRFs, or leave empty for all",
discoverLoading: "Discovering VRFs…", discoverLoading: "Discovering VRFs…",
discoverEmpty: "No VRFs discovered", discoverEmpty: "No VRFs discovered",
selectAllVrfs: "Select all", selectAllVrfs: "Select all",
clearVrfs: "Clear", deselectAllVrfs: "Deselect all",
batches: "Batches", batches: "Batches",
viewBatch: "Open", viewBatch: "Open",
export: "Export", export: "Export",

View file

@ -237,13 +237,11 @@ const zh = {
cancel: "关闭", cancel: "关闭",
bindTitle: "选择 VRF 绑定", bindTitle: "选择 VRF 绑定",
unbound: "未关联", unbound: "未关联",
allVrfsDefault: "默认全部 VRF",
bindHintRequired: "需关联 VRF 参数后才可采集", bindHintRequired: "需关联 VRF 参数后才可采集",
bindHintOptional: "可关联指定 VRF,未关联则采集全部",
discoverLoading: "正在发现 VRF…", discoverLoading: "正在发现 VRF…",
discoverEmpty: "未发现可用 VRF", discoverEmpty: "未发现可用 VRF",
selectAllVrfs: "全选", selectAllVrfs: "全选",
clearVrfs: "清空", deselectAllVrfs: "取消全选",
batches: "采集批次", batches: "采集批次",
viewBatch: "查看", viewBatch: "查看",
export: "导出", export: "导出",

View file

@ -800,7 +800,9 @@ export function BizStatePage() {
const existing = (item.bindings || []) const existing = (item.bindings || [])
.filter((b: any) => b.placeholder === ph.name) .filter((b: any) => b.placeholder === ph.name)
.map((b: any) => String(b.value)); .map((b: any) => String(b.value));
setSelectedVrfs(existing.length ? existing : cand.map((c) => c.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));
setSelectedVrfs(keep);
if (!cand.length) { if (!cand.length) {
setDiscoverError(t("bizState.discoverEmpty")); setDiscoverError(t("bizState.discoverEmpty"));
} }
@ -819,9 +821,8 @@ export function BizStatePage() {
const item = (detail?.items || []).find((it: any) => it.id === bindItemId); const item = (detail?.items || []).find((it: any) => it.id === bindItemId);
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 phName = (prof?.placeholders || [])[0]?.name || "vrf"; const phName = (prof?.placeholders || [])[0]?.name || "vrf";
const bindOptional = (prof?.placeholders || []).every((ph) => ph.required === false);
const picked = values !== undefined ? values : selectedVrfs; const picked = values !== undefined ? values : selectedVrfs;
if (!picked.length && !bindOptional) return; if (!picked.length) return;
setBusy(true); setBusy(true);
try { try {
await bizStateSetBindings( await bizStateSetBindings(
@ -1352,15 +1353,10 @@ export function BizStatePage() {
const enabled = Boolean(it?.enabled); const enabled = Boolean(it?.enabled);
const binds = it?.bindings || []; const binds = it?.bindings || [];
const needsBind = (prof.placeholders || []).length > 0; const needsBind = (prof.placeholders || []).length > 0;
const bindOptional = (prof.placeholders || []).every(
(ph) => ph.required === false,
);
const bindHint = needsBind const bindHint = needsBind
? binds.length ? binds.length
? binds.map((b: any) => b.value).join(", ") ? binds.map((b: any) => b.value).join(", ")
: bindOptional : t("bizState.unbound")
? t("bizState.allVrfsDefault")
: t("bizState.unbound")
: "—"; : "—";
return ( return (
<tr key={prof.profile_id}> <tr key={prof.profile_id}>
@ -1376,22 +1372,12 @@ export function BizStatePage() {
<div className="pt-list-task-name">{prof.title}</div> <div className="pt-list-task-name">{prof.title}</div>
{prof.description ? <div className="muted">{prof.description}</div> : null} {prof.description ? <div className="muted">{prof.description}</div> : null}
{needsBind ? ( {needsBind ? (
<div className="bs-bind-hint muted"> <div className="bs-bind-hint muted">{t("bizState.bindHintRequired")}</div>
{bindOptional
? t("bizState.bindHintOptional")
: t("bizState.bindHintRequired")}
</div>
) : null} ) : null}
</td> </td>
<td className="bs-params-cell"> <td className="bs-params-cell">
{needsBind ? ( {needsBind ? (
<span <span className={!binds.length ? "bs-params-warn" : undefined}>
className={
!binds.length && !bindOptional
? "bs-params-warn"
: undefined
}
>
{bindHint} {bindHint}
</span> </span>
) : ( ) : (
@ -1577,17 +1563,17 @@ export function BizStatePage() {
size="sm" size="sm"
variant="ghost" variant="ghost"
isDisabled={busy} isDisabled={busy}
onPress={() => setSelectedVrfs(candidates.map((c) => c.value))} onPress={() => {
const allSelected =
candidates.length > 0 &&
candidates.every((c) => selectedVrfs.includes(c.value));
setSelectedVrfs(allSelected ? [] : candidates.map((c) => c.value));
}}
> >
{t("bizState.selectAllVrfs")} {candidates.length > 0 &&
</Button> candidates.every((c) => selectedVrfs.includes(c.value))
<Button ? t("bizState.deselectAllVrfs")
size="sm" : t("bizState.selectAllVrfs")}
variant="ghost"
isDisabled={busy}
onPress={() => setSelectedVrfs([])}
>
{t("bizState.clearVrfs")}
</Button> </Button>
</div> </div>
<div className="bs-bind-list bs-bind-list--modal"> <div className="bs-bind-list bs-bind-list--modal">
@ -1614,43 +1600,22 @@ export function BizStatePage() {
) : null} ) : null}
</Modal.Body> </Modal.Body>
<Modal.Footer> <Modal.Footer>
{(() => { <Button
const item = (detail?.items || []).find((it: any) => it.id === bindItemId); size="sm"
const prof = profiles.find((p) => p.profile_id === item?.source_profile_id); variant="primary"
const bindOptional = (prof?.placeholders || []).every((ph) => ph.required === false); isDisabled={busy || discoverLoading || !selectedVrfs.length}
return ( onPress={() => void saveBindings()}
<> >
<Button {t("bizState.saveBindings")}
size="sm" </Button>
variant="primary" <Button
isDisabled={ size="sm"
busy || discoverLoading || (!selectedVrfs.length && !bindOptional) variant="ghost"
} isDisabled={discoverLoading}
onPress={() => void saveBindings()} onPress={closeBindModal}
> >
{t("bizState.saveBindings")} {t("bizState.cancel")}
</Button> </Button>
{bindOptional ? (
<Button
size="sm"
variant="secondary"
isDisabled={busy || discoverLoading}
onPress={() => void saveBindings([])}
>
{t("bizState.allVrfsDefault")}
</Button>
) : null}
<Button
size="sm"
variant="ghost"
isDisabled={discoverLoading}
onPress={closeBindModal}
>
{t("bizState.cancel")}
</Button>
</>
);
})()}
</Modal.Footer> </Modal.Footer>
</AppModalShell> </AppModalShell>