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
|
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:
|
def _format_failure_detail(creds: dict[str, Any], exc: BaseException) -> str:
|
||||||
lines = _connect_context_lines(creds)
|
lines = _connect_context_lines(creds)
|
||||||
lines.append(f"result=fail")
|
lines.append("result=fail")
|
||||||
lines.append(f"error={type(exc).__name__}: {exc}")
|
lines.append(f"error={_exc_headline(exc)}")
|
||||||
tb = traceback.format_exc().strip()
|
root = _root_cause_headline(exc)
|
||||||
if tb:
|
if root:
|
||||||
lines.append("")
|
lines.append(f"cause={root}")
|
||||||
lines.append(tb)
|
# Reachability failures: context + one-liners are enough.
|
||||||
|
# Auth/CLI/hop surprises still get a short stack for support.
|
||||||
|
if not _is_network_reachability_fail(exc):
|
||||||
|
stack = _compact_traceback()
|
||||||
|
if stack:
|
||||||
|
lines.append("")
|
||||||
|
lines.append("stack:")
|
||||||
|
lines.append(stack)
|
||||||
return _truncate_detail("\n".join(lines))
|
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)
|
_update_row(ne_id, status, message, discovered, detail=detail)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.exception("connect test failed for %s", ne_id)
|
_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:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
|
|
@ -436,8 +521,19 @@ def _run_single_ume(ume_ne_id: str) -> None:
|
||||||
try:
|
try:
|
||||||
creds, _device = resolve_cli_target(db, ume_ne_id=uid)
|
creds, _device = resolve_cli_target(db, ume_ne_id=uid)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
detail = _truncate_detail(traceback.format_exc())
|
detail = _format_failure_detail(
|
||||||
_update_ume_override_row(uid, "fail", str(exc)[:480], detail=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
|
return
|
||||||
skip = cli_creds_skip_reason(creds, interactive=False)
|
skip = cli_creds_skip_reason(creds, interactive=False)
|
||||||
if skip:
|
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)
|
_update_ume_override_row(uid, status, message, discovered, detail=detail)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.exception("ume connect test failed for %s", ume_ne_id)
|
_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:
|
finally:
|
||||||
db.close()
|
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)",
|
"After discover, project neighbors of scanned NEs onto the canvas (Save to keep permanently)",
|
||||||
undo: "Undo",
|
undo: "Undo",
|
||||||
redo: "Redo",
|
redo: "Redo",
|
||||||
discoverSelected: "Discover selected ({{count}})",
|
discoverSelected: "Discover links ({{count}})",
|
||||||
removeSelected: "Remove selected ({{count}})",
|
removeSelected: "Remove selected ({{count}})",
|
||||||
openWebcrt: "Open terminal",
|
openWebcrt: "Open terminal",
|
||||||
openNe: "NE details",
|
openNe: "NE details",
|
||||||
|
|
@ -1645,6 +1645,12 @@ const en = {
|
||||||
paneMenu: "Canvas",
|
paneMenu: "Canvas",
|
||||||
edgeMenu: "Edge",
|
edgeMenu: "Edge",
|
||||||
createNe: "New NE",
|
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",
|
createNeTitle: "New topology placeholder NE",
|
||||||
createNeHint:
|
createNeHint:
|
||||||
"Source is topology placeholder. Complete login via WebCRT, or promote in NE management.",
|
"Source is topology placeholder. Complete login via WebCRT, or promote in NE management.",
|
||||||
|
|
@ -1654,7 +1660,12 @@ const en = {
|
||||||
createNeIp: "IP (optional)",
|
createNeIp: "IP (optional)",
|
||||||
createNeIpPh: "Can fill later during setup",
|
createNeIpPh: "Can fill later during setup",
|
||||||
createNeDone: "Created placeholder {{name}}",
|
createNeDone: "Created placeholder {{name}}",
|
||||||
|
createManagedNeDone: "Created and placed {{name}}",
|
||||||
createNeBusy: "Creating…",
|
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",
|
selectionMenu: "Selection",
|
||||||
edgeStyle: "Edge style",
|
edgeStyle: "Edge style",
|
||||||
edgeColor: "Color",
|
edgeColor: "Color",
|
||||||
|
|
|
||||||
|
|
@ -1617,7 +1617,7 @@ const zh = {
|
||||||
discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布(需保存才永久保留)",
|
discoverProjectNeighbors: "发现后将本次扫描网元的直连邻居加入画布(需保存才永久保留)",
|
||||||
undo: "撤销",
|
undo: "撤销",
|
||||||
redo: "重做",
|
redo: "重做",
|
||||||
discoverSelected: "发现选中 ({{count}})",
|
discoverSelected: "发现链路({{count}})",
|
||||||
removeSelected: "移除选中 ({{count}})",
|
removeSelected: "移除选中 ({{count}})",
|
||||||
openWebcrt: "打开终端",
|
openWebcrt: "打开终端",
|
||||||
openNe: "网元详情",
|
openNe: "网元详情",
|
||||||
|
|
@ -1630,6 +1630,11 @@ const zh = {
|
||||||
paneMenu: "画布",
|
paneMenu: "画布",
|
||||||
edgeMenu: "链路",
|
edgeMenu: "链路",
|
||||||
createNe: "新建网元",
|
createNe: "新建网元",
|
||||||
|
createNeModeHint: "选择创建正式运维网元,或仅建拓扑占位(可稍后完善登录信息)。",
|
||||||
|
createNeManaged: "正式网元",
|
||||||
|
createNeManagedHint: "与网元管理相同的完整表单;创建后自动上图。",
|
||||||
|
createNePlaceholder: "占位网元",
|
||||||
|
createNePlaceholderHint: "仅名称/IP,来源为拓扑占位,适合先构图再补齐凭据。",
|
||||||
createNeTitle: "新建拓扑占位网元",
|
createNeTitle: "新建拓扑占位网元",
|
||||||
createNeHint: "来源为拓扑占位;完善登录信息后可在 WebCRT 连接,或在网元管理升级为手工。",
|
createNeHint: "来源为拓扑占位;完善登录信息后可在 WebCRT 连接,或在网元管理升级为手工。",
|
||||||
createNeName: "名称",
|
createNeName: "名称",
|
||||||
|
|
@ -1638,7 +1643,12 @@ const zh = {
|
||||||
createNeIp: "IP(可选)",
|
createNeIp: "IP(可选)",
|
||||||
createNeIpPh: "可稍后在完善时填写",
|
createNeIpPh: "可稍后在完善时填写",
|
||||||
createNeDone: "已新建占位网元 {{name}}",
|
createNeDone: "已新建占位网元 {{name}}",
|
||||||
|
createManagedNeDone: "已创建网元并上图 {{name}}",
|
||||||
createNeBusy: "创建中…",
|
createNeBusy: "创建中…",
|
||||||
|
connectTest: "连通性测试",
|
||||||
|
connectTestSelected: "连通性测试 ({{count}})",
|
||||||
|
connectTestNeedManaged: "该节点未绑定运维网元,无法做连通性测试",
|
||||||
|
connectTestSkipped: "跳过 {{count}} 台无运维绑定",
|
||||||
selectionMenu: "多选",
|
selectionMenu: "多选",
|
||||||
edgeStyle: "链路样式",
|
edgeStyle: "链路样式",
|
||||||
edgeColor: "颜色",
|
edgeColor: "颜色",
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import {
|
||||||
batchApplyHopManagedNe,
|
batchApplyHopManagedNe,
|
||||||
batchDeleteManagedNe,
|
batchDeleteManagedNe,
|
||||||
connectTestManagedNe,
|
connectTestManagedNe,
|
||||||
createManagedNe,
|
|
||||||
deleteUmeManagedNe,
|
deleteUmeManagedNe,
|
||||||
deleteManagedNe,
|
deleteManagedNe,
|
||||||
fetchIdsByTag,
|
fetchIdsByTag,
|
||||||
|
|
@ -17,7 +16,6 @@ import {
|
||||||
importManagedNe,
|
importManagedNe,
|
||||||
downloadManagedNeImportTemplate,
|
downloadManagedNeImportTemplate,
|
||||||
syncUmeManagedNe,
|
syncUmeManagedNe,
|
||||||
updateManagedNe,
|
|
||||||
type ManagedNeStats,
|
type ManagedNeStats,
|
||||||
} from "../services/api";
|
} from "../services/api";
|
||||||
import { HelpHint } from "../components/HelpHint";
|
import { HelpHint } from "../components/HelpHint";
|
||||||
|
|
@ -30,99 +28,29 @@ import type { ManagedNeItem } from "../types";
|
||||||
import { pageCount } from "../utils/display";
|
import { pageCount } from "../utils/display";
|
||||||
import { formatSystemTime } from "../utils/time";
|
import { formatSystemTime } from "../utils/time";
|
||||||
import { openOrFocusModule } from "../utils/moduleWindows";
|
import { openOrFocusModule } from "../utils/moduleWindows";
|
||||||
|
import { ManagedNeFormDialog } from "./managedNe/ManagedNeFormDialog";
|
||||||
|
import { ManagedNeConnectDetailDialog } from "./managedNe/ManagedNeConnectDetailDialog";
|
||||||
|
import { connectStatusClass } from "./managedNe/connectStatus";
|
||||||
import {
|
import {
|
||||||
defaultHopTemplate,
|
deviceTypeForVendor,
|
||||||
isAutoHopTemplate,
|
emptyManagedNeForm,
|
||||||
patchHopVendorChange,
|
managedSourceKey,
|
||||||
type HopVendor,
|
type ManagedNeFormState,
|
||||||
} from "../utils/hopProxy";
|
} from "./managedNe/formState";
|
||||||
|
|
||||||
/** Hide UME→managed sync/delete controls until needed again. APIs remain available. */
|
/** Hide UME→managed sync/delete controls until needed again. APIs remain available. */
|
||||||
const SHOW_UME_MANAGED_SYNC = false;
|
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 = {
|
type AccountState = {
|
||||||
username: string;
|
username: string;
|
||||||
password: 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 => ({
|
const emptyAccount = (): AccountState => ({
|
||||||
username: "",
|
username: "",
|
||||||
password: "",
|
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 }) {
|
function FormLabel({ children, required }: { children: ReactNode; required?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span className="form-label">
|
<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() {
|
export function NePage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showOk, showError } = useToast();
|
const { showOk, showError } = useToast();
|
||||||
|
|
@ -172,7 +85,7 @@ export function NePage() {
|
||||||
const [batchHopOpen, setBatchHopOpen] = useState(false);
|
const [batchHopOpen, setBatchHopOpen] = useState(false);
|
||||||
const [batchAccountOpen, setBatchAccountOpen] = useState(false);
|
const [batchAccountOpen, setBatchAccountOpen] = useState(false);
|
||||||
const [editing, setEditing] = useState<ManagedNeItem | null>(null);
|
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 [batchHop, setBatchHop] = useState<HopProxyFieldsState>(emptyHopProxyFields);
|
||||||
const [batchAccount, setBatchAccount] = useState<AccountState>(emptyAccount);
|
const [batchAccount, setBatchAccount] = useState<AccountState>(emptyAccount);
|
||||||
const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(null);
|
const [connectDetailRow, setConnectDetailRow] = useState<ManagedNeItem | null>(null);
|
||||||
|
|
@ -231,53 +144,6 @@ export function NePage() {
|
||||||
|
|
||||||
const invalidateList = () => queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: deleteManagedNe,
|
mutationFn: deleteManagedNe,
|
||||||
onSuccess: async () => {
|
onSuccess: async () => {
|
||||||
|
|
@ -438,12 +304,6 @@ export function NePage() {
|
||||||
});
|
});
|
||||||
|
|
||||||
const vendors = metaQuery.data?.vendors ?? [];
|
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 credsOk = metaQuery.data?.credentials_configured ?? false;
|
||||||
|
|
||||||
const allSelected = useMemo(() => {
|
const allSelected = useMemo(() => {
|
||||||
|
|
@ -453,45 +313,13 @@ export function NePage() {
|
||||||
|
|
||||||
const openCreate = () => {
|
const openCreate = () => {
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setForm(emptyForm());
|
setFormSeed(undefined);
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openEdit = (row: ManagedNeItem) => {
|
const openEdit = (row: ManagedNeItem) => {
|
||||||
setEditing(row);
|
setEditing(row);
|
||||||
setForm({
|
setFormSeed(undefined);
|
||||||
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),
|
|
||||||
});
|
|
||||||
setModalOpen(true);
|
setModalOpen(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -538,11 +366,10 @@ export function NePage() {
|
||||||
const name = String(searchParams.get("name") || "").trim();
|
const name = String(searchParams.get("name") || "").trim();
|
||||||
const ip = String(searchParams.get("ip_address") || "").trim();
|
const ip = String(searchParams.get("ip_address") || "").trim();
|
||||||
const vendorRaw = String(searchParams.get("vendor") || "").trim();
|
const vendorRaw = String(searchParams.get("vendor") || "").trim();
|
||||||
const base = emptyForm();
|
const base = emptyManagedNeForm();
|
||||||
const vendor = vendorRaw || base.vendor;
|
const vendor = vendorRaw || base.vendor;
|
||||||
setEditing(null);
|
setEditing(null);
|
||||||
setForm({
|
setFormSeed({
|
||||||
...base,
|
|
||||||
name,
|
name,
|
||||||
vendor,
|
vendor,
|
||||||
device_type: deviceTypeForVendor(vendor),
|
device_type: deviceTypeForVendor(vendor),
|
||||||
|
|
@ -999,161 +826,24 @@ export function NePage() {
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{modalOpen ? (
|
<ManagedNeFormDialog
|
||||||
<div className="modal-backdrop" role="presentation" onClick={() => setModalOpen(false)}>
|
open={modalOpen}
|
||||||
<div className="modal" role="dialog" onClick={(e) => e.stopPropagation()}>
|
editing={editing}
|
||||||
<h3>{editing ? t("managedNe.form.editTitle") : t("managedNe.form.createTitle")}</h3>
|
initialValues={formSeed}
|
||||||
<p className="form-hint">{t("managedNe.form.requiredHint")}</p>
|
onClose={() => {
|
||||||
<div className="form-grid">
|
setModalOpen(false);
|
||||||
<label>
|
setEditing(null);
|
||||||
<FormLabel>{t("managedNe.col.name")}</FormLabel>
|
setFormSeed(undefined);
|
||||||
<input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
}}
|
||||||
<span className="form-field-hint">{t("managedNe.form.nameConnectHint")}</span>
|
onSaved={async () => {
|
||||||
</label>
|
const wasEdit = Boolean(editing);
|
||||||
<label>
|
setModalOpen(false);
|
||||||
<FormLabel required>{t("managedNe.col.vendor")}</FormLabel>
|
setEditing(null);
|
||||||
<select required value={form.vendor} onChange={(e) => {
|
setFormSeed(undefined);
|
||||||
const vendor = e.target.value;
|
showOk(wasEdit ? t("managedNe.form.updated") : t("managedNe.form.created"));
|
||||||
setForm((prev) => {
|
await invalidateList();
|
||||||
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),
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
}));
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<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 ? (
|
{batchHopOpen ? (
|
||||||
<div className="modal-backdrop" role="presentation" onClick={() => setBatchHopOpen(false)}>
|
<div className="modal-backdrop" role="presentation" onClick={() => setBatchHopOpen(false)}>
|
||||||
|
|
@ -1335,47 +1025,13 @@ export function NePage() {
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{connectDetailRow ? (
|
<ManagedNeConnectDetailDialog
|
||||||
<div className="modal-backdrop" role="presentation" onClick={() => setConnectDetailRow(null)}>
|
row={connectDetailRow}
|
||||||
<div className="modal modal--wide" role="dialog" onClick={(e) => e.stopPropagation()}>
|
onClose={() => setConnectDetailRow(null)}
|
||||||
<h3>{t("managedNe.connectDetailTitle")}</h3>
|
onRetestSubmitted={() => {
|
||||||
<p className="form-hint">
|
void invalidateList();
|
||||||
{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]);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -51,6 +51,8 @@ import { useTopologyHistory } from "./topology/hooks/useTopologyHistory";
|
||||||
import { useTopologyDiscover } from "./topology/hooks/useTopologyDiscover";
|
import { useTopologyDiscover } from "./topology/hooks/useTopologyDiscover";
|
||||||
import { useTopologyTreeNav } from "./topology/hooks/useTopologyTreeNav";
|
import { useTopologyTreeNav } from "./topology/hooks/useTopologyTreeNav";
|
||||||
import { useTopologyCanvas } from "./topology/hooks/useTopologyCanvas";
|
import { useTopologyCanvas } from "./topology/hooks/useTopologyCanvas";
|
||||||
|
import { useTopologyCreateNe } from "./topology/hooks/useTopologyCreateNe";
|
||||||
|
import { useTopologyConnectTest } from "./topology/hooks/useTopologyConnectTest";
|
||||||
import {
|
import {
|
||||||
lazy,
|
lazy,
|
||||||
Suspense,
|
Suspense,
|
||||||
|
|
@ -73,7 +75,6 @@ import type {
|
||||||
import {
|
import {
|
||||||
addTopologyViewNodes,
|
addTopologyViewNodes,
|
||||||
createFabricManualEdge,
|
createFabricManualEdge,
|
||||||
createTopologyPlaceholder,
|
|
||||||
deleteFabricEdges,
|
deleteFabricEdges,
|
||||||
purgePlaceholderFabricNodes,
|
purgePlaceholderFabricNodes,
|
||||||
fetchLldpCollectDashboard,
|
fetchLldpCollectDashboard,
|
||||||
|
|
@ -165,13 +166,6 @@ export function TopologyPage() {
|
||||||
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
||||||
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
|
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
|
||||||
const [paletteAdding, setPaletteAdding] = useState(false);
|
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);
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
/** Opt-in poll so MCP / other clients painting the open map can be watched. Off by default. */
|
/** Opt-in poll so MCP / other clients painting the open map can be watched. Off by default. */
|
||||||
const [liveSync, setLiveSync] = useState(false);
|
const [liveSync, setLiveSync] = useState(false);
|
||||||
|
|
@ -821,6 +815,45 @@ export function TopologyPage() {
|
||||||
|
|
||||||
const closeCtxMenu = useCallback(() => setCtxMenu(null), []);
|
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(() => {
|
useEffect(() => {
|
||||||
const syncFs = () => {
|
const syncFs = () => {
|
||||||
const el = canvasRef.current;
|
const el = canvasRef.current;
|
||||||
|
|
@ -1400,14 +1433,43 @@ export function TopologyPage() {
|
||||||
void runDiscover([id]);
|
void runDiscover([id]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const openCreateNeAt = (flowX: number, flowY: number) => {
|
const selectedDiscoverableCount = useMemo(
|
||||||
if (isWorldFlatViewName(activeView?.name)) {
|
() =>
|
||||||
showError(t("topology.worldMapNoDirectNes"));
|
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;
|
return;
|
||||||
}
|
}
|
||||||
closeCtxMenu();
|
const ids = [
|
||||||
setCreateNeDialog({ flowX, flowY, name: "", ip_address: "" });
|
...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 placeCtxMenu = (clientX: number, clientY: number, size?: { w?: number; h?: number }): { x: number; y: number } => {
|
||||||
const pad = 8;
|
const pad = 8;
|
||||||
|
|
@ -1418,58 +1480,6 @@ export function TopologyPage() {
|
||||||
return { x: Math.max(pad, x), y: Math.max(pad, y) };
|
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<{
|
const pendingLocateWorldRef = useRef<{
|
||||||
id: string;
|
id: string;
|
||||||
worldX?: number;
|
worldX?: number;
|
||||||
|
|
@ -2351,13 +2361,24 @@ export function TopologyPage() {
|
||||||
onCloseNewRoot={() => setNewRootDialog(null)}
|
onCloseNewRoot={() => setNewRootDialog(null)}
|
||||||
onSubmitNewRoot={submitNewRoot}
|
onSubmitNewRoot={submitNewRoot}
|
||||||
createRegionPending={createRegionMut.isPending}
|
createRegionPending={createRegionMut.isPending}
|
||||||
createNeDialog={createNeDialog}
|
createNeModeOpen={createNeModeOpen}
|
||||||
createNeBusy={createNeBusy}
|
onCloseCreateNeMode={closeCreateNeMode}
|
||||||
onCreateNeChange={(patch) =>
|
onPickCreateManaged={pickCreateManaged}
|
||||||
setCreateNeDialog((prev) => (prev ? { ...prev, ...patch } : prev))
|
onPickCreatePlaceholder={pickCreatePlaceholder}
|
||||||
|
placeholderDialog={placeholderDialog}
|
||||||
|
placeholderBusy={placeholderBusy}
|
||||||
|
onPlaceholderChange={(patch) =>
|
||||||
|
setPlaceholderDialog((prev) => (prev ? { ...prev, ...patch } : prev))
|
||||||
}
|
}
|
||||||
onCloseCreateNe={() => setCreateNeDialog(null)}
|
onClosePlaceholder={closePlaceholder}
|
||||||
onSubmitCreateNe={submitCreateNe}
|
onSubmitPlaceholder={submitPlaceholder}
|
||||||
|
managedFormOpen={managedFormOpen}
|
||||||
|
managedFormInitial={managedFormInitial}
|
||||||
|
onCloseManagedForm={closeManagedForm}
|
||||||
|
onManagedFormSaved={(item) => void onManagedFormSaved(item)}
|
||||||
|
connectDetailRow={connectDetailRow}
|
||||||
|
onCloseConnectDetail={closeConnectDetail}
|
||||||
|
onConnectRetestSubmitted={onConnectRetestSubmitted}
|
||||||
outsidePeersOpen={outsidePeersOpen}
|
outsidePeersOpen={outsidePeersOpen}
|
||||||
outsidePeers={outsidePeers}
|
outsidePeers={outsidePeers}
|
||||||
outsidePeersVisible={outsidePeersVisible}
|
outsidePeersVisible={outsidePeersVisible}
|
||||||
|
|
@ -2419,12 +2440,18 @@ export function TopologyPage() {
|
||||||
onClose={closeCtxMenu}
|
onClose={closeCtxMenu}
|
||||||
onToggleFullscreen={toggleFullscreen}
|
onToggleFullscreen={toggleFullscreen}
|
||||||
onRemoveSelected={removeSelected}
|
onRemoveSelected={removeSelected}
|
||||||
|
onDiscoverSelected={discoverSelectedFor}
|
||||||
|
onConnectTestSelected={connectTestSelectedFor}
|
||||||
|
selectedDiscoverableCount={selectedDiscoverableCount}
|
||||||
|
selectedConnectableCount={selectedConnectableCount}
|
||||||
onOpenCreateNe={openCreateNeAt}
|
onOpenCreateNe={openCreateNeAt}
|
||||||
onPromptNewSubRegion={promptNewSubRegion}
|
onPromptNewSubRegion={promptNewSubRegion}
|
||||||
onRenameSelectedNode={renameSelectedNode}
|
onRenameSelectedNode={renameSelectedNode}
|
||||||
onDiscoverOne={discoverOneFor}
|
onDiscoverOne={discoverOneFor}
|
||||||
onOpenWebcrt={openWebcrtFor}
|
onOpenWebcrt={openWebcrtFor}
|
||||||
onOpenNe={openNeFor}
|
onOpenNe={openNeFor}
|
||||||
|
onConnectTest={runConnectTestForNode}
|
||||||
|
connectTestBusy={connectTestSubmitting}
|
||||||
onPurgePlaceholder={purgePlaceholderById}
|
onPurgePlaceholder={purgePlaceholderById}
|
||||||
onRemoveNode={removeNodeById}
|
onRemoveNode={removeNodeById}
|
||||||
onExpandPhysicalLinks={() => setExpandPhysicalLinks(true)}
|
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;
|
onClose: () => void;
|
||||||
onToggleFullscreen: () => void;
|
onToggleFullscreen: () => void;
|
||||||
onRemoveSelected: () => void;
|
onRemoveSelected: () => void;
|
||||||
|
onDiscoverSelected: () => void;
|
||||||
|
onConnectTestSelected: () => void;
|
||||||
|
selectedDiscoverableCount: number;
|
||||||
|
selectedConnectableCount: number;
|
||||||
onOpenCreateNe: (flowX: number, flowY: number) => void;
|
onOpenCreateNe: (flowX: number, flowY: number) => void;
|
||||||
onPromptNewSubRegion: () => void;
|
onPromptNewSubRegion: () => void;
|
||||||
onRenameSelectedNode: () => void;
|
onRenameSelectedNode: () => void;
|
||||||
onDiscoverOne: (node: Node<NeNodeData> | null) => void;
|
onDiscoverOne: (node: Node<NeNodeData> | null) => void;
|
||||||
onOpenWebcrt: (node: Node<NeNodeData> | null) => void;
|
onOpenWebcrt: (node: Node<NeNodeData> | null) => void;
|
||||||
onOpenNe: (node: Node<NeNodeData> | null) => void;
|
onOpenNe: (node: Node<NeNodeData> | null) => void;
|
||||||
|
onConnectTest: (node: Node<NeNodeData> | null) => void;
|
||||||
|
connectTestBusy?: boolean;
|
||||||
onPurgePlaceholder: (id: string) => void;
|
onPurgePlaceholder: (id: string) => void;
|
||||||
onRemoveNode: (id: string) => void;
|
onRemoveNode: (id: string) => void;
|
||||||
onExpandPhysicalLinks: () => void;
|
onExpandPhysicalLinks: () => void;
|
||||||
|
|
@ -70,12 +76,18 @@ export function TopologyCtxMenu({
|
||||||
onClose,
|
onClose,
|
||||||
onToggleFullscreen,
|
onToggleFullscreen,
|
||||||
onRemoveSelected,
|
onRemoveSelected,
|
||||||
|
onDiscoverSelected,
|
||||||
|
onConnectTestSelected,
|
||||||
|
selectedDiscoverableCount,
|
||||||
|
selectedConnectableCount,
|
||||||
onOpenCreateNe,
|
onOpenCreateNe,
|
||||||
onPromptNewSubRegion,
|
onPromptNewSubRegion,
|
||||||
onRenameSelectedNode,
|
onRenameSelectedNode,
|
||||||
onDiscoverOne,
|
onDiscoverOne,
|
||||||
onOpenWebcrt,
|
onOpenWebcrt,
|
||||||
onOpenNe,
|
onOpenNe,
|
||||||
|
onConnectTest,
|
||||||
|
connectTestBusy = false,
|
||||||
onPurgePlaceholder,
|
onPurgePlaceholder,
|
||||||
onRemoveNode,
|
onRemoveNode,
|
||||||
onExpandPhysicalLinks,
|
onExpandPhysicalLinks,
|
||||||
|
|
@ -98,7 +110,47 @@ export function TopologyCtxMenu({
|
||||||
<>
|
<>
|
||||||
<li className="topo-ctx__head" role="presentation">
|
<li className="topo-ctx__head" role="presentation">
|
||||||
{t("topology.selectionMenu")}
|
{t("topology.selectionMenu")}
|
||||||
|
<span className="topo-ctx__count"> · {selectedNodeCount}</span>
|
||||||
</li>
|
</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">
|
<li role="none">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -225,6 +277,24 @@ export function TopologyCtxMenu({
|
||||||
{t("topology.openNe")}
|
{t("topology.openNe")}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</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 />
|
<li className="topo-ctx__sep" aria-hidden />
|
||||||
{selectedNode &&
|
{selectedNode &&
|
||||||
isPlaceholderSource(selectedNode.data.managed_source, selectedNode.data.ne_ip) ? (
|
isPlaceholderSource(selectedNode.data.managed_source, selectedNode.data.ne_ip) ? (
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,20 @@
|
||||||
import type { DragEvent } from "react";
|
import type { DragEvent } from "react";
|
||||||
import type { TopologyOutsidePeer } from "../../types";
|
import type { TopologyOutsidePeer } from "../../types";
|
||||||
import { useI18n } from "../../i18n";
|
|
||||||
import type { PaletteItem, PaletteSource } from "./pageTypes";
|
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 = {
|
export type { PlaceholderCreateDialogState as CreateNeDialogState };
|
||||||
flowX: number;
|
|
||||||
flowY: number;
|
|
||||||
name: string;
|
|
||||||
ip_address: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type TopologyModalsProps = {
|
export type TopologyModalsProps = {
|
||||||
canvasMode: boolean;
|
canvasMode: boolean;
|
||||||
|
|
@ -17,11 +23,27 @@ export type TopologyModalsProps = {
|
||||||
onCloseNewRoot: () => void;
|
onCloseNewRoot: () => void;
|
||||||
onSubmitNewRoot: () => void;
|
onSubmitNewRoot: () => void;
|
||||||
createRegionPending: boolean;
|
createRegionPending: boolean;
|
||||||
createNeDialog: CreateNeDialogState | null;
|
|
||||||
createNeBusy: boolean;
|
createNeModeOpen: boolean;
|
||||||
onCreateNeChange: (patch: Partial<Pick<CreateNeDialogState, "name" | "ip_address">>) => void;
|
onCloseCreateNeMode: () => void;
|
||||||
onCloseCreateNe: () => void;
|
onPickCreateManaged: () => void;
|
||||||
onSubmitCreateNe: () => 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;
|
outsidePeersOpen: boolean;
|
||||||
outsidePeers: TopologyOutsidePeer[];
|
outsidePeers: TopologyOutsidePeer[];
|
||||||
outsidePeersVisible: TopologyOutsidePeer[];
|
outsidePeersVisible: TopologyOutsidePeer[];
|
||||||
|
|
@ -33,6 +55,7 @@ export type TopologyModalsProps = {
|
||||||
outsidePeersAdding: boolean;
|
outsidePeersAdding: boolean;
|
||||||
onCloseOutsidePeers: () => void;
|
onCloseOutsidePeers: () => void;
|
||||||
onAddOutsidePeers: (ids: string[]) => void;
|
onAddOutsidePeers: (ids: string[]) => void;
|
||||||
|
|
||||||
addNeOpen: boolean;
|
addNeOpen: boolean;
|
||||||
paletteSource: PaletteSource;
|
paletteSource: PaletteSource;
|
||||||
onPaletteSourceChange: (source: PaletteSource) => void;
|
onPaletteSourceChange: (source: PaletteSource) => void;
|
||||||
|
|
@ -48,6 +71,7 @@ export type TopologyModalsProps = {
|
||||||
onAddSelectedPalette: () => void;
|
onAddSelectedPalette: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Thin composer — keep individual dialogs in `modals/` / `managedNe/`. */
|
||||||
export function TopologyModals({
|
export function TopologyModals({
|
||||||
canvasMode,
|
canvasMode,
|
||||||
newRootDialog,
|
newRootDialog,
|
||||||
|
|
@ -55,11 +79,22 @@ export function TopologyModals({
|
||||||
onCloseNewRoot,
|
onCloseNewRoot,
|
||||||
onSubmitNewRoot,
|
onSubmitNewRoot,
|
||||||
createRegionPending,
|
createRegionPending,
|
||||||
createNeDialog,
|
createNeModeOpen,
|
||||||
createNeBusy,
|
onCloseCreateNeMode,
|
||||||
onCreateNeChange,
|
onPickCreateManaged,
|
||||||
onCloseCreateNe,
|
onPickCreatePlaceholder,
|
||||||
onSubmitCreateNe,
|
placeholderDialog,
|
||||||
|
placeholderBusy,
|
||||||
|
onPlaceholderChange,
|
||||||
|
onClosePlaceholder,
|
||||||
|
onSubmitPlaceholder,
|
||||||
|
managedFormOpen,
|
||||||
|
managedFormInitial,
|
||||||
|
onCloseManagedForm,
|
||||||
|
onManagedFormSaved,
|
||||||
|
connectDetailRow,
|
||||||
|
onCloseConnectDetail,
|
||||||
|
onConnectRetestSubmitted,
|
||||||
outsidePeersOpen,
|
outsidePeersOpen,
|
||||||
outsidePeers,
|
outsidePeers,
|
||||||
outsidePeersVisible,
|
outsidePeersVisible,
|
||||||
|
|
@ -85,460 +120,72 @@ export function TopologyModals({
|
||||||
onPaletteDragStart,
|
onPaletteDragStart,
|
||||||
onAddSelectedPalette,
|
onAddSelectedPalette,
|
||||||
}: TopologyModalsProps) {
|
}: TopologyModalsProps) {
|
||||||
const { t } = useI18n();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{newRootDialog ? (
|
<NewRootDialog
|
||||||
<div
|
dialog={newRootDialog}
|
||||||
className="topo-modal"
|
pending={createRegionPending}
|
||||||
role="dialog"
|
onNameChange={onNewRootNameChange}
|
||||||
aria-modal="true"
|
onClose={onCloseNewRoot}
|
||||||
aria-labelledby="topo-new-root-title"
|
onSubmit={onSubmitNewRoot}
|
||||||
>
|
/>
|
||||||
<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();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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 ? (
|
{canvasMode ? (
|
||||||
<div
|
<>
|
||||||
className="topo-modal"
|
<CreateNeModeDialog
|
||||||
role="dialog"
|
open={createNeModeOpen}
|
||||||
aria-modal="true"
|
onClose={onCloseCreateNeMode}
|
||||||
aria-labelledby="topo-create-ne-title"
|
onPickManaged={onPickCreateManaged}
|
||||||
>
|
onPickPlaceholder={onPickCreatePlaceholder}
|
||||||
<div
|
|
||||||
className="topo-modal__backdrop"
|
|
||||||
onClick={() => {
|
|
||||||
if (createNeBusy) return;
|
|
||||||
onCloseCreateNe();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
|
<PlaceholderCreateDialog
|
||||||
<div className="topo-modal__head">
|
dialog={placeholderDialog}
|
||||||
<strong id="topo-create-ne-title">{t("topology.createNeTitle")}</strong>
|
busy={placeholderBusy}
|
||||||
<button
|
onChange={onPlaceholderChange}
|
||||||
type="button"
|
onClose={onClosePlaceholder}
|
||||||
className="btn btn--sm btn--ghost"
|
onSubmit={onSubmitPlaceholder}
|
||||||
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();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</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();
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
<div className="topo-modal__panel topo-modal__panel--wide">
|
<ManagedNeFormDialog
|
||||||
<div className="topo-modal__head">
|
open={managedFormOpen}
|
||||||
<strong>
|
editing={null}
|
||||||
{t("topology.outsidePeersTitle")}
|
initialValues={managedFormInitial}
|
||||||
<span className="topo-modal__count"> · {outsidePeers.length}</span>
|
onClose={onCloseManagedForm}
|
||||||
</strong>
|
onSaved={onManagedFormSaved}
|
||||||
<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
|
|
||||||
/>
|
|
||||||
{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={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">
|
<ManagedNeConnectDetailDialog
|
||||||
<div className="topo-modal__head">
|
row={connectDetailRow}
|
||||||
<strong>{t("topology.addNe")}</strong>
|
onClose={onCloseConnectDetail}
|
||||||
<button
|
onRetestSubmitted={onConnectRetestSubmitted}
|
||||||
type="button"
|
/>
|
||||||
className="btn btn--sm btn--ghost"
|
<OutsidePeersDialog
|
||||||
disabled={paletteAdding}
|
open={outsidePeersOpen}
|
||||||
onClick={onCloseAddNe}
|
outsidePeers={outsidePeers}
|
||||||
>
|
outsidePeersVisible={outsidePeersVisible}
|
||||||
{t("topology.discoverClose")}
|
outsidePeerQuery={outsidePeerQuery}
|
||||||
</button>
|
onOutsidePeerQueryChange={onOutsidePeerQueryChange}
|
||||||
</div>
|
outsidePeerSelectedIds={outsidePeerSelectedIds}
|
||||||
<p className="panel__hint topo-modal__hint">{t("topology.paletteHint")}</p>
|
onOutsidePeerSelectedIdsChange={onOutsidePeerSelectedIdsChange}
|
||||||
<div className="topo-palette-source" role="tablist">
|
outsidePeerNameById={outsidePeerNameById}
|
||||||
<button
|
outsidePeersAdding={outsidePeersAdding}
|
||||||
type="button"
|
onClose={onCloseOutsidePeers}
|
||||||
role="tab"
|
onAddOutsidePeers={onAddOutsidePeers}
|
||||||
aria-selected={paletteSource === "managed"}
|
/>
|
||||||
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
|
<AddNePaletteDialog
|
||||||
disabled={paletteAdding}
|
open={addNeOpen}
|
||||||
onClick={() => onPaletteSourceChange("managed")}
|
paletteSource={paletteSource}
|
||||||
>
|
onPaletteSourceChange={onPaletteSourceChange}
|
||||||
{t("topology.paletteManaged")}
|
keyword={keyword}
|
||||||
</button>
|
onKeywordChange={onKeywordChange}
|
||||||
<button
|
paletteVisible={paletteVisible}
|
||||||
type="button"
|
paletteSelectedKeys={paletteSelectedKeys}
|
||||||
role="tab"
|
onPaletteSelectedKeysChange={onPaletteSelectedKeysChange}
|
||||||
aria-selected={paletteSource === "ume"}
|
paletteLoading={paletteLoading}
|
||||||
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`}
|
paletteAdding={paletteAdding}
|
||||||
disabled={paletteAdding}
|
onClose={onCloseAddNe}
|
||||||
onClick={() => onPaletteSourceChange("ume")}
|
onPaletteDragStart={onPaletteDragStart}
|
||||||
>
|
onAddSelected={onAddSelectedPalette}
|
||||||
{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}
|
) : 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