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 <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-01 20:37:42 +08:00
parent f8d4a396f1
commit 56569e49d1
19 changed files with 1980 additions and 931 deletions

View file

@ -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("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(tb)
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()

View file

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

View file

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

View file

@ -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: "颜色",

View file

@ -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<FormState> {
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 (
<span className="form-label">
@ -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<ManagedNeItem | null>(null);
const [form, setForm] = useState<FormState>(emptyForm);
const [formSeed, setFormSeed] = useState<Partial<ManagedNeFormState> | undefined>();
const [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields);
const [batchAccount, setBatchAccount] = useState<AccountState>(emptyAccount);
const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(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() {
</div>
</section>
{modalOpen ? (
<div className="modal-backdrop" role="presentation" onClick={() => setModalOpen(false)}>
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3>{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}</h3>
<p className="form-hint">{t("managedNe.form.requiredHint")}</p>
<div className="form-grid">
<label>
<FormLabel>{t("managedNe.col.name")}</FormLabel>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<span className="form-field-hint">{t("managedNe.form.nameConnectHint")}</span>
</label>
<label>
<FormLabel required>{t("managedNe.col.vendor")}</FormLabel>
<select required value={form.vendor} onChange={(e) => {
const vendor = e.target.value;
setForm((prev) => {
const next = { ...prev, vendor };
const dt = String(prev.device_type || "").trim().toLowerCase();
// LLDP/WebCRT placeholders use generic — pick a sensible driver when vendor is set.
if (!dt || dt === "generic" || dt === "other" || dt === "linux") {
if (vendor === "ZTE") next.device_type = "zte_zxros";
else if (vendor === "Huawei") next.device_type = "huawei";
else if (vendor === "Cisco") next.device_type = "cisco_ios";
else if (vendor === "Juniper") next.device_type = "juniper_junos";
else if (vendor === "Nokia") next.device_type = "nokia_sros";
}
return next;
});
}}>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.deviceType")}</FormLabel>
<select
required
value={form.device_type}
onChange={(e) => setForm({ ...form, device_type: e.target.value })}
>
{deviceTypes.map((dt) => (
<option key={dt} value={dt}>
{dt}
</option>
))}
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.ip")}</FormLabel>
<input
required
value={form.ip_address}
onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.port")}</FormLabel>
<input
type="number"
value={form.port}
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 22 })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.protocol")}</FormLabel>
<select value={form.protocol} onChange={(e) => setForm({ ...form, protocol: e.target.value })}>
<option value="ssh">ssh</option>
<option value="telnet">telnet</option>
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.user")}</FormLabel>
<input
required
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</label>
<label>
<FormLabel>
{t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
</FormLabel>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.tags")}</FormLabel>
<input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
</label>
<label className="form-grid__full">
<FormLabel>{t("managedNe.col.remark")}</FormLabel>
<input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} />
</label>
</div>
<fieldset className="form-fieldset form-grid__full">
<legend>{t("managedNe.hop.sectionTitle")}</legend>
<label className="form-check">
<input
type="checkbox"
checked={form.hop_enabled}
onChange={(e) => {
const hop_enabled = e.target.checked;
setForm((prev) => ({
...prev,
hop_enabled,
...(hop_enabled
? {
...patchHopVendorChange(prev.hop_vendor, prev),
...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true),
}
: {}),
}));
<ManagedNeFormDialog
open={modalOpen}
editing={editing}
initialValues={formSeed}
onClose={() => {
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();
}}
/>
<span className="form-check__text">{t("managedNe.hop.enable")}</span>
</label>
{form.hop_enabled ? (
<HopProxyFields
value={{
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_password: form.hop_password,
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,
}}
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
hopPasswordRequired={!editing}
hopPasswordOptional={Boolean(editing)}
/>
) : null}
</fieldset>
<div className="modal__actions">
<button type="button" onClick={() => setModalOpen(false)}>
{t("managedNe.form.cancel")}
</button>
<button type="button" disabled={saveMutation.isPending} onClick={() => saveMutation.mutate()}>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</button>
</div>
</div>
</div>
) : null}
{batchHopOpen ? (
<div className="modal-backdrop" role="presentation" onClick={() => setBatchHopOpen(false)}>
@ -1335,47 +1025,13 @@ export function NePage() {
</div>
) : null}
{connectDetailRow ? (
<div className="modal-backdrop" role="presentation" onClick={() => setConnectDetailRow(null)}>
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3>{t("managedNe.connectDetailTitle")}</h3>
<p className="form-hint">
{connectDetailRow.name || connectDetailRow.ip_address} · {connectDetailRow.ip_address}:
{connectDetailRow.port}/{connectDetailRow.protocol}
{connectDetailRow.connect_tested_at
? ` · ${formatSystemTime(connectDetailRow.connect_tested_at, { assumeUtcNaive: true })}`
: ""}
</p>
<p>
<span className={`conn-pill conn-pill--${connectPillLevel(connectDetailRow.connect_status)}`}>
{connectDetailRow.connect_status}
</span>
{connectDetailRow.connect_message ? (
<span className="connect-detail-summary"> — {connectDetailRow.connect_message}</span>
) : null}
</p>
<pre className="connect-log">
{connectDetailRow.connect_detail?.trim() ||
connectDetailRow.connect_message?.trim() ||
t("managedNe.connectDetailEmpty")}
</pre>
<div className="modal__actions">
<button
type="button"
disabled={connectMutation.isPending}
onClick={() => {
connectMutation.mutate([connectDetailRow.id]);
<ManagedNeConnectDetailDialog
row={connectDetailRow}
onClose={() => setConnectDetailRow(null)}
onRetestSubmitted={() => {
void invalidateList();
}}
>
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
</button>
<button type="button" onClick={() => setConnectDetailRow(null)}>
{t("managedNe.form.cancel")}
</button>
</div>
</div>
</div>
) : null}
/>
</div>
);
}

View file

@ -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<PaletteSource>("managed");
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
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)}

View file

@ -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 (
<div className="modal-backdrop" role="presentation" onClick={onClose}>
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3>{t("managedNe.connectDetailTitle")}</h3>
<p className="form-hint">
{row.name || row.ip_address} · {row.ip_address}:{row.port}/{row.protocol}
{row.connect_tested_at
? ` · ${formatSystemTime(row.connect_tested_at, { assumeUtcNaive: true })}`
: ""}
</p>
<p>
<span className={`conn-pill conn-pill--${connectPillLevel(row.connect_status)}`}>
{row.connect_status}
</span>
{row.connect_message ? (
<span className="connect-detail-summary"> — {row.connect_message}</span>
) : null}
</p>
<pre className="connect-log">
{row.connect_detail?.trim() || row.connect_message?.trim() || t("managedNe.connectDetailEmpty")}
</pre>
<div className="modal__actions">
<button
type="button"
disabled={connectMutation.isPending}
onClick={() => connectMutation.mutate([row.id])}
>
{connectMutation.isPending ? t("managedNe.connect.running") : t("managedNe.connect.retest")}
</button>
<button type="button" onClick={onClose}>
{t("managedNe.form.cancel")}
</button>
</div>
{connectMutation.isError ? (
<p className="form-hint" role="alert">
{String(connectMutation.error)}
</p>
) : null}
</div>
</div>
);
}

View file

@ -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 (
<span className="form-label">
{children}
{required ? (
<span className="form-label__required" title="required" aria-hidden="true">
{" "}
*
</span>
) : null}
</span>
);
}
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<ManagedNeFormState>;
onClose: () => void;
onSaved: (item: ManagedNeItem) => void;
};
export function ManagedNeFormDialog({
open,
editing,
initialValues,
onClose,
onSaved,
}: ManagedNeFormDialogProps) {
const { t } = useI18n();
const [form, setForm] = useState<ManagedNeFormState>(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 (
<div
className="modal-backdrop"
role="presentation"
onClick={() => {
if (saveMutation.isPending) return;
onClose();
}}
>
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
<h3>{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}</h3>
<p className="form-hint">{t("managedNe.form.requiredHint")}</p>
<div className="form-grid">
<label>
<FormLabel>{t("managedNe.col.name")}</FormLabel>
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
<span className="form-field-hint">{t("managedNe.form.nameConnectHint")}</span>
</label>
<label>
<FormLabel required>{t("managedNe.col.vendor")}</FormLabel>
<select
required
value={form.vendor}
onChange={(e) => {
const vendor = e.target.value;
setForm((prev) => {
const next = { ...prev, vendor };
const dt = String(prev.device_type || "").trim().toLowerCase();
if (!dt || dt === "generic" || dt === "other" || dt === "linux") {
if (vendor === "ZTE") next.device_type = "zte_zxros";
else if (vendor === "Huawei") next.device_type = "huawei";
else if (vendor === "Cisco") next.device_type = "cisco_ios";
else if (vendor === "Juniper") next.device_type = "juniper_junos";
else if (vendor === "Nokia") next.device_type = "nokia_sros";
}
return next;
});
}}
>
{vendors.map((v) => (
<option key={v} value={v}>
{v}
</option>
))}
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.deviceType")}</FormLabel>
<select
required
value={form.device_type}
onChange={(e) => setForm({ ...form, device_type: e.target.value })}
>
{deviceTypes.map((dt) => (
<option key={dt} value={dt}>
{dt}
</option>
))}
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.ip")}</FormLabel>
<input
required
value={form.ip_address}
onChange={(e) => setForm({ ...form, ip_address: e.target.value })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.port")}</FormLabel>
<input
type="number"
value={form.port}
onChange={(e) => setForm({ ...form, port: Number(e.target.value) || 22 })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.protocol")}</FormLabel>
<select value={form.protocol} onChange={(e) => setForm({ ...form, protocol: e.target.value })}>
<option value="ssh">ssh</option>
<option value="telnet">telnet</option>
</select>
</label>
<label>
<FormLabel required>{t("managedNe.col.user")}</FormLabel>
<input
required
value={form.username}
onChange={(e) => setForm({ ...form, username: e.target.value })}
/>
</label>
<label>
<FormLabel>
{t("managedNe.col.password")}
<span className="form-label__optional"> ({t("managedNe.form.passwordOptional")})</span>
</FormLabel>
<input
type="password"
value={form.password}
onChange={(e) => setForm({ ...form, password: e.target.value })}
/>
</label>
<label>
<FormLabel>{t("managedNe.col.tags")}</FormLabel>
<input value={form.tags} onChange={(e) => setForm({ ...form, tags: e.target.value })} />
</label>
<label className="form-grid__full">
<FormLabel>{t("managedNe.col.remark")}</FormLabel>
<input value={form.remark} onChange={(e) => setForm({ ...form, remark: e.target.value })} />
</label>
</div>
<fieldset className="form-fieldset form-grid__full">
<legend>{t("managedNe.hop.sectionTitle")}</legend>
<label className="form-check">
<input
type="checkbox"
checked={form.hop_enabled}
onChange={(e) => {
const hop_enabled = e.target.checked;
setForm((prev) => ({
...prev,
hop_enabled,
...(hop_enabled
? {
...patchHopVendorChange(prev.hop_vendor, prev),
...applyHopTemplate(prev, prev.hop_protocol, prev.hop_vrf, true),
}
: {}),
}));
}}
/>
<span className="form-check__text">{t("managedNe.hop.enable")}</span>
</label>
{form.hop_enabled ? (
<HopProxyFields
value={{
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_password: form.hop_password,
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,
}}
onChange={(patch) => setForm((prev) => ({ ...prev, ...patch }))}
hopPasswordRequired={!editing}
hopPasswordOptional={Boolean(editing)}
/>
) : null}
</fieldset>
<div className="modal__actions">
<button type="button" disabled={saveMutation.isPending} onClick={onClose}>
{t("managedNe.form.cancel")}
</button>
<button
type="button"
disabled={saveMutation.isPending}
onClick={() => saveMutation.mutate()}
>
{saveMutation.isPending ? t("managedNe.form.saving") : t("managedNe.form.save")}
</button>
</div>
{saveMutation.isError ? (
<p className="form-hint" role="alert">
{String(saveMutation.error)}
</p>
) : null}
</div>
</div>
);
}

View file

@ -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";
}

View file

@ -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<ManagedNeFormState> {
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<string, unknown> {
const body: Record<string, unknown> = {
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;
}

View file

@ -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<NeNodeData> | null) => void;
onOpenWebcrt: (node: Node<NeNodeData> | null) => void;
onOpenNe: (node: Node<NeNodeData> | null) => void;
onConnectTest: (node: Node<NeNodeData> | 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({
<>
<li className="topo-ctx__head" role="presentation">
{t("topology.selectionMenu")}
<span className="topo-ctx__count"> · {selectedNodeCount}</span>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={discovering || selectedDiscoverableCount === 0}
title={
selectedDiscoverableCount === 0 ? t("topology.discoverOneNeedNe") : undefined
}
onClick={() => onDiscoverSelected()}
>
{t("topology.discoverSelected").replace(
"{{count}}",
String(selectedDiscoverableCount || selectedNodeCount),
)}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={connectTestBusy || selectedConnectableCount === 0}
title={
selectedConnectableCount === 0
? t("topology.connectTestNeedManaged")
: undefined
}
onClick={() => onConnectTestSelected()}
>
{connectTestBusy
? t("managedNe.connect.running")
: t("topology.connectTestSelected").replace(
"{{count}}",
String(selectedConnectableCount || selectedNodeCount),
)}
</button>
</li>
<li className="topo-ctx__sep" aria-hidden />
<li role="none">
<button
type="button"
@ -225,6 +277,24 @@ export function TopologyCtxMenu({
{t("topology.openNe")}
</button>
</li>
<li role="none">
<button
type="button"
className="topo-ctx__item"
role="menuitem"
disabled={
connectTestBusy || !String(selectedNode?.data.managed_ne_id || "").trim()
}
title={
!String(selectedNode?.data.managed_ne_id || "").trim()
? t("topology.connectTestNeedManaged")
: undefined
}
onClick={() => onConnectTest(selectedNode)}
>
{connectTestBusy ? t("managedNe.connect.running") : t("topology.connectTest")}
</button>
</li>
<li className="topo-ctx__sep" aria-hidden />
{selectedNode &&
isPlaceholderSource(selectedNode.data.managed_source, selectedNode.data.ne_ip) ? (

View file

@ -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<Pick<CreateNeDialogState, "name" | "ip_address">>) => void;
onCloseCreateNe: () => void;
onSubmitCreateNe: () => void;
createNeModeOpen: boolean;
onCloseCreateNeMode: () => void;
onPickCreateManaged: () => void;
onPickCreatePlaceholder: () => void;
placeholderDialog: PlaceholderCreateDialogState | null;
placeholderBusy: boolean;
onPlaceholderChange: (patch: Partial<Pick<PlaceholderCreateDialogState, "name" | "ip_address">>) => void;
onClosePlaceholder: () => void;
onSubmitPlaceholder: () => void;
managedFormOpen: boolean;
managedFormInitial?: Partial<ManagedNeFormState>;
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 ? (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-new-root-title"
>
<div className="topo-modal__backdrop" onClick={onCloseNewRoot} />
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
<div className="topo-modal__head">
<strong id="topo-new-root-title">{t("topology.newRegion")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onCloseNewRoot}>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.folderHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">{t("topology.newRegionPrompt")}</span>
<input
autoFocus
value={newRootDialog.name}
disabled={createRegionPending}
onChange={(e) => onNewRootNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmitNewRoot();
}}
<NewRootDialog
dialog={newRootDialog}
pending={createRegionPending}
onNameChange={onNewRootNameChange}
onClose={onCloseNewRoot}
onSubmit={onSubmitNewRoot}
/>
</label>
</div>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={createRegionPending}
onClick={onCloseNewRoot}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={createRegionPending || !String(newRootDialog.name || "").trim()}
onClick={onSubmitNewRoot}
>
{createRegionPending ? "…" : t("topology.newRegion")}
</button>
</div>
</div>
</div>
) : null}
{createNeDialog && canvasMode ? (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-create-ne-title"
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (createNeBusy) return;
onCloseCreateNe();
}}
{canvasMode ? (
<>
<CreateNeModeDialog
open={createNeModeOpen}
onClose={onCloseCreateNeMode}
onPickManaged={onPickCreateManaged}
onPickPlaceholder={onPickCreatePlaceholder}
/>
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
<div className="topo-modal__head">
<strong id="topo-create-ne-title">{t("topology.createNeTitle")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={createNeBusy}
onClick={onCloseCreateNe}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.createNeHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">
{t("topology.createNeName")}
<span className="form-label__required" aria-hidden="true">
{" "}
*
</span>
</span>
<input
autoFocus
value={createNeDialog.name}
placeholder={t("topology.createNeNamePh")}
disabled={createNeBusy}
onChange={(e) => onCreateNeChange({ name: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmitCreateNe();
}}
<PlaceholderCreateDialog
dialog={placeholderDialog}
busy={placeholderBusy}
onChange={onPlaceholderChange}
onClose={onClosePlaceholder}
onSubmit={onSubmitPlaceholder}
/>
</label>
<label>
<span className="form-label">{t("topology.createNeIp")}</span>
<input
value={createNeDialog.ip_address}
placeholder={t("topology.createNeIpPh")}
disabled={createNeBusy}
onChange={(e) => onCreateNeChange({ ip_address: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmitCreateNe();
}}
<ManagedNeFormDialog
open={managedFormOpen}
editing={null}
initialValues={managedFormInitial}
onClose={onCloseManagedForm}
onSaved={onManagedFormSaved}
/>
</label>
</div>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--ghost"
disabled={createNeBusy}
onClick={onCloseCreateNe}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--primary"
disabled={createNeBusy}
onClick={() => void onSubmitCreateNe()}
>
{createNeBusy ? t("topology.createNeBusy") : t("topology.createNe")}
</button>
</div>
</div>
</div>
) : null}
{outsidePeersOpen && canvasMode ? (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-label={t("topology.outsidePeersTitle")}
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (outsidePeersAdding) return;
onCloseOutsidePeers();
}}
<ManagedNeConnectDetailDialog
row={connectDetailRow}
onClose={onCloseConnectDetail}
onRetestSubmitted={onConnectRetestSubmitted}
/>
<div className="topo-modal__panel topo-modal__panel--wide">
<div className="topo-modal__head">
<strong>
{t("topology.outsidePeersTitle")}
<span className="topo-modal__count"> · {outsidePeers.length}</span>
</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onCloseOutsidePeers}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.outsidePeersHint")}</p>
<input
className="input"
value={outsidePeerQuery}
onChange={(e) => onOutsidePeerQueryChange(e.target.value)}
placeholder={t("topology.outsidePeersFilterPh")}
disabled={outsidePeersAdding}
autoFocus
<OutsidePeersDialog
open={outsidePeersOpen}
outsidePeers={outsidePeers}
outsidePeersVisible={outsidePeersVisible}
outsidePeerQuery={outsidePeerQuery}
onOutsidePeerQueryChange={onOutsidePeerQueryChange}
outsidePeerSelectedIds={outsidePeerSelectedIds}
onOutsidePeerSelectedIdsChange={onOutsidePeerSelectedIdsChange}
outsidePeerNameById={outsidePeerNameById}
outsidePeersAdding={outsidePeersAdding}
onClose={onCloseOutsidePeers}
onAddOutsidePeers={onAddOutsidePeers}
/>
{outsidePeersVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
outsidePeersVisible.length > 0 &&
outsidePeersVisible.every((p) =>
outsidePeerSelectedIds.includes(p.fabric_node_id),
)
}
disabled={outsidePeersAdding}
onChange={(e) => {
if (e.target.checked) {
onOutsidePeerSelectedIdsChange((prev) => [
...new Set([
...prev,
...outsidePeersVisible.map((p) => p.fabric_node_id),
]),
]);
return;
}
const drop = new Set(outsidePeersVisible.map((p) => p.fabric_node_id));
onOutsidePeerSelectedIdsChange((prev) => prev.filter((id) => !drop.has(id)));
}}
aria-label={t("topology.selectAllVisible")}
<AddNePaletteDialog
open={addNeOpen}
paletteSource={paletteSource}
onPaletteSourceChange={onPaletteSourceChange}
keyword={keyword}
onKeywordChange={onKeywordChange}
paletteVisible={paletteVisible}
paletteSelectedKeys={paletteSelectedKeys}
onPaletteSelectedKeysChange={onPaletteSelectedKeysChange}
paletteLoading={paletteLoading}
paletteAdding={paletteAdding}
onClose={onCloseAddNe}
onPaletteDragStart={onPaletteDragStart}
onAddSelected={onAddSelectedPalette}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</span>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{outsidePeersVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.outsidePeersEmpty")}</span>
</li>
) : (
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 (
<li key={peer.fabric_node_id}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={outsidePeersAdding}
onChange={(e) => {
onOutsidePeerSelectedIdsChange((prev) =>
e.target.checked
? [...new Set([...prev, peer.fabric_node_id])]
: prev.filter((id) => id !== peer.fabric_node_id),
);
}}
aria-label={title}
/>
</label>
<button
type="button"
className="topo-palette__item"
disabled={outsidePeersAdding}
onClick={() => {
onOutsidePeerSelectedIdsChange((prev) =>
prev.includes(peer.fabric_node_id)
? prev.filter((id) => id !== peer.fabric_node_id)
: [...prev, peer.fabric_node_id],
);
}}
>
<span className="topo-palette__name">{title}</span>
<span className="topo-palette__meta">
{[peer.ip, t("topology.outsidePeersVia").replace("{{name}}", viaName)]
.filter(Boolean)
.join(" · ")}
</span>
</button>
</div>
</li>
);
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onCloseOutsidePeers}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding}
onClick={() => void onAddOutsidePeers(outsidePeerSelectedIds)}
>
{outsidePeersAdding
? t("topology.addingNe")
: t("topology.addSelected").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</button>
</div>
</div>
</div>
) : null}
{addNeOpen && canvasMode ? (
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
<div
className="topo-modal__backdrop"
onClick={() => {
if (paletteAdding) return;
onCloseAddNe();
}}
/>
<div className="topo-modal__panel">
<div className="topo-modal__head">
<strong>{t("topology.addNe")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={onCloseAddNe}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.paletteHint")}</p>
<div className="topo-palette-source" role="tablist">
<button
type="button"
role="tab"
aria-selected={paletteSource === "managed"}
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => onPaletteSourceChange("managed")}
>
{t("topology.paletteManaged")}
</button>
<button
type="button"
role="tab"
aria-selected={paletteSource === "ume"}
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => onPaletteSourceChange("ume")}
>
{t("topology.paletteUme")}
</button>
</div>
<input
className="input"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
placeholder={t("topology.filterPh")}
disabled={paletteAdding}
autoFocus
/>
{paletteVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
paletteVisible.length > 0 &&
paletteVisible.every((item) => paletteSelectedKeys.includes(item.key))
}
disabled={paletteAdding}
onChange={(e) => {
if (e.target.checked) {
onPaletteSelectedKeysChange(paletteVisible.map((item) => item.key));
return;
}
onPaletteSelectedKeysChange([]);
}}
aria-label={t("topology.selectAllVisible")}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace(
"{{count}}",
String(paletteSelectedKeys.length),
)}
</span>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{paletteLoading ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteLoading")}</span>
</li>
) : paletteVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteEmpty")}</span>
</li>
) : (
paletteVisible.map((item) => {
const checked = paletteSelectedKeys.includes(item.key);
return (
<li key={item.key}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={paletteAdding}
onChange={(e) => {
onPaletteSelectedKeysChange((prev) =>
e.target.checked
? [...new Set([...prev, item.key])]
: prev.filter((key) => key !== item.key),
);
}}
aria-label={item.name}
/>
</label>
<button
type="button"
className="topo-palette__item"
draggable={!paletteAdding}
disabled={paletteAdding}
onClick={() => {
onPaletteSelectedKeysChange((prev) =>
prev.includes(item.key)
? prev.filter((key) => key !== item.key)
: [...prev, item.key],
);
}}
onDragStart={(e) => onPaletteDragStart(e, item)}
title={t("topology.paletteDragHint")}
>
<span className="topo-palette__name">{item.name}</span>
<span className="topo-palette__meta">{item.meta}</span>
</button>
</div>
</li>
);
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={onCloseAddNe}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={paletteSelectedKeys.length === 0 || paletteAdding}
onClick={() => void onAddSelectedPalette()}
>
{paletteAdding
? t("topology.addingNe")
: t("topology.addSelected").replace(
"{{count}}",
String(paletteSelectedKeys.length),
)}
</button>
</div>
</div>
</div>
</>
) : null}
</>
);

View file

@ -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<ManagedNeItem | null>(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<NeNodeData> | 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<NeNodeData>[]) => {
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,
};
}

View file

@ -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<SetStateAction<Node<NeNodeData>[]>>;
setEdges: Dispatch<SetStateAction<Edge[]>>;
clearDirty: () => void;
historyLockRef: MutableRefObject<boolean>;
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<PlaceholderCreateDialogState | null>(
null,
);
const [placeholderBusy, setPlaceholderBusy] = useState(false);
const [managedFormOpen, setManagedFormOpen] = useState(false);
const [managedFormInitial, setManagedFormInitial] = useState<Partial<ManagedNeFormState> | 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<ReturnType<typeof fetchTopologyGraph>>) => {
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,
};
}

View file

@ -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 (
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
<div
className="topo-modal__backdrop"
onClick={() => {
if (paletteAdding) return;
onClose();
}}
/>
<div className="topo-modal__panel">
<div className="topo-modal__head">
<strong>{t("topology.addNe")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.paletteHint")}</p>
<div className="topo-palette-source" role="tablist">
<button
type="button"
role="tab"
aria-selected={paletteSource === "managed"}
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => onPaletteSourceChange("managed")}
>
{t("topology.paletteManaged")}
</button>
<button
type="button"
role="tab"
aria-selected={paletteSource === "ume"}
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`}
disabled={paletteAdding}
onClick={() => onPaletteSourceChange("ume")}
>
{t("topology.paletteUme")}
</button>
</div>
<input
className="input"
value={keyword}
onChange={(e) => onKeywordChange(e.target.value)}
placeholder={t("topology.filterPh")}
disabled={paletteAdding}
autoFocus
/>
{paletteVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
paletteVisible.length > 0 &&
paletteVisible.every((item) => paletteSelectedKeys.includes(item.key))
}
disabled={paletteAdding}
onChange={(e) => {
if (e.target.checked) {
onPaletteSelectedKeysChange(paletteVisible.map((item) => item.key));
return;
}
onPaletteSelectedKeysChange([]);
}}
aria-label={t("topology.selectAllVisible")}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace("{{count}}", String(paletteSelectedKeys.length))}
</span>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{paletteLoading ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteLoading")}</span>
</li>
) : paletteVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.paletteEmpty")}</span>
</li>
) : (
paletteVisible.map((item) => {
const checked = paletteSelectedKeys.includes(item.key);
return (
<li key={item.key}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={paletteAdding}
onChange={(e) => {
onPaletteSelectedKeysChange((prev) =>
e.target.checked
? [...new Set([...prev, item.key])]
: prev.filter((key) => key !== item.key),
);
}}
aria-label={item.name}
/>
</label>
<button
type="button"
className="topo-palette__item"
draggable={!paletteAdding}
disabled={paletteAdding}
onClick={() => {
onPaletteSelectedKeysChange((prev) =>
prev.includes(item.key)
? prev.filter((key) => key !== item.key)
: [...prev, item.key],
);
}}
onDragStart={(e) => onPaletteDragStart(e, item)}
title={t("topology.paletteDragHint")}
>
<span className="topo-palette__name">{item.name}</span>
<span className="topo-palette__meta">{item.meta}</span>
</button>
</div>
</li>
);
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={paletteAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={paletteSelectedKeys.length === 0 || paletteAdding}
onClick={() => void onAddSelected()}
>
{paletteAdding
? t("topology.addingNe")
: t("topology.addSelected").replace("{{count}}", String(paletteSelectedKeys.length))}
</button>
</div>
</div>
</div>
);
}

View file

@ -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 (
<div className="topo-modal" role="dialog" aria-modal="true" aria-labelledby="topo-create-ne-mode-title">
<div className="topo-modal__backdrop" onClick={onClose} />
<div className="topo-modal__panel" style={{ maxWidth: 440 }}>
<div className="topo-modal__head">
<strong id="topo-create-ne-mode-title">{t("topology.createNe")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onClose}>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.createNeModeHint")}</p>
<div className="topo-create-ne-mode" style={{ padding: "0 16px 16px", display: "grid", gap: 10 }}>
<button type="button" className="btn" onClick={onPickManaged}>
<span style={{ display: "block", fontWeight: 600 }}>{t("topology.createNeManaged")}</span>
<span className="panel__hint" style={{ display: "block", marginTop: 4 }}>
{t("topology.createNeManagedHint")}
</span>
</button>
<button type="button" className="btn btn--ghost" onClick={onPickPlaceholder}>
<span style={{ display: "block", fontWeight: 600 }}>{t("topology.createNePlaceholder")}</span>
<span className="panel__hint" style={{ display: "block", marginTop: 4 }}>
{t("topology.createNePlaceholderHint")}
</span>
</button>
</div>
</div>
</div>
);
}

View file

@ -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 (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-new-root-title"
>
<div className="topo-modal__backdrop" onClick={onClose} />
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
<div className="topo-modal__head">
<strong id="topo-new-root-title">{t("topology.newRegion")}</strong>
<button type="button" className="btn btn--sm btn--ghost" onClick={onClose}>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.folderHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">{t("topology.newRegionPrompt")}</span>
<input
autoFocus
value={dialog.name}
disabled={pending}
onChange={(e) => onNameChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") onSubmit();
}}
/>
</label>
</div>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={pending}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={pending || !String(dialog.name || "").trim()}
onClick={onSubmit}
>
{pending ? "…" : t("topology.newRegion")}
</button>
</div>
</div>
</div>
);
}

View file

@ -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<string, string>;
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 (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-label={t("topology.outsidePeersTitle")}
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (outsidePeersAdding) return;
onClose();
}}
/>
<div className="topo-modal__panel topo-modal__panel--wide">
<div className="topo-modal__head">
<strong>
{t("topology.outsidePeersTitle")}
<span className="topo-modal__count"> · {outsidePeers.length}</span>
</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.outsidePeersHint")}</p>
<input
className="input"
value={outsidePeerQuery}
onChange={(e) => onOutsidePeerQueryChange(e.target.value)}
placeholder={t("topology.outsidePeersFilterPh")}
disabled={outsidePeersAdding}
autoFocus
/>
{outsidePeersVisible.length > 0 ? (
<div className="topo-modal__selectbar">
<label className="topo-modal__selectall">
<input
type="checkbox"
checked={
outsidePeersVisible.length > 0 &&
outsidePeersVisible.every((p) =>
outsidePeerSelectedIds.includes(p.fabric_node_id),
)
}
disabled={outsidePeersAdding}
onChange={(e) => {
if (e.target.checked) {
onOutsidePeerSelectedIdsChange((prev) => [
...new Set([...prev, ...outsidePeersVisible.map((p) => p.fabric_node_id)]),
]);
return;
}
const drop = new Set(outsidePeersVisible.map((p) => p.fabric_node_id));
onOutsidePeerSelectedIdsChange((prev) => prev.filter((id) => !drop.has(id)));
}}
aria-label={t("topology.selectAllVisible")}
/>
<span>{t("topology.selectAllVisible")}</span>
</label>
<span className="panel__hint">
{t("topology.selectedCount").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</span>
</div>
) : null}
<ul className="topo-palette topo-modal__list">
{outsidePeersVisible.length === 0 ? (
<li className="topo-palette__empty">
<span className="panel__hint">{t("topology.outsidePeersEmpty")}</span>
</li>
) : (
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 (
<li key={peer.fabric_node_id}>
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
<label className="topo-palette__check">
<input
type="checkbox"
checked={checked}
disabled={outsidePeersAdding}
onChange={(e) => {
onOutsidePeerSelectedIdsChange((prev) =>
e.target.checked
? [...new Set([...prev, peer.fabric_node_id])]
: prev.filter((id) => id !== peer.fabric_node_id),
);
}}
aria-label={title}
/>
</label>
<button
type="button"
className="topo-palette__item"
disabled={outsidePeersAdding}
onClick={() => {
onOutsidePeerSelectedIdsChange((prev) =>
prev.includes(peer.fabric_node_id)
? prev.filter((id) => id !== peer.fabric_node_id)
: [...prev, peer.fabric_node_id],
);
}}
>
<span className="topo-palette__name">{title}</span>
<span className="topo-palette__meta">
{[peer.ip, t("topology.outsidePeersVia").replace("{{name}}", viaName)]
.filter(Boolean)
.join(" · ")}
</span>
</button>
</div>
</li>
);
})
)}
</ul>
<div className="topo-modal__foot">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={outsidePeersAdding}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={outsidePeerSelectedIds.length === 0 || outsidePeersAdding}
onClick={() => void onAddOutsidePeers(outsidePeerSelectedIds)}
>
{outsidePeersAdding
? t("topology.addingNe")
: t("topology.addSelected").replace(
"{{count}}",
String(outsidePeerSelectedIds.length),
)}
</button>
</div>
</div>
</div>
);
}

View file

@ -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<Pick<PlaceholderCreateDialogState, "name" | "ip_address">>) => void;
onClose: () => void;
onSubmit: () => void;
};
export function PlaceholderCreateDialog({
dialog,
busy,
onChange,
onClose,
onSubmit,
}: PlaceholderCreateDialogProps) {
const { t } = useI18n();
if (!dialog) return null;
return (
<div
className="topo-modal"
role="dialog"
aria-modal="true"
aria-labelledby="topo-create-ne-title"
>
<div
className="topo-modal__backdrop"
onClick={() => {
if (busy) return;
onClose();
}}
/>
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
<div className="topo-modal__head">
<strong id="topo-create-ne-title">{t("topology.createNeTitle")}</strong>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={busy}
onClick={onClose}
>
{t("topology.discoverClose")}
</button>
</div>
<p className="panel__hint topo-modal__hint">{t("topology.createNeHint")}</p>
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
<label>
<span className="form-label">
{t("topology.createNeName")}
<span className="form-label__required" aria-hidden="true">
{" "}
*
</span>
</span>
<input
autoFocus
value={dialog.name}
placeholder={t("topology.createNeNamePh")}
disabled={busy}
onChange={(e) => onChange({ name: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
/>
</label>
<label>
<span className="form-label">{t("topology.createNeIp")}</span>
<input
value={dialog.ip_address}
placeholder={t("topology.createNeIpPh")}
disabled={busy}
onChange={(e) => onChange({ ip_address: e.target.value })}
onKeyDown={(e) => {
if (e.key === "Enter") void onSubmit();
}}
/>
</label>
</div>
<div className="topo-modal__foot">
<button type="button" className="btn btn--ghost" disabled={busy} onClick={onClose}>
{t("topology.discoverClose")}
</button>
<button
type="button"
className="btn btn--primary"
disabled={busy}
onClick={() => void onSubmit()}
>
{busy ? t("topology.createNeBusy") : t("topology.createNePlaceholder")}
</button>
</div>
</div>
</div>
);
}