From 56569e49d1543918fe07fbfa027acddd4fa0a41b Mon Sep 17 00:00:00 2001 From: oliver Date: Tue, 1 Sep 2026 20:37:42 +0800 Subject: [PATCH] Add topology NE create/connect flows and trim connect-test logs. Bring full managed-NE create onto the canvas (keep placeholders), add single/multi connect test and multi discover links, split topology/managedNe dialogs into modules, and shorten redundant connectivity failure details. Co-authored-by: Cursor --- netx_api/ne_connect.py | 128 +++- tests/test_ne_connect_detail.py | 61 ++ web/src/i18n/en.ts | 13 +- web/src/i18n/zh.ts | 12 +- web/src/pages/NePage.tsx | 420 ++----------- web/src/pages/TopologyPage.tsx | 171 +++--- .../ManagedNeConnectDetailDialog.tsx | 71 +++ .../pages/managedNe/ManagedNeFormDialog.tsx | 273 +++++++++ web/src/pages/managedNe/connectStatus.ts | 14 + web/src/pages/managedNe/formState.ts | 167 +++++ web/src/pages/topology/TopologyCtxMenu.tsx | 70 +++ web/src/pages/topology/TopologyModals.tsx | 577 ++++-------------- .../topology/hooks/useTopologyConnectTest.ts | 111 ++++ .../topology/hooks/useTopologyCreateNe.ts | 225 +++++++ .../topology/modals/AddNePaletteDialog.tsx | 194 ++++++ .../topology/modals/CreateNeModeDialog.tsx | 48 ++ .../pages/topology/modals/NewRootDialog.tsx | 66 ++ .../topology/modals/OutsidePeersDialog.tsx | 186 ++++++ .../modals/PlaceholderCreateDialog.tsx | 104 ++++ 19 files changed, 1980 insertions(+), 931 deletions(-) create mode 100644 tests/test_ne_connect_detail.py create mode 100644 web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx create mode 100644 web/src/pages/managedNe/ManagedNeFormDialog.tsx create mode 100644 web/src/pages/managedNe/connectStatus.ts create mode 100644 web/src/pages/managedNe/formState.ts create mode 100644 web/src/pages/topology/hooks/useTopologyConnectTest.ts create mode 100644 web/src/pages/topology/hooks/useTopologyCreateNe.ts create mode 100644 web/src/pages/topology/modals/AddNePaletteDialog.tsx create mode 100644 web/src/pages/topology/modals/CreateNeModeDialog.tsx create mode 100644 web/src/pages/topology/modals/NewRootDialog.tsx create mode 100644 web/src/pages/topology/modals/OutsidePeersDialog.tsx create mode 100644 web/src/pages/topology/modals/PlaceholderCreateDialog.tsx diff --git a/netx_api/ne_connect.py b/netx_api/ne_connect.py index 096b5d9..15748c4 100644 --- a/netx_api/ne_connect.py +++ b/netx_api/ne_connect.py @@ -209,14 +209,86 @@ def _classify_connect_error(creds: dict[str, Any], exc: BaseException) -> str: return detail +def _exc_headline(exc: BaseException) -> str: + """First line of the exception only (Netmiko often appends multi-line advice).""" + text = str(exc).strip() or type(exc).__name__ + first = text.splitlines()[0].strip() + return f"{type(exc).__name__}: {first}"[:480] + + +def _root_cause_headline(exc: BaseException) -> str | None: + """Underlying OS/socket error when Netmiko wraps TimeoutError / etc.""" + cause = exc.__cause__ or exc.__context__ + if cause is None or cause is exc: + return None + # Prefer the deepest non-trivial cause one level down (TimeoutError under NetmikoTimeout). + headline = _exc_headline(cause) + outer = _exc_headline(exc) + if headline == outer: + return None + return headline + + +def _is_network_reachability_fail(exc: BaseException) -> bool: + """TCP timeout / refused / unreachable — stack traces add noise, not diagnosis.""" + chunks = [str(exc)] + if exc.__cause__ is not None: + chunks.append(str(exc.__cause__)) + if exc.__context__ is not None: + chunks.append(str(exc.__context__)) + raw = " ".join(chunks).lower() + name = type(exc).__name__.lower() + if "timeout" in name or "timeout" in raw: + return True + if "connection refused" in raw or "10060" in raw or "10061" in raw: + return True + if "no route" in raw or "network is unreachable" in raw or "name or service not known" in raw: + return True + return False + + +def _compact_traceback(*, max_frames: int = 8) -> str: + """File/line frames only; drop exception body (already shown on error=).""" + tb = traceback.format_exc().strip() + if not tb: + return "" + frames: list[str] = [] + for line in tb.splitlines(): + s = line.rstrip() + if s.startswith("Traceback ") or s.startswith("During handling"): + continue + # Exception summary / advice paragraphs (no leading indent, not a frame header). + if frames and s and not s.startswith(" ") and not s.startswith("File "): + break + if s.startswith(" File ") or (frames and s.startswith(" ")): + frames.append(s) + continue + if s.startswith("File "): + frames.append(s) + if not frames: + return "" + # Keep the deepest frames (closest to failure). + if len(frames) > max_frames * 2: + # each frame is typically 2 lines (File + code) + frames = frames[-(max_frames * 2) :] + return "\n".join(frames) + + def _format_failure_detail(creds: dict[str, Any], exc: BaseException) -> str: lines = _connect_context_lines(creds) - lines.append(f"result=fail") - lines.append(f"error={type(exc).__name__}: {exc}") - tb = traceback.format_exc().strip() - if tb: - lines.append("") - lines.append(tb) + lines.append("result=fail") + lines.append(f"error={_exc_headline(exc)}") + root = _root_cause_headline(exc) + if root: + lines.append(f"cause={root}") + # Reachability failures: context + one-liners are enough. + # Auth/CLI/hop surprises still get a short stack for support. + if not _is_network_reachability_fail(exc): + stack = _compact_traceback() + if stack: + lines.append("") + lines.append("stack:") + lines.append(stack) return _truncate_detail("\n".join(lines)) @@ -386,7 +458,20 @@ def _run_single(ne_id: str) -> None: _update_row(ne_id, status, message, discovered, detail=detail) except Exception as exc: _log.exception("connect test failed for %s", ne_id) - _update_row(ne_id, "fail", str(exc)[:480], detail=_truncate_detail(traceback.format_exc())) + detail = _format_failure_detail( + { + "ip_address": "?", + "port": "?", + "protocol": "?", + "device_type": "?", + "vendor": "?", + "username": "?", + "hop_enabled": False, + }, + exc, + ) + # Prefer short message; full multi-line Netmiko advice is not useful in the pill. + _update_row(ne_id, "fail", _exc_headline(exc)[:480], detail=detail) finally: db.close() @@ -436,8 +521,19 @@ def _run_single_ume(ume_ne_id: str) -> None: try: creds, _device = resolve_cli_target(db, ume_ne_id=uid) except Exception as exc: - detail = _truncate_detail(traceback.format_exc()) - _update_ume_override_row(uid, "fail", str(exc)[:480], detail=detail) + detail = _format_failure_detail( + { + "ip_address": "?", + "port": "?", + "protocol": "?", + "device_type": "?", + "vendor": "?", + "username": "?", + "hop_enabled": False, + }, + exc, + ) + _update_ume_override_row(uid, "fail", _exc_headline(exc)[:480], detail=detail) return skip = cli_creds_skip_reason(creds, interactive=False) if skip: @@ -450,7 +546,19 @@ def _run_single_ume(ume_ne_id: str) -> None: _update_ume_override_row(uid, status, message, discovered, detail=detail) except Exception as exc: _log.exception("ume connect test failed for %s", ume_ne_id) - _update_ume_override_row(ume_ne_id, "fail", str(exc)[:480], detail=_truncate_detail(traceback.format_exc())) + detail = _format_failure_detail( + { + "ip_address": "?", + "port": "?", + "protocol": "?", + "device_type": "?", + "vendor": "?", + "username": "?", + "hop_enabled": False, + }, + exc, + ) + _update_ume_override_row(ume_ne_id, "fail", _exc_headline(exc)[:480], detail=detail) finally: db.close() diff --git a/tests/test_ne_connect_detail.py b/tests/test_ne_connect_detail.py new file mode 100644 index 0000000..e8d1409 --- /dev/null +++ b/tests/test_ne_connect_detail.py @@ -0,0 +1,61 @@ +"""Connect-failure detail formatting should stay short and non-redundant.""" + +from __future__ import annotations + +from netx_api.ne_connect import ( + _exc_headline, + _format_failure_detail, + _is_network_reachability_fail, + _root_cause_headline, +) + + +def test_exc_headline_uses_first_line_only() -> None: + exc = Exception("TCP connection to device failed.\n\nCommon causes of this problem are:\n1. wrong IP") + assert _exc_headline(exc) == "Exception: TCP connection to device failed." + assert "Common causes" not in _exc_headline(exc) + + +def test_root_cause_headline() -> None: + try: + try: + raise TimeoutError("[WinError 10060] timed out") + except TimeoutError as inner: + raise RuntimeError("TCP connection to device failed.") from inner + except RuntimeError as outer: + root = _root_cause_headline(outer) + assert root is not None + assert "TimeoutError" in root + assert "10060" in root + + +def test_format_failure_detail_omits_stack_for_timeout() -> None: + creds = { + "ip_address": "192.168.0.127", + "port": 22, + "protocol": "ssh", + "device_type": "huawei", + "vendor": "Huawei", + "username": "huawei", + "hop_enabled": False, + } + try: + try: + raise TimeoutError("[WinError 10060] timed out") + except TimeoutError as inner: + raise RuntimeError( + "TCP connection to device failed.\n\nCommon causes of this problem are:\n1. firewall" + ) from inner + except RuntimeError as outer: + assert _is_network_reachability_fail(outer) + detail = _format_failure_detail(creds, outer) + + assert "target=192.168.0.127:22/ssh" in detail + assert "hop=disabled (direct)" in detail + assert "result=fail" in detail + assert "error=RuntimeError: TCP connection to device failed." in detail + assert "cause=TimeoutError:" in detail + assert "Common causes" not in detail + assert "Traceback" not in detail + assert "stack:" not in detail + assert "File \"" not in detail diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 9e5f075..52e1f30 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -1631,7 +1631,7 @@ const en = { "After discover, project neighbors of scanned NEs onto the canvas (Save to keep permanently)", undo: "Undo", redo: "Redo", - discoverSelected: "Discover selected ({{count}})", + discoverSelected: "Discover links ({{count}})", removeSelected: "Remove selected ({{count}})", openWebcrt: "Open terminal", openNe: "NE details", @@ -1645,6 +1645,12 @@ const en = { paneMenu: "Canvas", edgeMenu: "Edge", createNe: "New NE", + createNeModeHint: + "Create a full managed NE, or a topology placeholder (credentials can be filled later).", + createNeManaged: "Managed NE", + createNeManagedHint: "Same full form as NE management; placed on the canvas after create.", + createNePlaceholder: "Placeholder NE", + createNePlaceholderHint: "Name/IP only; topology placeholder for sketching first.", createNeTitle: "New topology placeholder NE", createNeHint: "Source is topology placeholder. Complete login via WebCRT, or promote in NE management.", @@ -1654,7 +1660,12 @@ const en = { createNeIp: "IP (optional)", createNeIpPh: "Can fill later during setup", createNeDone: "Created placeholder {{name}}", + createManagedNeDone: "Created and placed {{name}}", createNeBusy: "Creating…", + connectTest: "Connectivity test", + connectTestSelected: "Connectivity test ({{count}})", + connectTestNeedManaged: "Node is not linked to a managed NE; cannot run connectivity test", + connectTestSkipped: "skipped {{count}} without managed link", selectionMenu: "Selection", edgeStyle: "Edge style", edgeColor: "Color", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 31f6f68..4631812 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -1617,7 +1617,7 @@ const zh = { discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布(需保存才永久保留)", undo: "撤销", redo: "重做", - discoverSelected: "发现选中 ({{count}})", + discoverSelected: "发现链路({{count}})", removeSelected: "移除选中 ({{count}})", openWebcrt: "打开终端", openNe: "网元详情", @@ -1630,6 +1630,11 @@ const zh = { paneMenu: "画布", edgeMenu: "链路", createNe: "新建网元", + createNeModeHint: "选择创建正式运维网元,或仅建拓扑占位(可稍后完善登录信息)。", + createNeManaged: "正式网元", + createNeManagedHint: "与网元管理相同的完整表单;创建后自动上图。", + createNePlaceholder: "占位网元", + createNePlaceholderHint: "仅名称/IP,来源为拓扑占位,适合先构图再补齐凭据。", createNeTitle: "新建拓扑占位网元", createNeHint: "来源为拓扑占位;完善登录信息后可在 WebCRT 连接,或在网元管理升级为手工。", createNeName: "名称", @@ -1638,7 +1643,12 @@ const zh = { createNeIp: "IP(可选)", createNeIpPh: "可稍后在完善时填写", createNeDone: "已新建占位网元 {{name}}", + createManagedNeDone: "已创建网元并上图 {{name}}", createNeBusy: "创建中…", + connectTest: "连通性测试", + connectTestSelected: "连通性测试 ({{count}})", + connectTestNeedManaged: "该节点未绑定运维网元,无法做连通性测试", + connectTestSkipped: "跳过 {{count}} 台无运维绑定", selectionMenu: "多选", edgeStyle: "链路样式", edgeColor: "颜色", diff --git a/web/src/pages/NePage.tsx b/web/src/pages/NePage.tsx index 0530a51..b085cf1 100644 --- a/web/src/pages/NePage.tsx +++ b/web/src/pages/NePage.tsx @@ -6,7 +6,6 @@ import { batchApplyHopManagedNe, batchDeleteManagedNe, connectTestManagedNe, - createManagedNe, deleteUmeManagedNe, deleteManagedNe, fetchIdsByTag, @@ -17,7 +16,6 @@ import { importManagedNe, downloadManagedNeImportTemplate, syncUmeManagedNe, - updateManagedNe, type ManagedNeStats, } from "../services/api"; import { HelpHint } from "../components/HelpHint"; @@ -30,99 +28,29 @@ import type { ManagedNeItem } from "../types"; import { pageCount } from "../utils/display"; import { formatSystemTime } from "../utils/time"; import { openOrFocusModule } from "../utils/moduleWindows"; +import { ManagedNeFormDialog } from "./managedNe/ManagedNeFormDialog"; +import { ManagedNeConnectDetailDialog } from "./managedNe/ManagedNeConnectDetailDialog"; +import { connectStatusClass } from "./managedNe/connectStatus"; import { - defaultHopTemplate, - isAutoHopTemplate, - patchHopVendorChange, - type HopVendor, -} from "../utils/hopProxy"; + deviceTypeForVendor, + emptyManagedNeForm, + managedSourceKey, + type ManagedNeFormState, +} from "./managedNe/formState"; /** Hide UME→managed sync/delete controls until needed again. APIs remain available. */ const SHOW_UME_MANAGED_SYNC = false; -type FormState = { - name: string; - vendor: string; - device_type: string; - ip_address: string; - port: number; - protocol: string; - username: string; - password: string; - tags: string; - remark: string; - hop_enabled: boolean; - hop_vendor: HopVendor; - hop_host: string; - hop_port: number; - hop_protocol: string; - hop_username: string; - hop_password: string; - hop_command_template: string; - hop_vrf: string; - hop_target_auth_mode: "bastion_managed" | "manual"; - hop_enter_system_view: boolean; -}; - type AccountState = { username: string; password: string; }; -function deviceTypeForVendor(vendor: string): string { - if (vendor === "ZTE") return "zte_zxros"; - if (vendor === "Huawei") return "huawei"; - if (vendor === "Cisco") return "cisco_ios"; - if (vendor === "Juniper") return "juniper_junos"; - if (vendor === "Nokia") return "nokia_sros"; - return "generic"; -} - -const emptyForm = (): FormState => ({ - name: "", - vendor: "ZTE", - device_type: "zte_zxros", - ip_address: "", - port: 22, - protocol: "ssh", - username: "", - password: "", - tags: "", - remark: "", - hop_enabled: false, - hop_vendor: "zte", - hop_host: "", - hop_port: 22, - hop_protocol: "ssh", - hop_username: "", - hop_password: "", - hop_command_template: defaultHopTemplate("zte", "ssh", ""), - hop_vrf: "", - hop_target_auth_mode: "bastion_managed", - hop_enter_system_view: false, -}); - const emptyAccount = (): AccountState => ({ username: "", password: "", }); -function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force = false): Partial { - if (!force && !isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf)) { - return {}; - } - return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) }; -} - -function managedSourceKey( - source: string | undefined, -): "manual" | "ume_sync" | "webcrt" | "lldp" | "topology" | "" { - const s = String(source || "").trim().toLowerCase(); - if (!s) return "manual"; - if (s === "ume_sync" || s === "webcrt" || s === "lldp" || s === "topology") return s; - return ""; -} - function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) { return ( @@ -137,21 +65,6 @@ function FormLabel({ children, required }: { children: ReactNode; required?: boo ); } -function connectStatusClass(status: string): string { - if (status === "pass") return "pt-list-status--ok"; - if (status === "fail") return "pt-list-status--failed"; - if (status === "testing") return "pt-list-status--running"; - return "pt-list-status--unknown"; -} - -function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" { - const s = String(status || "").toLowerCase(); - if (s === "pass" || s === "ok") return "up"; - if (s === "fail" || s === "error") return "down"; - if (s === "testing") return "warn"; - return "unknown"; -} - export function NePage() { const { t } = useI18n(); const { showOk, showError } = useToast(); @@ -172,7 +85,7 @@ export function NePage() { const [batchHopOpen, setBatchHopOpen] = useState(false); const [batchAccountOpen, setBatchAccountOpen] = useState(false); const [editing, setEditing] = useState(null); - const [form, setForm] = useState(emptyForm); + const [formSeed, setFormSeed] = useState | undefined>(); const [batchHop, setBatchHop] = useState(emptyHopProxyFields); const [batchAccount, setBatchAccount] = useState(emptyAccount); const [connectDetailRow, setConnectDetailRow] = useState(null); @@ -231,53 +144,6 @@ export function NePage() { const invalidateList = () => queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll }); - const saveMutation = useMutation({ - mutationFn: async () => { - const body = { - name: form.name, - vendor: form.vendor, - device_type: form.device_type, - ip_address: form.ip_address, - port: form.port, - protocol: form.protocol, - username: form.username, - tags: form.tags, - remark: form.remark, - hop_enabled: form.hop_enabled, - hop_vendor: form.hop_vendor, - hop_host: form.hop_host, - hop_port: form.hop_port, - hop_protocol: form.hop_protocol, - hop_username: form.hop_username, - hop_command_template: form.hop_command_template, - hop_vrf: form.hop_vrf, - hop_target_auth_mode: form.hop_target_auth_mode, - hop_enter_system_view: form.hop_enter_system_view, - ...(form.password ? { password: form.password } : {}), - ...(form.hop_password ? { hop_password: form.hop_password } : {}), - }; - if (form.hop_enabled) { - if (!form.hop_host.trim()) throw new Error(t("managedNe.hop.hostRequired")); - if (!form.hop_username.trim()) throw new Error(t("managedNe.hop.userRequired")); - if (!editing && !form.hop_password) throw new Error(t("managedNe.hop.passwordRequired")); - } - if (editing) { - if (!form.password) delete (body as { password?: string }).password; - if (!form.hop_password) delete (body as { hop_password?: string }).hop_password; - return updateManagedNe(editing.id, body); - } - return createManagedNe({ ...body, password: form.password || "" }); - }, - onSuccess: async () => { - setModalOpen(false); - setEditing(null); - setForm(emptyForm()); - showOk(editing ? t("managedNe.form.updated") : t("managedNe.form.created")); - await invalidateList(); - }, - onError: (err) => showError(String(err)), - }); - const deleteMutation = useMutation({ mutationFn: deleteManagedNe, onSuccess: async () => { @@ -438,12 +304,6 @@ export function NePage() { }); const vendors = metaQuery.data?.vendors ?? []; - const deviceTypes = useMemo(() => { - const base = metaQuery.data?.device_types ?? []; - const cur = String(form.device_type || "").trim(); - if (cur && !base.includes(cur)) return [cur, ...base]; - return base; - }, [metaQuery.data?.device_types, form.device_type]); const credsOk = metaQuery.data?.credentials_configured ?? false; const allSelected = useMemo(() => { @@ -453,45 +313,13 @@ export function NePage() { const openCreate = () => { setEditing(null); - setForm(emptyForm()); + setFormSeed(undefined); setModalOpen(true); }; const openEdit = (row: ManagedNeItem) => { setEditing(row); - setForm({ - name: row.name, - vendor: row.vendor, - device_type: row.device_type, - ip_address: row.ip_address, - port: row.port, - protocol: row.protocol, - username: row.username, - password: "", - tags: row.tags, - remark: row.remark, - hop_enabled: row.hop_enabled, - hop_vendor: (["linux", "huawei", "cisco", "zte", "bastion"].includes(row.hop_vendor) - ? row.hop_vendor - : "zte") as HopVendor, - hop_host: row.hop_host, - hop_port: row.hop_port, - hop_protocol: row.hop_protocol, - hop_username: row.hop_username, - hop_password: "", - hop_command_template: isAutoHopTemplate( - row.hop_command_template, - row.hop_vendor, - row.hop_protocol, - row.hop_vrf, - ) - ? defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf) - : row.hop_command_template || defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf), - hop_vrf: row.hop_vrf, - hop_target_auth_mode: - row.hop_target_auth_mode === "manual" ? "manual" : "bastion_managed", - hop_enter_system_view: Boolean(row.hop_enter_system_view), - }); + setFormSeed(undefined); setModalOpen(true); }; @@ -538,11 +366,10 @@ export function NePage() { const name = String(searchParams.get("name") || "").trim(); const ip = String(searchParams.get("ip_address") || "").trim(); const vendorRaw = String(searchParams.get("vendor") || "").trim(); - const base = emptyForm(); + const base = emptyManagedNeForm(); const vendor = vendorRaw || base.vendor; setEditing(null); - setForm({ - ...base, + setFormSeed({ name, vendor, device_type: deviceTypeForVendor(vendor), @@ -999,161 +826,24 @@ export function NePage() { - {modalOpen ? ( -
setModalOpen(false)}> -
e.stopPropagation()}> -

