mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
3576e4a69c
commit
771f7e21c2
6 changed files with 327 additions and 138 deletions
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: "导出",
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue