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

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

View file

@ -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: "采集批次",

View file

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

View file

@ -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<string, string>;
};
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<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);
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() {
<th>{t("bizState.enable")}</th>
<th>{t("bizState.profiles")}</th>
<th>{t("bizState.params")}</th>
<th>{t("bizState.colActions")}</th>
<th>{t("bizState.command")}</th>
<th />
</tr>
</thead>
<tbody>
@ -1377,17 +1417,17 @@ export function BizStatePage() {
</td>
<td className="bs-params-cell">
{needsBind ? (
<span className={!binds.length ? "bs-params-warn" : undefined}>
<div
className={`bs-params-scroll${!binds.length ? " bs-params-warn" : ""}`}
title={bindHint}
>
{bindHint}
</span>
</div>
) : (
"—"
)}
</td>
<td>
<code className="bs-cmd-cell">{prof.command_template}</code>
</td>
<td>
<td className="bs-actions-cell">
{needsBind && enabled && it ? (
<Button
size="sm"
@ -1395,9 +1435,16 @@ export function BizStatePage() {
isDisabled={busy || discoverLoading}
onPress={() => void startDiscover(it)}
>
{t("bizState.discoverVrf")}
{t("bizState.discoverBind")}
</Button>
) : null}
) : needsBind ? (
<span className="muted">—</span>
) : (
"—"
)}
</td>
<td>
<code className="bs-cmd-cell">{prof.command_template}</code>
</td>
</tr>
);