{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}

-

{t("managedNe.form.requiredHint")}

-
- - - - - - - - - - -
- -
- {t("managedNe.hop.sectionTitle")} - - {form.hop_enabled ? ( - setForm((prev) => ({ ...prev, ...patch }))} - hopPasswordRequired={!editing} - hopPasswordOptional={Boolean(editing)} - /> - ) : null} -
-
- - -
-
-
- ) : null} + { + setModalOpen(false); + setEditing(null); + setFormSeed(undefined); + }} + onSaved={async () => { + const wasEdit = Boolean(editing); + setModalOpen(false); + setEditing(null); + setFormSeed(undefined); + showOk(wasEdit ? t("managedNe.form.updated") : t("managedNe.form.created")); + await invalidateList(); + }} + /> {batchHopOpen ? (
setBatchHopOpen(false)}> @@ -1335,47 +1025,13 @@ export function NePage() {
) : null} - {connectDetailRow ? ( -
setConnectDetailRow(null)}> -
e.stopPropagation()}> -

{t("managedNe.connectDetailTitle")}

-

- {connectDetailRow.name || connectDetailRow.ip_address} · {connectDetailRow.ip_address}: - {connectDetailRow.port}/{connectDetailRow.protocol} - {connectDetailRow.connect_tested_at - ? ` · ${formatSystemTime(connectDetailRow.connect_tested_at, { assumeUtcNaive: true })}` - : ""} -

