Improve VRF bind UX with modal and share CLI cache across lanes.

Open a blocking discover dialog for VRF selection, reorder profile columns with scroll/hints, and reuse shared collect cache between light/heavy monitor items.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-21 18:40:04 +08:00
parent 3576e4a69c
commit 771f7e21c2
6 changed files with 327 additions and 138 deletions

View file

@ -367,6 +367,10 @@ def _run_collect_lane(
per_cmd: int, per_cmd: int,
cap: int, cap: int,
label: str, label: str,
shared_cache: dict[str, Any] | None = None,
cache_lock: Any | None = None,
cmd_locks: dict[str, Any] | None = None,
aux_persisted: set[tuple[str, str]] | None = None,
) -> tuple[int, int, bool, bool]: ) -> tuple[int, int, bool, bool]:
"""Run one SSH lane (own connection + CollectSession + timeout budget).""" """Run one SSH lane (own connection + CollectSession + timeout budget)."""
if not work: if not work:
@ -405,6 +409,9 @@ def _run_collect_lane(
device_type=device_type_eff, device_type=device_type_eff,
vendor_key=vendor_key, vendor_key=vendor_key,
read_timeout=per_cmd, read_timeout=per_cmd,
shared_cache=shared_cache,
cache_lock=cache_lock,
cmd_locks=cmd_locks,
) )
try: try:
batch_row = sdb.get(BizStateBatch, batch_id) batch_row = sdb.get(BizStateBatch, batch_id)
@ -413,7 +420,7 @@ def _run_collect_lane(
# Resolve expand_all → concrete per-VRF commands via discover profile. # Resolve expand_all → concrete per-VRF commands via discover profile.
flat_work: list[WorkItem] = [] flat_work: list[WorkItem] = []
aux_persisted: set[tuple[str, str]] = set() persisted = aux_persisted if aux_persisted is not None else set()
for concrete, params, profile_id, item_id, mode in work: for concrete, params, profile_id, item_id, mode in work:
if mode != "expand_all": if mode != "expand_all":
flat_work.append((concrete, params, profile_id, item_id, mode)) flat_work.append((concrete, params, profile_id, item_id, mode))
@ -627,7 +634,16 @@ def _run_collect_lane(
and aux_mid in _GENERIC_METRICS and aux_mid in _GENERIC_METRICS
): ):
persist_key = (normalize_command(ra.command), aux_mid) persist_key = (normalize_command(ra.command), aux_mid)
if persist_key not in aux_persisted: do_persist = False
if cache_lock is not None:
with cache_lock:
if persist_key not in persisted:
persisted.add(persist_key)
do_persist = True
elif persist_key not in persisted:
persisted.add(persist_key)
do_persist = True
if do_persist:
n_aux = _persist_metric_rows( n_aux = _persist_metric_rows(
sdb, sdb,
batch=batch_row, batch=batch_row,
@ -637,7 +653,6 @@ def _run_collect_lane(
) )
aux_row.row_count = n_aux aux_row.row_count = n_aux
total_rows += n_aux total_rows += n_aux
aux_persisted.add(persist_key)
if n_aux: if n_aux:
_bump_batch_progress(batch_id, add_rows=n_aux) _bump_batch_progress(batch_id, add_rows=n_aux)
sdb.add(aux_row) sdb.add(aux_row)
@ -900,12 +915,20 @@ def _run_collect_session(
raise RuntimeError("no commands to run") raise RuntimeError("no commands to run")
light_work, heavy_work = partition_work(work) light_work, heavy_work = partition_work(work)
shared_cache: dict[str, Any] = {}
cache_lock = threading.RLock()
cmd_locks: dict[str, Any] = {}
aux_persisted: set[tuple[str, str]] = set()
lane_kwargs = dict( lane_kwargs = dict(
batch_id=batch_id, batch_id=batch_id,
creds=creds, creds=creds,
vendor_eff=vendor_eff, vendor_eff=vendor_eff,
device_type_eff=device_type_eff, device_type_eff=device_type_eff,
vendor_key=vendor_key, vendor_key=vendor_key,
shared_cache=shared_cache,
cache_lock=cache_lock,
cmd_locks=cmd_locks,
aux_persisted=aux_persisted,
) )
def _run_light() -> tuple[int, int, bool, bool]: def _run_light() -> tuple[int, int, bool, bool]:

View file

@ -2,9 +2,11 @@
from __future__ import annotations from __future__ import annotations
from contextlib import nullcontext
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any, Callable from typing import Any, Callable
import re import re
import threading
from .command_match import normalize_command from .command_match import normalize_command
from .enrich import EnrichJoin, apply_enrich_joins from .enrich import EnrichJoin, apply_enrich_joins
@ -88,7 +90,11 @@ class ParseBundle:
class CollectSession: class CollectSession:
"""SSH session-scoped command cache + aux fetch/parse.""" """SSH session-scoped command cache + aux fetch/parse.
Optional ``shared_cache`` / ``cache_lock`` / ``cmd_locks`` let light+heavy
lanes reuse the same CLI results within one batch (config_vrf / FIB aux).
"""
def __init__( def __init__(
self, self,
@ -99,6 +105,9 @@ class CollectSession:
vendor_key: str = "", vendor_key: str = "",
read_timeout: int = 120, read_timeout: int = 120,
send_fn: SendFn | None = None, send_fn: SendFn | None = None,
shared_cache: dict[str, CachedCommand] | None = None,
cache_lock: threading.RLock | None = None,
cmd_locks: dict[str, threading.Lock] | None = None,
) -> None: ) -> None:
self.conn = conn self.conn = conn
self.vendor = vendor self.vendor = vendor
@ -106,7 +115,25 @@ class CollectSession:
self.vendor_key = vendor_key self.vendor_key = vendor_key
self.read_timeout = int(read_timeout or 120) self.read_timeout = int(read_timeout or 120)
self._send = send_fn self._send = send_fn
self.cache: dict[str, CachedCommand] = {} self.cache: dict[str, CachedCommand] = (
shared_cache if shared_cache is not None else {}
)
self._cache_lock = cache_lock
self._cmd_locks = cmd_locks if cmd_locks is not None else {}
def _meta_lock(self):
return self._cache_lock if self._cache_lock is not None else nullcontext()
def _command_lock(self, command: str):
ck = normalize_command(command)
if self._cache_lock is None:
return nullcontext()
with self._cache_lock:
lock = self._cmd_locks.get(ck)
if lock is None:
lock = threading.Lock()
self._cmd_locks[ck] = lock
return lock
def _send_show(self, command: str) -> str: def _send_show(self, command: str) -> str:
if self._send is None: if self._send is None:
@ -135,14 +162,16 @@ class CollectSession:
error=str(error or ""), error=str(error or ""),
cmd_row_id=str(cmd_row_id or ""), cmd_row_id=str(cmd_row_id or ""),
) )
self.cache[ck] = entry with self._meta_lock():
self.cache[ck] = entry
return entry return entry
def get_cached(self, command: str) -> CachedCommand | None: def get_cached(self, command: str) -> CachedCommand | None:
ck = normalize_command(command) ck = normalize_command(command)
hit = self.cache.get(ck) with self._meta_lock():
if hit and hit.ok: hit = self.cache.get(ck)
return hit if hit and hit.ok:
return hit
return None return None
def fetch_and_parse( def fetch_and_parse(
@ -154,52 +183,57 @@ class CollectSession:
params: dict[str, str] | None = None, params: dict[str, str] | None = None,
cmd_row_id: str = "", cmd_row_id: str = "",
) -> tuple[CachedCommand, bool]: ) -> tuple[CachedCommand, bool]:
"""Return ``(entry, cache_hit)``. On miss: CLI + optional parser.""" """Return ``(entry, cache_hit)``. On miss: CLI + optional parser.
cached = self.get_cached(command)
if cached is not None: Same concrete CLI is serialized across shared-cache lanes so aux of
return cached, True one monitor item can be reused by the next without re-collecting.
try: """
raw = self._send_show(command) with self._command_lock(command):
except Exception as exc: cached = self.get_cached(command)
entry = self.remember( if cached is not None:
command, return cached, True
raw="",
ok=False,
error=f"{type(exc).__name__}: {exc}",
cmd_row_id=cmd_row_id,
)
return entry, False
records: list[dict[str, Any]] = []
fsm_tables: dict[str, list[dict[str, Any]]] = {}
if parser_id and get_parser(parser_id):
try: try:
records, fsm_tables, _keys = run_parser( raw = self._send_show(command)
parser_id,
raw_text=raw,
vendor=self.vendor,
device_type=self.device_type,
command=textfsm_command or command,
textfsm_command=textfsm_command or "",
params=params or {},
)
except Exception as exc: except Exception as exc:
entry = self.remember( entry = self.remember(
command, command,
raw=raw, raw="",
ok=False, ok=False,
error=f"parse: {type(exc).__name__}: {exc}", error=f"{type(exc).__name__}: {exc}",
cmd_row_id=cmd_row_id, cmd_row_id=cmd_row_id,
) )
return entry, False return entry, False
entry = self.remember( records: list[dict[str, Any]] = []
command, fsm_tables: dict[str, list[dict[str, Any]]] = {}
raw=raw, if parser_id and get_parser(parser_id):
fsm_tables=fsm_tables, try:
records=records, records, fsm_tables, _keys = run_parser(
ok=True, parser_id,
cmd_row_id=cmd_row_id, raw_text=raw,
) vendor=self.vendor,
return entry, False device_type=self.device_type,
command=textfsm_command or command,
textfsm_command=textfsm_command or "",
params=params or {},
)
except Exception as exc:
entry = self.remember(
command,
raw=raw,
ok=False,
error=f"parse: {type(exc).__name__}: {exc}",
cmd_row_id=cmd_row_id,
)
return entry, False
entry = self.remember(
command,
raw=raw,
fsm_tables=fsm_tables,
records=records,
ok=True,
cmd_row_id=cmd_row_id,
)
return entry, False
def primary_rule_keys(parser_id: str) -> list[str]: def primary_rule_keys(parser_id: str) -> list[str]:

