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:
oliver 2026-08-05 17:42:51 +08:00
parent 71c5193ecd
commit 241093343e
21 changed files with 460 additions and 31 deletions

View file

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

View file

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

View file

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

View file

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

View file

@ -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();
}

View file

@ -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() {

View file

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