-

- - {connectDetailRow.connect_status} - - {connectDetailRow.connect_message ? ( - — {connectDetailRow.connect_message} - ) : null} -

-
-              {connectDetailRow.connect_detail?.trim() ||
-                connectDetailRow.connect_message?.trim() ||
-                t("managedNe.connectDetailEmpty")}
-            
-
- - -
-
-
- ) : null} + setConnectDetailRow(null)} + onRetestSubmitted={() => { + void invalidateList(); + }} + /> ); } diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 392fd95..80a81e1 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -51,6 +51,8 @@ import { useTopologyHistory } from "./topology/hooks/useTopologyHistory"; import { useTopologyDiscover } from "./topology/hooks/useTopologyDiscover"; import { useTopologyTreeNav } from "./topology/hooks/useTopologyTreeNav"; import { useTopologyCanvas } from "./topology/hooks/useTopologyCanvas"; +import { useTopologyCreateNe } from "./topology/hooks/useTopologyCreateNe"; +import { useTopologyConnectTest } from "./topology/hooks/useTopologyConnectTest"; import { lazy, Suspense, @@ -73,7 +75,6 @@ import type { import { addTopologyViewNodes, createFabricManualEdge, - createTopologyPlaceholder, deleteFabricEdges, purgePlaceholderFabricNodes, fetchLldpCollectDashboard, @@ -165,13 +166,6 @@ export function TopologyPage() { const [paletteSource, setPaletteSource] = useState("managed"); const [paletteSelectedKeys, setPaletteSelectedKeys] = useState([]); const [paletteAdding, setPaletteAdding] = useState(false); - const [createNeDialog, setCreateNeDialog] = useState<{ - flowX: number; - flowY: number; - name: string; - ip_address: string; - } | null>(null); - const [createNeBusy, setCreateNeBusy] = useState(false); const [fullscreen, setFullscreen] = useState(false); /** Opt-in poll so MCP / other clients painting the open map can be watched. Off by default. */ const [liveSync, setLiveSync] = useState(false); @@ -821,6 +815,45 @@ export function TopologyPage() { const closeCtxMenu = useCallback(() => setCtxMenu(null), []); + const createNe = useTopologyCreateNe({ + mapId, + activeViewName: activeView?.name, + canWrite: canWriteTopology, + edgeDefaults, + setNodes, + setEdges, + clearDirty, + historyLockRef, + focusNode, + closeCtxMenu, + }); + const { + modeOpen: createNeModeOpen, + openCreateNeAt, + closeMode: closeCreateNeMode, + pickManaged: pickCreateManaged, + pickPlaceholder: pickCreatePlaceholder, + placeholderDialog, + setPlaceholderDialog, + placeholderBusy, + closePlaceholder, + submitPlaceholder, + managedFormOpen, + managedFormInitial, + closeManagedForm, + onManagedFormSaved, + } = createNe; + + const connectTest = useTopologyConnectTest({ closeCtxMenu }); + const { + connectDetailRow, + connectTestSubmitting, + closeConnectDetail, + runConnectTestForNode, + runConnectTestForNodes, + onConnectRetestSubmitted, + } = connectTest; + useEffect(() => { const syncFs = () => { const el = canvasRef.current; @@ -1400,14 +1433,43 @@ export function TopologyPage() { void runDiscover([id]); }; - const openCreateNeAt = (flowX: number, flowY: number) => { - if (isWorldFlatViewName(activeView?.name)) { - showError(t("topology.worldMapNoDirectNes")); + const selectedDiscoverableCount = useMemo( + () => + selectedNodes.filter((n) => + Boolean(String(n.data.managed_ne_id || n.data.ume_ne_id || "").trim()), + ).length, + [selectedNodes], + ); + + const selectedConnectableCount = useMemo( + () => + selectedNodes.filter((n) => Boolean(String(n.data.managed_ne_id || "").trim())).length, + [selectedNodes], + ); + + const discoverSelectedFor = useCallback(() => { + closeCtxMenu(); + if (!canWriteTopology) { + showError(t("topology.readOnlyHint")); return; } - closeCtxMenu(); - setCreateNeDialog({ flowX, flowY, name: "", ip_address: "" }); - }; + const ids = [ + ...new Set( + selectedNodes + .map((n) => String(n.data.managed_ne_id || n.data.ume_ne_id || "").trim()) + .filter(Boolean), + ), + ]; + if (!ids.length) { + showError(t("topology.discoverOneNeedNe")); + return; + } + void runDiscover(ids); + }, [closeCtxMenu, canWriteTopology, selectedNodes, runDiscover, showError, t]); + + const connectTestSelectedFor = useCallback(() => { + void runConnectTestForNodes(selectedNodes); + }, [runConnectTestForNodes, selectedNodes]); const placeCtxMenu = (clientX: number, clientY: number, size?: { w?: number; h?: number }): { x: number; y: number } => { const pad = 8; @@ -1418,58 +1480,6 @@ export function TopologyPage() { return { x: Math.max(pad, x), y: Math.max(pad, y) }; }; - const submitCreateNe = useCallback(async () => { - if (!mapId || !createNeDialog) return; - const name = createNeDialog.name.trim(); - if (!name) { - showError(t("topology.createNeNameRequired")); - return; - } - setCreateNeBusy(true); - try { - const graph = await createTopologyPlaceholder(mapId, { - name, - ip_address: createNeDialog.ip_address.trim(), - x: createNeDialog.flowX, - y: createNeDialog.flowY, - }); - queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); - historyLockRef.current = true; - applyViewGraph(graph, edgeDefaults, setNodes, setEdges); - historyLockRef.current = false; - clearDirty(); - const created = graph.nodes.find( - (n) => - String(n.name || "").trim() === name && - Math.abs(Number(n.x) - createNeDialog.flowX) < 0.5 && - Math.abs(Number(n.y) - createNeDialog.flowY) < 0.5, - ); - if (created?.fabric_node_id) { - focusNode(created.fabric_node_id, false); - } - setCreateNeDialog(null); - showOk(t("topology.createNeDone").replace("{{name}}", name)); - void queryClient.invalidateQueries({ queryKey: ["managedNe"] }); - void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); - } catch (err) { - showError(String(err)); - } finally { - setCreateNeBusy(false); - } - }, [ - mapId, - createNeDialog, - queryClient, - edgeDefaults, - setNodes, - setEdges, - clearDirty, - showError, - showOk, - t, - focusNode, - ]); - const pendingLocateWorldRef = useRef<{ id: string; worldX?: number; @@ -2351,13 +2361,24 @@ export function TopologyPage() { onCloseNewRoot={() => setNewRootDialog(null)} onSubmitNewRoot={submitNewRoot} createRegionPending={createRegionMut.isPending} - createNeDialog={createNeDialog} - createNeBusy={createNeBusy} - onCreateNeChange={(patch) => - setCreateNeDialog((prev) => (prev ? { ...prev, ...patch } : prev)) + createNeModeOpen={createNeModeOpen} + onCloseCreateNeMode={closeCreateNeMode} + onPickCreateManaged={pickCreateManaged} + onPickCreatePlaceholder={pickCreatePlaceholder} + placeholderDialog={placeholderDialog} + placeholderBusy={placeholderBusy} + onPlaceholderChange={(patch) => + setPlaceholderDialog((prev) => (prev ? { ...prev, ...patch } : prev)) } - onCloseCreateNe={() => setCreateNeDialog(null)} - onSubmitCreateNe={submitCreateNe} + onClosePlaceholder={closePlaceholder} + onSubmitPlaceholder={submitPlaceholder} + managedFormOpen={managedFormOpen} + managedFormInitial={managedFormInitial} + onCloseManagedForm={closeManagedForm} + onManagedFormSaved={(item) => void onManagedFormSaved(item)} + connectDetailRow={connectDetailRow} + onCloseConnectDetail={closeConnectDetail} + onConnectRetestSubmitted={onConnectRetestSubmitted} outsidePeersOpen={outsidePeersOpen} outsidePeers={outsidePeers} outsidePeersVisible={outsidePeersVisible} @@ -2419,12 +2440,18 @@ export function TopologyPage() { onClose={closeCtxMenu} onToggleFullscreen={toggleFullscreen} onRemoveSelected={removeSelected} + onDiscoverSelected={discoverSelectedFor} + onConnectTestSelected={connectTestSelectedFor} + selectedDiscoverableCount={selectedDiscoverableCount} + selectedConnectableCount={selectedConnectableCount} onOpenCreateNe={openCreateNeAt} onPromptNewSubRegion={promptNewSubRegion} onRenameSelectedNode={renameSelectedNode} onDiscoverOne={discoverOneFor} onOpenWebcrt={openWebcrtFor} onOpenNe={openNeFor} + onConnectTest={runConnectTestForNode} + connectTestBusy={connectTestSubmitting} onPurgePlaceholder={purgePlaceholderById} onRemoveNode={removeNodeById} onExpandPhysicalLinks={() => setExpandPhysicalLinks(true)} diff --git a/web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx b/web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx new file mode 100644 index 0000000..550996e --- /dev/null +++ b/web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx @@ -0,0 +1,71 @@ +import { useMutation } from "@tanstack/react-query"; +import { connectTestManagedNe } from "../../services/api"; +import { useI18n } from "../../i18n"; +import { formatSystemTime } from "../../utils/time"; +import type { ManagedNeItem } from "../../types"; +import { connectPillLevel } from "./connectStatus"; + +export type ManagedNeConnectDetailDialogProps = { + row: ManagedNeItem | null; + onClose: () => void; + /** Called after a retest is submitted so parent can refresh/poll. */ + onRetestSubmitted?: (rowId: string) => void; +}; + +export function ManagedNeConnectDetailDialog({ + row, + onClose, + onRetestSubmitted, +}: ManagedNeConnectDetailDialogProps) { + const { t } = useI18n(); + const connectMutation = useMutation({ + mutationFn: connectTestManagedNe, + onSuccess: () => { + if (row) onRetestSubmitted?.(row.id); + }, + }); + + if (!row) return null; + + return ( +
+
e.stopPropagation()}> +

{t("managedNe.connectDetailTitle")}

+

+ {row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol} + {row.connect_tested_at + ? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}` + : ""} +

+

+ + {row.connect_status} + + {row.connect_message ? ( + — {row.connect_message} + ) : null} +

+
+          {row.connect_detail?.trim() || row.connect_message?.trim() || t("managedNe.connectDetailEmpty")}
+        
+
+ + +
+ {connectMutation.isError ? ( +

+ {String(connectMutation.error)} +

+ ) : null} +
+
+ ); +} diff --git a/web/src/pages/managedNe/ManagedNeFormDialog.tsx b/web/src/pages/managedNe/ManagedNeFormDialog.tsx new file mode 100644 index 0000000..cb73841 --- /dev/null +++ b/web/src/pages/managedNe/ManagedNeFormDialog.tsx @@ -0,0 +1,273 @@ +import { useEffect, useMemo, useState, type ReactNode } from "react"; +import { useMutation, useQuery } from "@tanstack/react-query"; +import { createManagedNe, fetchManagedNeMeta, updateManagedNe } from "../../services/api"; +import { HopProxyFields } from "../../components/HopProxyFields"; +import { queryKeys } from "../../constants/queryKeys"; +import { useI18n } from "../../i18n"; +import { patchHopVendorChange } from "../../utils/hopProxy"; +import type { ManagedNeItem } from "../../types"; +import { + applyHopTemplate, + buildManagedNeSaveBody, + emptyManagedNeForm, + formFromManagedNe, + type ManagedNeFormState, +} from "./formState"; + +function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) { + return ( + + {children} + {required ? ( + + ) : null} + + ); +} + +export type ManagedNeFormDialogProps = { + open: boolean; + /** null = create mode */ + editing: ManagedNeItem | null; + /** Prefill when creating (e.g. topology deep-link fields). Ignored when editing. */ + initialValues?: Partial; + onClose: () => void; + onSaved: (item: ManagedNeItem) => void; +}; + +export function ManagedNeFormDialog({ + open, + editing, + initialValues, + onClose, + onSaved, +}: ManagedNeFormDialogProps) { + const { t } = useI18n(); + const [form, setForm] = useState(emptyManagedNeForm); + + const metaQuery = useQuery({ + queryKey: queryKeys.managedNeMeta, + queryFn: fetchManagedNeMeta, + staleTime: 60_000, + enabled: open, + }); + + // Reset only when the dialog opens or the edited row changes — avoid clobbering + // in-progress edits if the parent re-renders with a new initialValues object identity. + const editingId = editing?.id ?? ""; + useEffect(() => { + if (!open) return; + if (editing) { + setForm(formFromManagedNe(editing)); + return; + } + setForm({ ...emptyManagedNeForm(), ...(initialValues || {}) }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- seed once per open/create + }, [open, editingId]); + + const vendors = metaQuery.data?.vendors ?? []; + const deviceTypes = useMemo(() => { + const base = metaQuery.data?.device_types ?? []; + const cur = String(form.device_type || "").trim(); + if (cur && !base.includes(cur)) return [cur, ...base]; + return base; + }, [metaQuery.data?.device_types, form.device_type]); + + const saveMutation = useMutation({ + mutationFn: async () => { + const body = buildManagedNeSaveBody(form, { + editing: Boolean(editing), + hopHostRequired: t("managedNe.hop.hostRequired"), + hopUserRequired: t("managedNe.hop.userRequired"), + hopPasswordRequired: t("managedNe.hop.passwordRequired"), + }); + if (editing) { + return updateManagedNe(editing.id, body); + } + return createManagedNe(body); + }, + onSuccess: (item) => { + onSaved(item); + }, + }); + + if (!open) return null; + + return ( +
{ + if (saveMutation.isPending) return; + onClose(); + }} + > +
e.stopPropagation()}> +

{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}

+

{t("managedNe.form.requiredHint")}

+
+ + + + + + + + + + +
+ +
+ {t("managedNe.hop.sectionTitle")} + + {form.hop_enabled ? ( + setForm((prev) => ({ ...prev, ...patch }))} + hopPasswordRequired={!editing} + hopPasswordOptional={Boolean(editing)} + /> + ) : null} +
+
+ + +
+ {saveMutation.isError ? ( +

+ {String(saveMutation.error)} +

+ ) : null} +
+
+ ); +} diff --git a/web/src/pages/managedNe/connectStatus.ts b/web/src/pages/managedNe/connectStatus.ts new file mode 100644 index 0000000..0601550 --- /dev/null +++ b/web/src/pages/managedNe/connectStatus.ts @@ -0,0 +1,14 @@ +export function connectStatusClass(status: string): string { + if (status === "pass") return "pt-list-status--ok"; + if (status === "fail") return "pt-list-status--failed"; + if (status === "testing") return "pt-list-status--running"; + return "pt-list-status--unknown"; +} + +export function connectPillLevel(status: string): "up" | "down" | "unknown" | "warn" { + const s = String(status || "").toLowerCase(); + if (s === "pass" || s === "ok") return "up"; + if (s === "fail" || s === "error") return "down"; + if (s === "testing") return "warn"; + return "unknown"; +} diff --git a/web/src/pages/managedNe/formState.ts b/web/src/pages/managedNe/formState.ts new file mode 100644 index 0000000..5fe2129 --- /dev/null +++ b/web/src/pages/managedNe/formState.ts @@ -0,0 +1,167 @@ +import { + defaultHopTemplate, + isAutoHopTemplate, + type HopVendor, +} from "../../utils/hopProxy"; +import type { ManagedNeItem } from "../../types"; + +export type ManagedNeFormState = { + name: string; + vendor: string; + device_type: string; + ip_address: string; + port: number; + protocol: string; + username: string; + password: string; + tags: string; + remark: string; + hop_enabled: boolean; + hop_vendor: HopVendor; + hop_host: string; + hop_port: number; + hop_protocol: string; + hop_username: string; + hop_password: string; + hop_command_template: string; + hop_vrf: string; + hop_target_auth_mode: "bastion_managed" | "manual"; + hop_enter_system_view: boolean; +}; + +export function deviceTypeForVendor(vendor: string): string { + if (vendor === "ZTE") return "zte_zxros"; + if (vendor === "Huawei") return "huawei"; + if (vendor === "Cisco") return "cisco_ios"; + if (vendor === "Juniper") return "juniper_junos"; + if (vendor === "Nokia") return "nokia_sros"; + return "generic"; +} + +export function emptyManagedNeForm(): ManagedNeFormState { + return { + name: "", + vendor: "ZTE", + device_type: "zte_zxros", + ip_address: "", + port: 22, + protocol: "ssh", + username: "", + password: "", + tags: "", + remark: "", + hop_enabled: false, + hop_vendor: "zte", + hop_host: "", + hop_port: 22, + hop_protocol: "ssh", + hop_username: "", + hop_password: "", + hop_command_template: defaultHopTemplate("zte", "ssh", ""), + hop_vrf: "", + hop_target_auth_mode: "bastion_managed", + hop_enter_system_view: false, + }; +} + +export function applyHopTemplate( + prev: ManagedNeFormState, + protocol: string, + vrf: string, + force = false, +): Partial { + if ( + !force && + !isAutoHopTemplate(prev.hop_command_template, prev.hop_vendor, prev.hop_protocol, prev.hop_vrf) + ) { + return {}; + } + return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) }; +} + +export function formFromManagedNe(row: ManagedNeItem): ManagedNeFormState { + const hopVendor = (["linux", "huawei", "cisco", "zte", "bastion"].includes(row.hop_vendor) + ? row.hop_vendor + : "zte") as HopVendor; + return { + name: row.name, + vendor: row.vendor, + device_type: row.device_type, + ip_address: row.ip_address, + port: row.port, + protocol: row.protocol, + username: row.username, + password: "", + tags: row.tags, + remark: row.remark, + hop_enabled: row.hop_enabled, + hop_vendor: hopVendor, + hop_host: row.hop_host, + hop_port: row.hop_port, + hop_protocol: row.hop_protocol, + hop_username: row.hop_username, + hop_password: "", + hop_command_template: isAutoHopTemplate( + row.hop_command_template, + row.hop_vendor, + row.hop_protocol, + row.hop_vrf, + ) + ? defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf) + : row.hop_command_template || defaultHopTemplate(row.hop_vendor, row.hop_protocol, row.hop_vrf), + hop_vrf: row.hop_vrf, + hop_target_auth_mode: row.hop_target_auth_mode === "manual" ? "manual" : "bastion_managed", + hop_enter_system_view: Boolean(row.hop_enter_system_view), + }; +} + +export function managedSourceKey( + source: string | undefined, +): "manual" | "ume_sync" | "webcrt" | "lldp" | "topology" | "" { + const s = String(source || "").trim().toLowerCase(); + if (!s) return "manual"; + if (s === "ume_sync" || s === "webcrt" || s === "lldp" || s === "topology") return s; + return ""; +} + +/** Build API body + hop validation. Throws Error with message key text already translated by caller. */ +export function buildManagedNeSaveBody( + form: ManagedNeFormState, + opts: { editing: boolean; hopHostRequired: string; hopUserRequired: string; hopPasswordRequired: string }, +): Record { + const body: Record = { + name: form.name, + vendor: form.vendor, + device_type: form.device_type, + ip_address: form.ip_address, + port: form.port, + protocol: form.protocol, + username: form.username, + tags: form.tags, + remark: form.remark, + hop_enabled: form.hop_enabled, + hop_vendor: form.hop_vendor, + hop_host: form.hop_host, + hop_port: form.hop_port, + hop_protocol: form.hop_protocol, + hop_username: form.hop_username, + hop_command_template: form.hop_command_template, + hop_vrf: form.hop_vrf, + hop_target_auth_mode: form.hop_target_auth_mode, + hop_enter_system_view: form.hop_enter_system_view, + ...(form.password ? { password: form.password } : {}), + ...(form.hop_password ? { hop_password: form.hop_password } : {}), + }; + if (form.hop_enabled) { + if (!form.hop_host.trim()) throw new Error(opts.hopHostRequired); + if (!form.hop_username.trim()) throw new Error(opts.hopUserRequired); + if (!opts.editing && !form.hop_password) throw new Error(opts.hopPasswordRequired); + } + if (opts.editing) { + if (!form.password) delete body.password; + if (!form.hop_password) delete body.hop_password; + } else { + body.password = form.password || ""; + } + return body; +} diff --git a/web/src/pages/topology/TopologyCtxMenu.tsx b/web/src/pages/topology/TopologyCtxMenu.tsx index ea824a8..bf4a505 100644 --- a/web/src/pages/topology/TopologyCtxMenu.tsx +++ b/web/src/pages/topology/TopologyCtxMenu.tsx @@ -33,12 +33,18 @@ export type TopologyCtxMenuProps = { onClose: () => void; onToggleFullscreen: () => void; onRemoveSelected: () => void; + onDiscoverSelected: () => void; + onConnectTestSelected: () => void; + selectedDiscoverableCount: number; + selectedConnectableCount: number; onOpenCreateNe: (flowX: number, flowY: number) => void; onPromptNewSubRegion: () => void; onRenameSelectedNode: () => void; onDiscoverOne: (node: Node | null) => void; onOpenWebcrt: (node: Node | null) => void; onOpenNe: (node: Node | null) => void; + onConnectTest: (node: Node | null) => void; + connectTestBusy?: boolean; onPurgePlaceholder: (id: string) => void; onRemoveNode: (id: string) => void; onExpandPhysicalLinks: () => void; @@ -70,12 +76,18 @@ export function TopologyCtxMenu({ onClose, onToggleFullscreen, onRemoveSelected, + onDiscoverSelected, + onConnectTestSelected, + selectedDiscoverableCount, + selectedConnectableCount, onOpenCreateNe, onPromptNewSubRegion, onRenameSelectedNode, onDiscoverOne, onOpenWebcrt, onOpenNe, + onConnectTest, + connectTestBusy = false, onPurgePlaceholder, onRemoveNode, onExpandPhysicalLinks, @@ -98,7 +110,47 @@ export function TopologyCtxMenu({ <>
  • {t("topology.selectionMenu")} + · {selectedNodeCount}
  • +
  • + +
  • +
  • + +
  • +
  • +
  • + +
  • {selectedNode && isPlaceholderSource(selectedNode.data.managed_source, selectedNode.data.ne_ip) ? ( diff --git a/web/src/pages/topology/TopologyModals.tsx b/web/src/pages/topology/TopologyModals.tsx index 17c62c1..bdbc422 100644 --- a/web/src/pages/topology/TopologyModals.tsx +++ b/web/src/pages/topology/TopologyModals.tsx @@ -1,14 +1,20 @@ import type { DragEvent } from "react"; import type { TopologyOutsidePeer } from "../../types"; -import { useI18n } from "../../i18n"; import type { PaletteItem, PaletteSource } from "./pageTypes"; +import { AddNePaletteDialog } from "./modals/AddNePaletteDialog"; +import { CreateNeModeDialog } from "./modals/CreateNeModeDialog"; +import { NewRootDialog } from "./modals/NewRootDialog"; +import { + PlaceholderCreateDialog, + type PlaceholderCreateDialogState, +} from "./modals/PlaceholderCreateDialog"; +import { OutsidePeersDialog } from "./modals/OutsidePeersDialog"; +import { ManagedNeFormDialog } from "../managedNe/ManagedNeFormDialog"; +import { ManagedNeConnectDetailDialog } from "../managedNe/ManagedNeConnectDetailDialog"; +import type { ManagedNeItem } from "../../types"; +import type { ManagedNeFormState } from "../managedNe/formState"; -export type CreateNeDialogState = { - flowX: number; - flowY: number; - name: string; - ip_address: string; -}; +export type { PlaceholderCreateDialogState as CreateNeDialogState }; export type TopologyModalsProps = { canvasMode: boolean; @@ -17,11 +23,27 @@ export type TopologyModalsProps = { onCloseNewRoot: () => void; onSubmitNewRoot: () => void; createRegionPending: boolean; - createNeDialog: CreateNeDialogState | null; - createNeBusy: boolean; - onCreateNeChange: (patch: Partial>) => void; - onCloseCreateNe: () => void; - onSubmitCreateNe: () => void; + + createNeModeOpen: boolean; + onCloseCreateNeMode: () => void; + onPickCreateManaged: () => void; + onPickCreatePlaceholder: () => void; + + placeholderDialog: PlaceholderCreateDialogState | null; + placeholderBusy: boolean; + onPlaceholderChange: (patch: Partial>) => void; + onClosePlaceholder: () => void; + onSubmitPlaceholder: () => void; + + managedFormOpen: boolean; + managedFormInitial?: Partial; + onCloseManagedForm: () => void; + onManagedFormSaved: (item: ManagedNeItem) => void; + + connectDetailRow: ManagedNeItem | null; + onCloseConnectDetail: () => void; + onConnectRetestSubmitted: (rowId: string) => void; + outsidePeersOpen: boolean; outsidePeers: TopologyOutsidePeer[]; outsidePeersVisible: TopologyOutsidePeer[]; @@ -33,6 +55,7 @@ export type TopologyModalsProps = { outsidePeersAdding: boolean; onCloseOutsidePeers: () => void; onAddOutsidePeers: (ids: string[]) => void; + addNeOpen: boolean; paletteSource: PaletteSource; onPaletteSourceChange: (source: PaletteSource) => void; @@ -48,6 +71,7 @@ export type TopologyModalsProps = { onAddSelectedPalette: () => void; }; +/** Thin composer — keep individual dialogs in `modals/` / `managedNe/`. */ export function TopologyModals({ canvasMode, newRootDialog, @@ -55,11 +79,22 @@ export function TopologyModals({ onCloseNewRoot, onSubmitNewRoot, createRegionPending, - createNeDialog, - createNeBusy, - onCreateNeChange, - onCloseCreateNe, - onSubmitCreateNe, + createNeModeOpen, + onCloseCreateNeMode, + onPickCreateManaged, + onPickCreatePlaceholder, + placeholderDialog, + placeholderBusy, + onPlaceholderChange, + onClosePlaceholder, + onSubmitPlaceholder, + managedFormOpen, + managedFormInitial, + onCloseManagedForm, + onManagedFormSaved, + connectDetailRow, + onCloseConnectDetail, + onConnectRetestSubmitted, outsidePeersOpen, outsidePeers, outsidePeersVisible, @@ -85,460 +120,72 @@ export function TopologyModals({ onPaletteDragStart, onAddSelectedPalette, }: TopologyModalsProps) { - const { t } = useI18n(); - return ( <> - {newRootDialog ? ( -
    -
    -
    -
    - {t("topology.newRegion")} - -
    -

    {t("topology.folderHint")}

    -
    - -
    -
    - - -
    -
    -
    - ) : null} + - {createNeDialog && canvasMode ? ( -
    -
    { - if (createNeBusy) return; - onCloseCreateNe(); - }} + {canvasMode ? ( + <> + -
    -
    - {t("topology.createNeTitle")} - -
    -

    {t("topology.createNeHint")}

    -
    - - -
    -
    - - -
    -
    -
    - ) : null} - - {outsidePeersOpen && canvasMode ? ( -
    -
    { - if (outsidePeersAdding) return; - onCloseOutsidePeers(); - }} + -
    -
    - - {t("topology.outsidePeersTitle")} - · {outsidePeers.length} - - -
    -

    {t("topology.outsidePeersHint")}

    - onOutsidePeerQueryChange(e.target.value)} - placeholder={t("topology.outsidePeersFilterPh")} - disabled={outsidePeersAdding} - autoFocus - /> - {outsidePeersVisible.length > 0 ? ( -
    - - - {t("topology.selectedCount").replace( - "{{count}}", - String(outsidePeerSelectedIds.length), - )} - -
    - ) : null} -
      - {outsidePeersVisible.length === 0 ? ( -
    • - {t("topology.outsidePeersEmpty")} -
    • - ) : ( - outsidePeersVisible.map((peer) => { - const checked = outsidePeerSelectedIds.includes(peer.fabric_node_id); - const viaName = - outsidePeerNameById.get(peer.via_node_id) || peer.via_node_id.slice(0, 8); - const title = peer.name || peer.ip || peer.fabric_node_id.slice(0, 8); - return ( -
    • -
      - - -
      -
    • - ); - }) - )} -
    -
    - - -
    -
    -
    - ) : null} - - {addNeOpen && canvasMode ? ( -
    -
    { - if (paletteAdding) return; - onCloseAddNe(); - }} + -
    -
    - {t("topology.addNe")} - -
    -

    {t("topology.paletteHint")}

    -
    - - -
    - onKeywordChange(e.target.value)} - placeholder={t("topology.filterPh")} - disabled={paletteAdding} - autoFocus - /> - {paletteVisible.length > 0 ? ( -
    - - - {t("topology.selectedCount").replace( - "{{count}}", - String(paletteSelectedKeys.length), - )} - -
    - ) : null} -
      - {paletteLoading ? ( -
    • - {t("topology.paletteLoading")} -
    • - ) : paletteVisible.length === 0 ? ( -
    • - {t("topology.paletteEmpty")} -
    • - ) : ( - paletteVisible.map((item) => { - const checked = paletteSelectedKeys.includes(item.key); - return ( -
    • -
      - - -
      -
    • - ); - }) - )} -
    -
    - - -
    -
    -
    + + + + ) : null} ); diff --git a/web/src/pages/topology/hooks/useTopologyConnectTest.ts b/web/src/pages/topology/hooks/useTopologyConnectTest.ts new file mode 100644 index 0000000..8e04f34 --- /dev/null +++ b/web/src/pages/topology/hooks/useTopologyConnectTest.ts @@ -0,0 +1,111 @@ +import { useCallback, useEffect, useState } from "react"; +import type { Node } from "@xyflow/react"; +import { connectTestManagedNe, fetchManagedNeById } from "../../../services/api"; +import { useI18n } from "../../../i18n"; +import { useToast } from "../../../hooks/useToast"; +import type { ManagedNeItem } from "../../../types"; +import type { NeNodeData } from "../TopologyReactFlowView"; + +type Args = { + closeCtxMenu: () => void; +}; + +export function useTopologyConnectTest({ closeCtxMenu }: Args) { + const { t } = useI18n(); + const { showOk, showError } = useToast(); + const [detailRow, setDetailRow] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const refreshRow = useCallback(async (id: string) => { + const row = await fetchManagedNeById(id); + setDetailRow(row); + return row; + }, []); + + useEffect(() => { + if (!detailRow || detailRow.connect_status !== "testing") return; + const id = detailRow.id; + const timer = window.setInterval(() => { + void refreshRow(id).catch(() => { + /* ignore transient poll errors */ + }); + }, 2000); + return () => window.clearInterval(timer); + }, [detailRow?.id, detailRow?.connect_status, refreshRow]); + + const runForNode = useCallback( + async (node: Node | null) => { + closeCtxMenu(); + const managedId = String(node?.data.managed_ne_id || "").trim(); + if (!managedId) { + showError(t("topology.connectTestNeedManaged")); + return; + } + setSubmitting(true); + try { + const res = await connectTestManagedNe([managedId]); + showOk(t("managedNe.connect.submitted", { n: res.submitted })); + await refreshRow(managedId); + } catch (err) { + showError(String(err)); + } finally { + setSubmitting(false); + } + }, + [closeCtxMenu, refreshRow, showError, showOk, t], + ); + + /** Batch: submit connect tests; open detail only when exactly one managed NE. */ + const runForNodes = useCallback( + async (nodes: Node[]) => { + closeCtxMenu(); + const ids = [ + ...new Set( + nodes + .map((n) => String(n.data.managed_ne_id || "").trim()) + .filter(Boolean), + ), + ]; + if (!ids.length) { + showError(t("topology.connectTestNeedManaged")); + return; + } + const skipped = nodes.length - ids.length; + setSubmitting(true); + try { + const res = await connectTestManagedNe(ids); + const msg = t("managedNe.connect.submitted", { n: res.submitted }); + showOk( + skipped > 0 + ? `${msg} · ${t("topology.connectTestSkipped").replace("{{count}}", String(skipped))}` + : msg, + ); + if (ids.length === 1) { + await refreshRow(ids[0]); + } + } catch (err) { + showError(String(err)); + } finally { + setSubmitting(false); + } + }, + [closeCtxMenu, refreshRow, showError, showOk, t], + ); + + const onRetestSubmitted = useCallback( + (rowId: string) => { + showOk(t("managedNe.connect.submitted", { n: 1 })); + void refreshRow(rowId); + }, + [refreshRow, showOk, t], + ); + + return { + connectDetailRow: detailRow, + connectTestSubmitting: submitting, + closeConnectDetail: () => setDetailRow(null), + runConnectTestForNode: runForNode, + runConnectTestForNodes: runForNodes, + onConnectRetestSubmitted: onRetestSubmitted, + }; +} diff --git a/web/src/pages/topology/hooks/useTopologyCreateNe.ts b/web/src/pages/topology/hooks/useTopologyCreateNe.ts new file mode 100644 index 0000000..5f485a9 --- /dev/null +++ b/web/src/pages/topology/hooks/useTopologyCreateNe.ts @@ -0,0 +1,225 @@ +import { + useCallback, + useRef, + useState, + type Dispatch, + type MutableRefObject, + type SetStateAction, +} from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import type { Edge, Node } from "@xyflow/react"; +import { + addTopologyViewNodes, + createTopologyPlaceholder, + fetchTopologyGraph, + patchTopologyPositions, +} from "../../../services/api"; +import { queryKeys } from "../../../constants/queryKeys"; +import { useI18n } from "../../../i18n"; +import { useToast } from "../../../hooks/useToast"; +import type { ManagedNeItem } from "../../../types"; +import { applyViewGraph } from "../graphFlow"; +import type { EdgeDefaults } from "../edgeStyle"; +import type { PlaceholderCreateDialogState } from "../modals/PlaceholderCreateDialog"; +import type { ManagedNeFormState } from "../../managedNe/formState"; +import { isWorldFlatViewName } from "../treeUtils"; +import type { NeNodeData } from "../TopologyReactFlowView"; + +type FocusNode = (fabricNodeId: string, fit?: boolean) => void; + +type Args = { + mapId: string | null | undefined; + activeViewName: string | undefined; + canWrite: boolean; + edgeDefaults: EdgeDefaults; + setNodes: Dispatch[]>>; + setEdges: Dispatch>; + clearDirty: () => void; + historyLockRef: MutableRefObject; + focusNode: FocusNode; + closeCtxMenu: () => void; +}; + +export function useTopologyCreateNe({ + mapId, + activeViewName, + canWrite, + edgeDefaults, + setNodes, + setEdges, + clearDirty, + historyLockRef, + focusNode, + closeCtxMenu, +}: Args) { + const { t } = useI18n(); + const { showOk, showError } = useToast(); + const queryClient = useQueryClient(); + + const [modeOpen, setModeOpen] = useState(false); + const [placeAt, setPlaceAt] = useState<{ flowX: number; flowY: number } | null>(null); + const [placeholderDialog, setPlaceholderDialog] = useState( + null, + ); + const [placeholderBusy, setPlaceholderBusy] = useState(false); + const [managedFormOpen, setManagedFormOpen] = useState(false); + const [managedFormInitial, setManagedFormInitial] = useState | undefined>(); + const [managedBusy, setManagedBusy] = useState(false); + const placeAtRef = useRef(placeAt); + placeAtRef.current = placeAt; + + const openCreateNeAt = useCallback( + (flowX: number, flowY: number) => { + if (!canWrite) { + showError(t("topology.readOnlyHint")); + return; + } + if (isWorldFlatViewName(activeViewName)) { + showError(t("topology.worldMapNoDirectNes")); + return; + } + closeCtxMenu(); + setPlaceAt({ flowX, flowY }); + setModeOpen(true); + }, + [canWrite, activeViewName, closeCtxMenu, showError, t], + ); + + const closeMode = useCallback(() => setModeOpen(false), []); + + const pickManaged = useCallback(() => { + setModeOpen(false); + setManagedFormInitial(undefined); + setManagedFormOpen(true); + }, []); + + const pickPlaceholder = useCallback(() => { + const at = placeAtRef.current; + if (!at) return; + setModeOpen(false); + setPlaceholderDialog({ ...at, name: "", ip_address: "" }); + }, []); + + const closePlaceholder = useCallback(() => setPlaceholderDialog(null), []); + + const closeManagedForm = useCallback(() => { + if (managedBusy) return; + setManagedFormOpen(false); + }, [managedBusy]); + + const applyGraph = useCallback( + (graph: Awaited>) => { + if (!mapId) return; + queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph); + historyLockRef.current = true; + applyViewGraph(graph, edgeDefaults, setNodes, setEdges); + historyLockRef.current = false; + clearDirty(); + }, + [mapId, queryClient, edgeDefaults, setNodes, setEdges, clearDirty, historyLockRef], + ); + + const submitPlaceholder = useCallback(async () => { + if (!mapId || !placeholderDialog) return; + const name = placeholderDialog.name.trim(); + if (!name) { + showError(t("topology.createNeNameRequired")); + return; + } + setPlaceholderBusy(true); + try { + const graph = await createTopologyPlaceholder(mapId, { + name, + ip_address: placeholderDialog.ip_address.trim(), + x: placeholderDialog.flowX, + y: placeholderDialog.flowY, + }); + applyGraph(graph); + const created = graph.nodes.find( + (n) => + String(n.name || "").trim() === name && + Math.abs(Number(n.x) - placeholderDialog.flowX) < 0.5 && + Math.abs(Number(n.y) - placeholderDialog.flowY) < 0.5, + ); + if (created?.fabric_node_id) { + focusNode(created.fabric_node_id, false); + } + setPlaceholderDialog(null); + showOk(t("topology.createNeDone").replace("{{name}}", name)); + void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll }); + void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); + } catch (err) { + showError(String(err)); + } finally { + setPlaceholderBusy(false); + } + }, [ + mapId, + placeholderDialog, + applyGraph, + focusNode, + queryClient, + showError, + showOk, + t, + ]); + + const onManagedFormSaved = useCallback( + async (item: ManagedNeItem) => { + setManagedFormOpen(false); + if (!mapId) { + showOk(t("managedNe.form.created")); + return; + } + const at = placeAtRef.current || { flowX: 80, flowY: 80 }; + setManagedBusy(true); + try { + const graph = await addTopologyViewNodes(mapId, { + managed_ne_ids: [item.id], + layout: "grid", + }); + const added = graph.nodes.find((n) => n.managed_ne_id === item.id); + if (added?.fabric_node_id) { + await patchTopologyPositions(mapId, [ + { + fabric_node_id: added.fabric_node_id, + x: at.flowX, + y: at.flowY, + label: added.label || added.name || item.name || "", + }, + ]); + } + const refreshed = await fetchTopologyGraph(mapId); + applyGraph(refreshed); + if (added?.fabric_node_id) { + focusNode(added.fabric_node_id, false); + } + showOk(t("topology.createManagedNeDone").replace("{{name}}", item.name || item.ip_address)); + void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll }); + void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] }); + } catch (err) { + showError(String(err)); + } finally { + setManagedBusy(false); + } + }, + [mapId, applyGraph, focusNode, queryClient, showError, showOk, t], + ); + + return { + modeOpen, + openCreateNeAt, + closeMode, + pickManaged, + pickPlaceholder, + placeholderDialog, + setPlaceholderDialog, + placeholderBusy, + closePlaceholder, + submitPlaceholder, + managedFormOpen, + managedFormInitial, + closeManagedForm, + onManagedFormSaved, + }; +} diff --git a/web/src/pages/topology/modals/AddNePaletteDialog.tsx b/web/src/pages/topology/modals/AddNePaletteDialog.tsx new file mode 100644 index 0000000..aba3a2c --- /dev/null +++ b/web/src/pages/topology/modals/AddNePaletteDialog.tsx @@ -0,0 +1,194 @@ +import type { DragEvent } from "react"; +import { useI18n } from "../../../i18n"; +import type { PaletteItem, PaletteSource } from "../pageTypes"; + +export type AddNePaletteDialogProps = { + open: boolean; + paletteSource: PaletteSource; + onPaletteSourceChange: (source: PaletteSource) => void; + keyword: string; + onKeywordChange: (keyword: string) => void; + paletteVisible: PaletteItem[]; + paletteSelectedKeys: string[]; + onPaletteSelectedKeysChange: (keys: string[] | ((prev: string[]) => string[])) => void; + paletteLoading: boolean; + paletteAdding: boolean; + onClose: () => void; + onPaletteDragStart: (e: DragEvent, item: PaletteItem) => void; + onAddSelected: () => void; +}; + +export function AddNePaletteDialog({ + open, + paletteSource, + onPaletteSourceChange, + keyword, + onKeywordChange, + paletteVisible, + paletteSelectedKeys, + onPaletteSelectedKeysChange, + paletteLoading, + paletteAdding, + onClose, + onPaletteDragStart, + onAddSelected, +}: AddNePaletteDialogProps) { + const { t } = useI18n(); + if (!open) return null; + + return ( +
    +
    { + if (paletteAdding) return; + onClose(); + }} + /> +
    +
    + {t("topology.addNe")} + +
    +

    {t("topology.paletteHint")}

    +
    + + +
    + onKeywordChange(e.target.value)} + placeholder={t("topology.filterPh")} + disabled={paletteAdding} + autoFocus + /> + {paletteVisible.length > 0 ? ( +
    + + + {t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))} + +
    + ) : null} +
      + {paletteLoading ? ( +
    • + {t("topology.paletteLoading")} +
    • + ) : paletteVisible.length === 0 ? ( +
    • + {t("topology.paletteEmpty")} +
    • + ) : ( + paletteVisible.map((item) => { + const checked = paletteSelectedKeys.includes(item.key); + return ( +
    • +
      + + +
      +
    • + ); + }) + )} +
    +
    + + +
    +
    +
    + ); +} diff --git a/web/src/pages/topology/modals/CreateNeModeDialog.tsx b/web/src/pages/topology/modals/CreateNeModeDialog.tsx new file mode 100644 index 0000000..45697b3 --- /dev/null +++ b/web/src/pages/topology/modals/CreateNeModeDialog.tsx @@ -0,0 +1,48 @@ +import { useI18n } from "../../../i18n"; + +export type CreateNeModeDialogProps = { + open: boolean; + onClose: () => void; + onPickManaged: () => void; + onPickPlaceholder: () => void; +}; + +/** Choose formal Managed NE vs topology placeholder before opening the create flow. */ +export function CreateNeModeDialog({ + open, + onClose, + onPickManaged, + onPickPlaceholder, +}: CreateNeModeDialogProps) { + const { t } = useI18n(); + if (!open) return null; + + return ( +
    +
    +
    +
    + {t("topology.createNe")} + +
    +

    {t("topology.createNeModeHint")}

    +
    + + +
    +
    +
    + ); +} diff --git a/web/src/pages/topology/modals/NewRootDialog.tsx b/web/src/pages/topology/modals/NewRootDialog.tsx new file mode 100644 index 0000000..fc4a7be --- /dev/null +++ b/web/src/pages/topology/modals/NewRootDialog.tsx @@ -0,0 +1,66 @@ +import { useI18n } from "../../../i18n"; + +export type NewRootDialogProps = { + dialog: { name: string } | null; + pending: boolean; + onNameChange: (name: string) => void; + onClose: () => void; + onSubmit: () => void; +}; + +export function NewRootDialog({ dialog, pending, onNameChange, onClose, onSubmit }: NewRootDialogProps) { + const { t } = useI18n(); + if (!dialog) return null; + + return ( +
    +
    +
    +
    + {t("topology.newRegion")} + +
    +

    {t("topology.folderHint")}

    +
    + +
    +
    + + +
    +
    +
    + ); +} diff --git a/web/src/pages/topology/modals/OutsidePeersDialog.tsx b/web/src/pages/topology/modals/OutsidePeersDialog.tsx new file mode 100644 index 0000000..bb02e9a --- /dev/null +++ b/web/src/pages/topology/modals/OutsidePeersDialog.tsx @@ -0,0 +1,186 @@ +import type { TopologyOutsidePeer } from "../../../types"; +import { useI18n } from "../../../i18n"; + +export type OutsidePeersDialogProps = { + open: boolean; + outsidePeers: TopologyOutsidePeer[]; + outsidePeersVisible: TopologyOutsidePeer[]; + outsidePeerQuery: string; + onOutsidePeerQueryChange: (q: string) => void; + outsidePeerSelectedIds: string[]; + onOutsidePeerSelectedIdsChange: (ids: string[] | ((prev: string[]) => string[])) => void; + outsidePeerNameById: Map; + outsidePeersAdding: boolean; + onClose: () => void; + onAddOutsidePeers: (ids: string[]) => void; +}; + +export function OutsidePeersDialog({ + open, + outsidePeers, + outsidePeersVisible, + outsidePeerQuery, + onOutsidePeerQueryChange, + outsidePeerSelectedIds, + onOutsidePeerSelectedIdsChange, + outsidePeerNameById, + outsidePeersAdding, + onClose, + onAddOutsidePeers, +}: OutsidePeersDialogProps) { + const { t } = useI18n(); + if (!open) return null; + + return ( +
    +
    { + if (outsidePeersAdding) return; + onClose(); + }} + /> +
    +
    + + {t("topology.outsidePeersTitle")} + · {outsidePeers.length} + + +
    +

    {t("topology.outsidePeersHint")}

    + onOutsidePeerQueryChange(e.target.value)} + placeholder={t("topology.outsidePeersFilterPh")} + disabled={outsidePeersAdding} + autoFocus + /> + {outsidePeersVisible.length > 0 ? ( +
    + + + {t("topology.selectedCount").replace( + "{{count}}", + String(outsidePeerSelectedIds.length), + )} + +
    + ) : null} +
      + {outsidePeersVisible.length === 0 ? ( +
    • + {t("topology.outsidePeersEmpty")} +
    • + ) : ( + outsidePeersVisible.map((peer) => { + const checked = outsidePeerSelectedIds.includes(peer.fabric_node_id); + const viaName = + outsidePeerNameById.get(peer.via_node_id) || peer.via_node_id.slice(0, 8); + const title = peer.name || peer.ip || peer.fabric_node_id.slice(0, 8); + return ( +
    • +
      + + +
      +
    • + ); + }) + )} +
    +
    + + +
    +
    +
    + ); +} diff --git a/web/src/pages/topology/modals/PlaceholderCreateDialog.tsx b/web/src/pages/topology/modals/PlaceholderCreateDialog.tsx new file mode 100644 index 0000000..d735dd1 --- /dev/null +++ b/web/src/pages/topology/modals/PlaceholderCreateDialog.tsx @@ -0,0 +1,104 @@ +import { useI18n } from "../../../i18n"; + +export type PlaceholderCreateDialogState = { + flowX: number; + flowY: number; + name: string; + ip_address: string; +}; + +export type PlaceholderCreateDialogProps = { + dialog: PlaceholderCreateDialogState | null; + busy: boolean; + onChange: (patch: Partial>) => void; + onClose: () => void; + onSubmit: () => void; +}; + +export function PlaceholderCreateDialog({ + dialog, + busy, + onChange, + onClose, + onSubmit, +}: PlaceholderCreateDialogProps) { + const { t } = useI18n(); + if (!dialog) return null; + + return ( +
    +
    { + if (busy) return; + onClose(); + }} + /> +
    +
    + {t("topology.createNeTitle")} + +
    +

    {t("topology.createNeHint")}

    +
    + + +
    +
    + + +
    +
    +
    + ); +}