View file

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

View file

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

View file

@ -11261,11 +11261,52 @@ html.login-page--paused .login-page__flare {
overflow: auto; overflow: auto;
} }
.bs-bind-list--modal {
max-height: min(48vh, 360px);
padding: 4px 2px;
border: 1px solid rgba(148, 163, 184, 0.18);
border-radius: 8px;
}
.bs-bind-item { .bs-bind-item {
display: block; display: block;
margin-bottom: 4px; margin-bottom: 4px;
} }
.bs-profiles-table-wrap {
overflow-x: auto !important;
overflow-y: auto;
max-height: min(58vh, 560px);
}
.bs-profiles-table {
min-width: 960px;
}
.bs-profiles-table .bs-cmd-cell {
white-space: nowrap;
font-size: 12px;
}
.bs-profiles-table .bs-params-cell {
min-width: 140px;
max-width: 280px;
word-break: break-word;
}
.bs-bind-hint {
margin-top: 4px;
font-size: 12px;
}
.bs-params-warn {
color: #fbbf24;
}
.bs-bind-modal.app-heroui-modal {
z-index: 80;
}
/* biz_state compare result workbook / board — dark surfaces (match nm modal) */ /* biz_state compare result workbook / board — dark surfaces (match nm modal) */
.bs-cmp-job-body { .bs-cmp-job-body {
min-height: min(64vh, 680px); min-height: min(64vh, 680px);

View file

@ -234,11 +234,13 @@ export function BizStatePage() {
const [dailyKeepCount, setDailyKeepCount] = useState(10); const [dailyKeepCount, setDailyKeepCount] = useState(10);
const [selectedBatchIds, setSelectedBatchIds] = useState<string[]>([]); const [selectedBatchIds, setSelectedBatchIds] = useState<string[]>([]);
// VRF bind (inside task modal) // VRF bind modal (blocking)
const [bindItemId, setBindItemId] = useState(""); const [bindItemId, setBindItemId] = useState("");
const [candidates, setCandidates] = useState<Candidate[]>([]); const [candidates, setCandidates] = useState<Candidate[]>([]);
const [selectedVrfs, setSelectedVrfs] = useState<string[]>([]); const [selectedVrfs, setSelectedVrfs] = useState<string[]>([]);
const [discoverCmd, setDiscoverCmd] = useState(""); const [discoverCmd, setDiscoverCmd] = useState("");
const [discoverLoading, setDiscoverLoading] = useState(false);
const [discoverError, setDiscoverError] = useState("");
// batch workbook modal (summary + lazy-paged metric sheets) // batch workbook modal (summary + lazy-paged metric sheets)
const [batchDetail, setBatchDetail] = useState<any>(null); const [batchDetail, setBatchDetail] = useState<any>(null);
@ -756,6 +758,15 @@ export function BizStatePage() {
} }
}; };
const closeBindModal = () => {
setBindItemId("");
setCandidates([]);
setSelectedVrfs([]);
setDiscoverCmd("");
setDiscoverError("");
setDiscoverLoading(false);
};
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);
@ -764,8 +775,13 @@ export function BizStatePage() {
showError(t("bizState.noNeedBind")); showError(t("bizState.noNeedBind"));
return; return;
} }
setBusy(true);
setBindItemId(item.id); setBindItemId(item.id);
setCandidates([]);
setSelectedVrfs([]);
setDiscoverCmd("");
setDiscoverError("");
setDiscoverLoading(true);
setBusy(true);
try { try {
const res = await bizStateDiscover({ const res = await bizStateDiscover({
task_id: taskId, task_id: taskId,
@ -773,8 +789,9 @@ export function BizStatePage() {
placeholder: ph.name, placeholder: ph.name,
}); });
if (!res.ok) { if (!res.ok) {
showError(res.error || t("bizState.discoverFailed")); const err = res.error || t("bizState.discoverFailed");
setCandidates([]); setDiscoverError(err);
showError(err);
return; return;
} }
setDiscoverCmd(res.command || ""); setDiscoverCmd(res.command || "");
@ -784,9 +801,15 @@ export function BizStatePage() {
.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)); setSelectedVrfs(existing.length ? existing : cand.map((c) => c.value));
if (!cand.length) {
setDiscoverError(t("bizState.discoverEmpty"));
}
} catch (e) { } catch (e) {
showError(formatErr(e)); const err = formatErr(e);
setDiscoverError(err);
showError(err);
} finally { } finally {
setDiscoverLoading(false);
setBusy(false); setBusy(false);
} }
}; };
@ -808,8 +831,7 @@ export function BizStatePage() {
); );
showOk(t("bizState.bindingsSaved")); showOk(t("bizState.bindingsSaved"));
await loadTask(taskId); await loadTask(taskId);
setBindItemId(""); closeBindModal();
setCandidates([]);
} catch (e) { } catch (e) {
showError(formatErr(e)); showError(formatErr(e));
} finally { } finally {
@ -1311,14 +1333,14 @@ export function BizStatePage() {
{taskTab === "profiles" ? ( {taskTab === "profiles" ? (
<> <>
<div className="pt-list-table-wrap"> <div className="pt-list-table-wrap bs-profiles-table-wrap">
<table className="data-table pt-list-table"> <table className="data-table pt-list-table bs-profiles-table">
<thead> <thead>
<tr> <tr>
<th>{t("bizState.enable")}</th> <th>{t("bizState.enable")}</th>
<th>{t("bizState.profiles")}</th> <th>{t("bizState.profiles")}</th>
<th>{t("bizState.command")}</th>
<th>{t("bizState.params")}</th> <th>{t("bizState.params")}</th>
<th>{t("bizState.command")}</th>
<th /> <th />
</tr> </tr>
</thead> </thead>
@ -1333,6 +1355,13 @@ export function BizStatePage() {
const bindOptional = (prof.placeholders || []).every( const bindOptional = (prof.placeholders || []).every(
(ph) => ph.required === false, (ph) => ph.required === false,
); );
const bindHint = needsBind
? binds.length
? binds.map((b: any) => b.value).join(", ")
: bindOptional
? t("bizState.allVrfsDefault")
: t("bizState.unbound")
: "—";
return ( return (
<tr key={prof.profile_id}> <tr key={prof.profile_id}>
<td> <td>
@ -1346,25 +1375,38 @@ export function BizStatePage() {
<td> <td>
<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 ? (
<div className="bs-bind-hint muted">
{bindOptional
? t("bizState.bindHintOptional")
: t("bizState.bindHintRequired")}
</div>
) : null}
</td>
<td className="bs-params-cell">
{needsBind ? (
<span
className={
!binds.length && !bindOptional
? "bs-params-warn"
: undefined
}
>
{bindHint}
</span>
) : (
"—"
)}
</td> </td>
<td> <td>
<code>{prof.command_template}</code> <code className="bs-cmd-cell">{prof.command_template}</code>
</td>
<td>
{needsBind
? binds.length
? binds.map((b: any) => b.value).join(", ")
: bindOptional
? t("bizState.allVrfsDefault")
: t("bizState.unbound")
: "—"}
</td> </td>
<td> <td>
{needsBind && enabled && it ? ( {needsBind && enabled && it ? (
<Button <Button
size="sm" size="sm"
variant="secondary" variant="secondary"
isDisabled={busy} isDisabled={busy || discoverLoading}
onPress={() => void startDiscover(it)} onPress={() => void startDiscover(it)}
> >
{t("bizState.discoverVrf")} {t("bizState.discoverVrf")}
@ -1377,76 +1419,6 @@ export function BizStatePage() {
</tbody> </tbody>
</table> </table>
</div> </div>
{bindItemId && candidates.length ? (
<div className="bs-bind-panel">
<h4 style={{ margin: "0 0 8px" }}>{t("bizState.bindTitle")}</h4>
<p className="muted">
<code>{discoverCmd}</code> · {selectedVrfs.length}
</p>
<div className="bs-bind-list">
{candidates.map((c) => (
<label key={c.value} className="bs-bind-item">
<input
type="checkbox"
checked={selectedVrfs.includes(c.value)}
onChange={(e) => {
setSelectedVrfs((prev) =>
e.target.checked
? [...prev, c.value]
: prev.filter((x) => x !== c.value),
);
}}
/>{" "}
{c.label}
{c.rd ? <span className="muted"> · RD {c.rd}</span> : null}
</label>
))}
</div>
<div className="btn-row" style={{ marginTop: 8 }}>
<Button
size="sm"
variant="primary"
isDisabled={
busy ||
(!selectedVrfs.length &&
!(
profiles
.find(
(p) =>
p.profile_id ===
(detail?.items || []).find((it: any) => it.id === bindItemId)
?.source_profile_id,
)
?.placeholders || []
).every((ph) => ph.required === false))
}
onPress={() => void saveBindings()}
>
{t("bizState.saveBindings")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={busy}
onPress={() => void saveBindings([])}
>
{t("bizState.allVrfsDefault")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={busy}
onPress={() => {
setBindItemId("");
setCandidates([]);
}}
>
{t("bizState.cancel")}
</Button>
</div>
</div>
) : null}
</> </>
) : ( ) : (
<div className="pt-list-table-wrap"> <div className="pt-list-table-wrap">
@ -1575,6 +1547,113 @@ export function BizStatePage() {
</Modal.Footer> </Modal.Footer>
</AppModalShell> </AppModalShell>
{/* VRF discover / bind — blocking modal above task dialog */}
<AppModalShell
open={Boolean(bindItemId)}
onClose={closeBindModal}
dismissible={!discoverLoading && !busy}
size="md"
className="bs-bind-modal"
>
<Modal.Header>
<Modal.Heading>{t("bizState.bindTitle")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{discoverLoading ? (
<p className="muted">{t("bizState.discoverLoading")}</p>
) : null}
{discoverCmd ? (
<p className="muted">
<code>{discoverCmd}</code>
{candidates.length ? ` · ${selectedVrfs.length}/${candidates.length}` : null}
</p>
) : null}
{discoverError ? <p className="bs-params-warn">{discoverError}</p> : null}
{!discoverLoading && candidates.length ? (
<>
<div className="btn-row" style={{ gap: 8 }}>
<Button
size="sm"
variant="ghost"
isDisabled={busy}
onPress={() => setSelectedVrfs(candidates.map((c) => c.value))}
>
{t("bizState.selectAllVrfs")}
</Button>
<Button
size="sm"
variant="ghost"
isDisabled={busy}
onPress={() => setSelectedVrfs([])}
>
{t("bizState.clearVrfs")}
</Button>
</div>
<div className="bs-bind-list bs-bind-list--modal">
{candidates.map((c) => (
<label key={c.value} className="bs-bind-item">
<input
type="checkbox"
checked={selectedVrfs.includes(c.value)}
disabled={busy}
onChange={(e) => {
setSelectedVrfs((prev) =>
e.target.checked
? [...prev, c.value]
: prev.filter((x) => x !== c.value),
);
}}
/>{" "}
{c.label}
{c.rd ? <span className="muted"> · RD {c.rd}</span> : null}
</label>
))}
</div>
</>
) : null}
</Modal.Body>
<Modal.Footer>
{(() => {
const item = (detail?.items || []).find((it: any) => it.id === bindItemId);
const prof = profiles.find((p) => p.profile_id === item?.source_profile_id);
const bindOptional = (prof?.placeholders || []).every((ph) => ph.required === false);
return (
<>
<Button
size="sm"
variant="primary"
isDisabled={
busy || discoverLoading || (!selectedVrfs.length && !bindOptional)
}
onPress={() => void saveBindings()}
>
{t("bizState.saveBindings")}
</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>
</AppModalShell>
{/* Batch workbook: summary + lazy-paged metric sheets */} {/* Batch workbook: summary + lazy-paged metric sheets */}
<AppModalShell <AppModalShell
open={Boolean(batchDetail)} open={Boolean(batchDetail)}