mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 21:50:47 +08:00
Add topology-canvas placeholder NE create (source=topology).
Toolbar and pane context menu create placeholders that follow the same setup path as LLDP in NE management and WebCRT. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
71c5193ecd
commit
241093343e
21 changed files with 460 additions and 31 deletions
|
|
@ -737,6 +737,7 @@ const en = {
|
|||
ume_sync: "UME sync",
|
||||
webcrt: "WebCRT",
|
||||
lldp: "LLDP placeholder",
|
||||
topology: "Topology placeholder",
|
||||
},
|
||||
connect: {
|
||||
run: "Connectivity test",
|
||||
|
|
@ -1123,9 +1124,9 @@ const en = {
|
|||
},
|
||||
newSession: {
|
||||
title: "New Session",
|
||||
claimTitle: "Complete LLDP placeholder",
|
||||
claimTitle: "Complete placeholder session",
|
||||
claimHint:
|
||||
"Enter a reachable host. Session name defaults to the LLDP placeholder. On success, source becomes WebCRT.",
|
||||
"Enter a reachable host. Session name defaults to the placeholder name. On success, source becomes WebCRT.",
|
||||
connect: "Connect",
|
||||
connecting: "Connecting…",
|
||||
protocol: "Protocol",
|
||||
|
|
@ -1454,6 +1455,18 @@ const en = {
|
|||
removeNode: "Remove from canvas",
|
||||
removeEdge: "Delete edge",
|
||||
nodeMenu: "Node",
|
||||
paneMenu: "Canvas",
|
||||
createNe: "Create NE",
|
||||
createNeTitle: "Create topology placeholder NE",
|
||||
createNeHint:
|
||||
"Source is topology placeholder. Complete login via WebCRT, or promote in NE management.",
|
||||
createNeName: "Name",
|
||||
createNeNamePh: "e.g. SW-CORE-01",
|
||||
createNeNameRequired: "NE name is required",
|
||||
createNeIp: "IP (optional)",
|
||||
createNeIpPh: "Can fill later during setup",
|
||||
createNeDone: "Created placeholder {{name}}",
|
||||
createNeBusy: "Creating…",
|
||||
selectionMenu: "Selection",
|
||||
edgeStyle: "Edge style",
|
||||
edgeColor: "Color",
|
||||
|
|
|
|||
|
|
@ -732,6 +732,7 @@ const zh = {
|
|||
ume_sync: "UME 同步",
|
||||
webcrt: "WebCRT",
|
||||
lldp: "LLDP 占位",
|
||||
topology: "拓扑占位",
|
||||
},
|
||||
connect: {
|
||||
run: "连通性测试",
|
||||
|
|
@ -1117,8 +1118,8 @@ const zh = {
|
|||
},
|
||||
newSession: {
|
||||
title: "新建会话",
|
||||
claimTitle: "完善 LLDP 占位会话",
|
||||
claimHint: "请填写可达地址;会话名默认使用 LLDP 占位名。连接成功后来源将更新为 WebCRT。",
|
||||
claimTitle: "完善占位会话",
|
||||
claimHint: "请填写可达地址;会话名默认使用占位名。连接成功后来源将更新为 WebCRT。",
|
||||
connect: "连接",
|
||||
connecting: "连接中…",
|
||||
protocol: "协议",
|
||||
|
|
@ -1447,6 +1448,17 @@ const zh = {
|
|||
removeNode: "从画布移除",
|
||||
removeEdge: "删除链路",
|
||||
nodeMenu: "网元",
|
||||
paneMenu: "画布",
|
||||
createNe: "建立网元",
|
||||
createNeTitle: "建立拓扑占位网元",
|
||||
createNeHint: "来源为拓扑占位;完善登录信息后可在 WebCRT 连接,或在网元管理升级为手工。",
|
||||
createNeName: "名称",
|
||||
createNeNamePh: "例如 SW-CORE-01",
|
||||
createNeNameRequired: "请填写网元名称",
|
||||
createNeIp: "IP(可选)",
|
||||
createNeIpPh: "可稍后在完善时填写",
|
||||
createNeDone: "已建立占位网元 {{name}}",
|
||||
createNeBusy: "创建中…",
|
||||
selectionMenu: "多选",
|
||||
edgeStyle: "链路样式",
|
||||
edgeColor: "颜色",
|
||||
|
|
|
|||
|
|
@ -112,10 +112,12 @@ function applyHopTemplate(prev: FormState, protocol: string, vrf: string, force
|
|||
return { hop_command_template: defaultHopTemplate(prev.hop_vendor, protocol, vrf) };
|
||||
}
|
||||
|
||||
function managedSourceKey(source: string | undefined): "manual" | "ume_sync" | "webcrt" | "lldp" | "" {
|
||||
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") return s;
|
||||
if (s === "ume_sync" || s === "webcrt" || s === "lldp" || s === "topology") return s;
|
||||
return "";
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@ import {
|
|||
addTopologyViewNodes,
|
||||
createFabricManualEdge,
|
||||
createTopologyFolder,
|
||||
createTopologyPlaceholder,
|
||||
createTopologyView,
|
||||
deleteTopologyFolder,
|
||||
deleteTopologyMap,
|
||||
|
|
@ -298,7 +299,8 @@ type TopoDisplayOpts = {
|
|||
type CtxMenu =
|
||||
| { kind: "node"; id: string; x: number; y: number }
|
||||
| { kind: "edge"; id: string; x: number; y: number }
|
||||
| { kind: "selection"; x: number; y: number };
|
||||
| { kind: "selection"; x: number; y: number }
|
||||
| { kind: "pane"; x: number; y: number; flowX: number; flowY: number };
|
||||
|
||||
const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
||||
hideIp: true,
|
||||
|
|
@ -853,6 +855,13 @@ export function TopologyPage() {
|
|||
const [paletteSource, setPaletteSource] = useState<PaletteSource>("managed");
|
||||
const [paletteSelectedKeys, setPaletteSelectedKeys] = useState<string[]>([]);
|
||||
const [paletteAdding, setPaletteAdding] = useState(false);
|
||||
const [createNeDialog, setCreateNeDialog] = useState<{
|
||||
flowX: number;
|
||||
flowY: number;
|
||||
name: string;
|
||||
ip_address: string;
|
||||
} | null>(null);
|
||||
const [createNeBusy, setCreateNeBusy] = useState(false);
|
||||
const [discoverOpen, setDiscoverOpen] = useState(false);
|
||||
const [discovering, setDiscovering] = useState(false);
|
||||
const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null);
|
||||
|
|
@ -2084,8 +2093,8 @@ export function TopologyPage() {
|
|||
const src = String(ne.source || "").trim().toLowerCase();
|
||||
const hasIp = Boolean(String(ne.ip_address || "").trim());
|
||||
// Ready inventory hosts: reuse stored credentials (incl. hop/proxy).
|
||||
// LLDP placeholders / incomplete rows → full NE edit form (vendor, hop, etc.).
|
||||
const needsSetup = !hasIp || src === "lldp";
|
||||
// LLDP / topology placeholders / incomplete rows → full NE edit form.
|
||||
const needsSetup = !hasIp || src === "lldp" || src === "topology";
|
||||
if (!needsSetup) {
|
||||
openOrFocusModule({
|
||||
moduleId: "webcrt",
|
||||
|
|
@ -2180,6 +2189,11 @@ export function TopologyPage() {
|
|||
void runDiscover([id]);
|
||||
};
|
||||
|
||||
const openCreateNeAt = (flowX: number, flowY: number) => {
|
||||
closeCtxMenu();
|
||||
setCreateNeDialog({ flowX, flowY, name: "", ip_address: "" });
|
||||
};
|
||||
|
||||
const placeCtxMenu = (clientX: number, clientY: number, size?: { w?: number; h?: number }): { x: number; y: number } => {
|
||||
const pad = 8;
|
||||
const w = size?.w ?? 200;
|
||||
|
|
@ -2206,6 +2220,58 @@ export function TopologyPage() {
|
|||
[setNodes, setEdges],
|
||||
);
|
||||
|
||||
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 locateNode = useCallback(
|
||||
(nodeId: string) => {
|
||||
focusNode(nodeId, false);
|
||||
|
|
@ -2858,6 +2924,29 @@ export function TopologyPage() {
|
|||
>
|
||||
{t("topology.addNe")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm"
|
||||
onClick={() => {
|
||||
let flowX = 80 + nodes.length * 24;
|
||||
let flowY = 80 + nodes.length * 24;
|
||||
if (rfRef.current) {
|
||||
const pane = document.querySelector(".react-flow__pane");
|
||||
const rect = pane?.getBoundingClientRect();
|
||||
if (rect && rect.width > 0 && rect.height > 0) {
|
||||
const center = rfRef.current.screenToFlowPosition({
|
||||
x: rect.left + rect.width / 2,
|
||||
y: rect.top + rect.height / 2,
|
||||
});
|
||||
flowX = center.x;
|
||||
flowY = center.y;
|
||||
}
|
||||
}
|
||||
openCreateNeAt(flowX, flowY);
|
||||
}}
|
||||
>
|
||||
{t("topology.createNe")}
|
||||
</button>
|
||||
<button type="button" className="btn btn--sm btn--ghost" onClick={() => goBackBrowse()}>
|
||||
{t("topology.backUp")}
|
||||
</button>
|
||||
|
|
@ -3736,6 +3825,21 @@ export function TopologyPage() {
|
|||
const pos = placeCtxMenu(e.clientX, e.clientY);
|
||||
setCtxMenu({ kind: "selection", ...pos });
|
||||
}}
|
||||
onPaneContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
if (!rfRef.current) return;
|
||||
const flow = rfRef.current.screenToFlowPosition({
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
});
|
||||
const pos = placeCtxMenu(e.clientX, e.clientY, { w: 180, h: 100 });
|
||||
setCtxMenu({
|
||||
kind: "pane",
|
||||
...pos,
|
||||
flowX: flow.x,
|
||||
flowY: flow.y,
|
||||
});
|
||||
}}
|
||||
onMoveStart={closeCtxMenu}
|
||||
onInit={(inst) => {
|
||||
rfRef.current = inst as ReactFlowInstance<Node<NeNodeData>, Edge>;
|
||||
|
|
@ -3779,6 +3883,94 @@ export function TopologyPage() {
|
|||
)}
|
||||
</main>
|
||||
|
||||
{createNeDialog && canvasMode ? (
|
||||
<div
|
||||
className="topo-modal"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="topo-create-ne-title"
|
||||
>
|
||||
<div
|
||||
className="topo-modal__backdrop"
|
||||
onClick={() => {
|
||||
if (createNeBusy) return;
|
||||
setCreateNeDialog(null);
|
||||
}}
|
||||
/>
|
||||
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
|
||||
<div className="topo-modal__head">
|
||||
<strong id="topo-create-ne-title">{t("topology.createNeTitle")}</strong>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--sm btn--ghost"
|
||||
disabled={createNeBusy}
|
||||
onClick={() => setCreateNeDialog(null)}
|
||||
>
|
||||
{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) =>
|
||||
setCreateNeDialog((prev) => (prev ? { ...prev, name: e.target.value } : prev))
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submitCreateNe();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
<span className="form-label">{t("topology.createNeIp")}</span>
|
||||
<input
|
||||
value={createNeDialog.ip_address}
|
||||
placeholder={t("topology.createNeIpPh")}
|
||||
disabled={createNeBusy}
|
||||
onChange={(e) =>
|
||||
setCreateNeDialog((prev) =>
|
||||
prev ? { ...prev, ip_address: e.target.value } : prev,
|
||||
)
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void submitCreateNe();
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="topo-modal__foot">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--ghost"
|
||||
disabled={createNeBusy}
|
||||
onClick={() => setCreateNeDialog(null)}
|
||||
>
|
||||
{t("topology.discoverClose")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn--primary"
|
||||
disabled={createNeBusy}
|
||||
onClick={() => void submitCreateNe()}
|
||||
>
|
||||
{createNeBusy ? t("topology.createNeBusy") : t("topology.createNe")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{addNeOpen && canvasMode ? (
|
||||
<div className="topo-modal" role="dialog" aria-modal="true" aria-label={t("topology.addNe")}>
|
||||
<div
|
||||
|
|
@ -3974,6 +4166,22 @@ export function TopologyPage() {
|
|||
</button>
|
||||
</li>
|
||||
</>
|
||||
) : ctxMenu.kind === "pane" ? (
|
||||
<>
|
||||
<li className="topo-ctx__head" role="presentation">
|
||||
{t("topology.paneMenu")}
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
className="topo-ctx__item"
|
||||
role="menuitem"
|
||||
onClick={() => openCreateNeAt(ctxMenu.flowX, ctxMenu.flowY)}
|
||||
>
|
||||
{t("topology.createNe")}
|
||||
</button>
|
||||
</li>
|
||||
</>
|
||||
) : ctxMenu.kind === "node" ? (
|
||||
<>
|
||||
<li className="topo-ctx__head" role="presentation">
|
||||
|
|
|
|||
|
|
@ -355,7 +355,7 @@ function isInventorySsh(target: Pick<CliTargetItem, "source" | "protocol">): boo
|
|||
return String(target.protocol || "ssh").toLowerCase() !== "telnet";
|
||||
}
|
||||
|
||||
/** LLDP placeholders / incomplete inventory rows need New Session (host/IP) before auth. */
|
||||
/** LLDP / topology placeholders / incomplete inventory rows need New Session (host/IP) before auth. */
|
||||
function needsSessionSetup(
|
||||
target: Pick<CliTargetItem, "source" | "ne_source" | "ip_address">,
|
||||
): boolean {
|
||||
|
|
@ -363,7 +363,7 @@ function needsSessionSetup(
|
|||
if (listSrc === "ume") return false;
|
||||
if (listSrc !== "managed" && listSrc !== "webcrt") return false;
|
||||
const neSrc = String(target.ne_source || "").trim().toLowerCase();
|
||||
if (neSrc === "lldp") return true;
|
||||
if (neSrc === "lldp" || neSrc === "topology") return true;
|
||||
return !String(target.ip_address || "").trim();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean {
|
|||
}
|
||||
if (n.managed_alive === false) return true;
|
||||
const src = String(n.managed_source || "").toLowerCase();
|
||||
return src === "lldp" || src === "webcrt";
|
||||
return src === "lldp" || src === "topology" || src === "webcrt";
|
||||
}
|
||||
|
||||
export function TopologyClassifyPage() {
|
||||
|
|
|
|||
|
|
@ -1070,6 +1070,15 @@ export const addTopologyViewNodes = (
|
|||
},
|
||||
) => apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes`, body);
|
||||
|
||||
export const createTopologyPlaceholder = (
|
||||
viewId: string,
|
||||
body: { name: string; ip_address?: string; x?: number; y?: number },
|
||||
) =>
|
||||
apiPost<TopologyViewGraph>(
|
||||
`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/create-placeholder`,
|
||||
body,
|
||||
);
|
||||
|
||||
export const removeTopologyViewNodes = (viewId: string, fabricNodeIds: string[]) =>
|
||||
apiPost<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/nodes/remove`, {
|
||||
fabric_node_ids: fabricNodeIds,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue