mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
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:
parent
f8d4a396f1
commit
56569e49d1
19 changed files with 1980 additions and 931 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
61
tests/test_ne_connect_detail.py
Normal file
61
tests/test_ne_connect_detail.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "颜色",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
71
web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx
Normal file
71
web/src/pages/managedNe/ManagedNeConnectDetailDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
273
web/src/pages/managedNe/ManagedNeFormDialog.tsx
Normal file
273
web/src/pages/managedNe/ManagedNeFormDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
14
web/src/pages/managedNe/connectStatus.ts
Normal file
14
web/src/pages/managedNe/connectStatus.ts
Normal 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";
|
||||
}
|
||||
167
web/src/pages/managedNe/formState.ts
Normal file
167
web/src/pages/managedNe/formState.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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) ? (
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
</>
|
||||
);
|
||||
|
|
|
|||
111
web/src/pages/topology/hooks/useTopologyConnectTest.ts
Normal file
111
web/src/pages/topology/hooks/useTopologyConnectTest.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
225
web/src/pages/topology/hooks/useTopologyCreateNe.ts
Normal file
225
web/src/pages/topology/hooks/useTopologyCreateNe.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
194
web/src/pages/topology/modals/AddNePaletteDialog.tsx
Normal file
194
web/src/pages/topology/modals/AddNePaletteDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
48
web/src/pages/topology/modals/CreateNeModeDialog.tsx
Normal file
48
web/src/pages/topology/modals/CreateNeModeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
web/src/pages/topology/modals/NewRootDialog.tsx
Normal file
66
web/src/pages/topology/modals/NewRootDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
web/src/pages/topology/modals/OutsidePeersDialog.tsx
Normal file
186
web/src/pages/topology/modals/OutsidePeersDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
web/src/pages/topology/modals/PlaceholderCreateDialog.tsx
Normal file
104
web/src/pages/topology/modals/PlaceholderCreateDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue