mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Show connect/alarm/level on the canvas with display toggles, refresh overlay fields even on dirty edits, fix connect-test dots and create-place messaging, and deep-link node alarms into the network alarms page. Co-authored-by: Cursor <cursoragent@cursor.com>
521 lines
18 KiB
TypeScript
521 lines
18 KiB
TypeScript
import type { RefObject } from "react";
|
|
import { createPortal } from "react-dom";
|
|
import type { Edge, Node } from "@xyflow/react";
|
|
import { useI18n } from "../../i18n";
|
|
import type { CtxMenu } from "./pageTypes";
|
|
import type { EdgeStyleData } from "./edgeStyle";
|
|
import { formatPortPairLabel } from "./linkDisplay";
|
|
import { isPlaceholderSource } from "./searchUtils";
|
|
import type { NeNodeData } from "./TopologyReactFlowView";
|
|
|
|
export type ResolvedEdgeStyle = {
|
|
stroke: string;
|
|
strokeWidth: number;
|
|
strokeDasharray?: string;
|
|
};
|
|
|
|
export type TopologyCtxMenuProps = {
|
|
ctxMenu: CtxMenu;
|
|
menuRef: RefObject<HTMLUListElement | null>;
|
|
portalTarget: HTMLElement | null;
|
|
fullscreen: boolean;
|
|
selectedNodeCount: number;
|
|
selectedNode: Node<NeNodeData> | null;
|
|
selectedEdge: Edge | null;
|
|
selectedEdgeData: EdgeStyleData;
|
|
selectedEdgeResolved: ResolvedEdgeStyle | null;
|
|
selectedEdgeSourceNode: Node<NeNodeData> | null;
|
|
selectedEdgeTargetNode: Node<NeNodeData> | null;
|
|
expandPhysicalLinks: boolean;
|
|
discovering: boolean;
|
|
isWorldFlatCanvas: boolean;
|
|
createRegionPending: boolean;
|
|
onClose: () => void;
|
|
onToggleFullscreen: () => void;
|
|
onRemoveSelected: () => void;
|
|
onDiscoverSelected: () => void;
|
|
onConnectTestSelected: () => void;
|
|
selectedDiscoverableCount: number;
|
|
selectedConnectableCount: number;
|
|
onOpenCreateNe: (flowX: number, flowY: number) => void;
|
|
onPromptNewSubRegion: () => void;
|
|
onRenameSelectedNode: () => void;
|
|
onDiscoverOne: (node: Node<NeNodeData> | null) => void;
|
|
onOpenWebcrt: (node: Node<NeNodeData> | null) => void;
|
|
onOpenNe: (node: Node<NeNodeData> | null) => void;
|
|
onOpenAlarms: (node: Node<NeNodeData> | null) => void;
|
|
onConnectTest: (node: Node<NeNodeData> | null) => void;
|
|
connectTestBusy?: boolean;
|
|
onPurgePlaceholder: (id: string) => void;
|
|
onRemoveNode: (id: string) => void;
|
|
onExpandPhysicalLinks: () => void;
|
|
onPushHistory: () => void;
|
|
onPatchSelectedEdgeStyle: (
|
|
patch: Partial<EdgeStyleData>,
|
|
opts?: { skipHistory?: boolean },
|
|
) => void;
|
|
onOpenPortTraffic: (edge: Edge | null) => void;
|
|
onRemoveEdge: (id: string) => void;
|
|
};
|
|
|
|
export function TopologyCtxMenu({
|
|
ctxMenu,
|
|
menuRef,
|
|
portalTarget,
|
|
fullscreen,
|
|
selectedNodeCount,
|
|
selectedNode,
|
|
selectedEdge,
|
|
selectedEdgeData,
|
|
selectedEdgeResolved,
|
|
selectedEdgeSourceNode,
|
|
selectedEdgeTargetNode,
|
|
expandPhysicalLinks,
|
|
discovering,
|
|
isWorldFlatCanvas,
|
|
createRegionPending,
|
|
onClose,
|
|
onToggleFullscreen,
|
|
onRemoveSelected,
|
|
onDiscoverSelected,
|
|
onConnectTestSelected,
|
|
selectedDiscoverableCount,
|
|
selectedConnectableCount,
|
|
onOpenCreateNe,
|
|
onPromptNewSubRegion,
|
|
onRenameSelectedNode,
|
|
onDiscoverOne,
|
|
onOpenWebcrt,
|
|
onOpenNe,
|
|
onOpenAlarms,
|
|
onConnectTest,
|
|
connectTestBusy = false,
|
|
onPurgePlaceholder,
|
|
onRemoveNode,
|
|
onExpandPhysicalLinks,
|
|
onPushHistory,
|
|
onPatchSelectedEdgeStyle,
|
|
onOpenPortTraffic,
|
|
onRemoveEdge,
|
|
}: TopologyCtxMenuProps) {
|
|
const { t } = useI18n();
|
|
|
|
return createPortal(
|
|
<ul
|
|
ref={menuRef}
|
|
className={`topo-ctx${ctxMenu.kind === "edge" ? " topo-ctx--edge" : ""}`}
|
|
style={{ left: ctxMenu.x, top: ctxMenu.y }}
|
|
role="menu"
|
|
onContextMenu={(e) => e.preventDefault()}
|
|
>
|
|
{ctxMenu.kind === "selection" ? (
|
|
<>
|
|
<li className="topo-ctx__head" role="presentation">
|
|
{t("topology.selectionMenu")}
|
|
<span className="topo-ctx__count"> · {selectedNodeCount}</span>
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
disabled={discovering || selectedDiscoverableCount === 0}
|
|
title={
|
|
selectedDiscoverableCount === 0 ? t("topology.discoverOneNeedNe") : undefined
|
|
}
|
|
onClick={() => onDiscoverSelected()}
|
|
>
|
|
{t("topology.discoverSelected").replace(
|
|
"{{count}}",
|
|
String(selectedDiscoverableCount || selectedNodeCount),
|
|
)}
|
|
</button>
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
disabled={connectTestBusy || selectedConnectableCount === 0}
|
|
title={
|
|
selectedConnectableCount === 0
|
|
? t("topology.connectTestNeedManaged")
|
|
: undefined
|
|
}
|
|
onClick={() => onConnectTestSelected()}
|
|
>
|
|
{connectTestBusy
|
|
? t("managedNe.connect.running")
|
|
: t("topology.connectTestSelected").replace(
|
|
"{{count}}",
|
|
String(selectedConnectableCount || selectedNodeCount),
|
|
)}
|
|
</button>
|
|
</li>
|
|
<li className="topo-ctx__sep" aria-hidden />
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
onClose();
|
|
void onToggleFullscreen();
|
|
}}
|
|
>
|
|
{fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
|
|
</button>
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item topo-ctx__item--danger"
|
|
role="menuitem"
|
|
onClick={() => onRemoveSelected()}
|
|
>
|
|
{t("topology.removeSelected").replace("{{count}}", String(selectedNodeCount))}
|
|
</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"
|
|
disabled={isWorldFlatCanvas}
|
|
onClick={() => onOpenCreateNe(ctxMenu.flowX, ctxMenu.flowY)}
|
|
>
|
|
{t("topology.createNe")}
|
|
</button>
|
|
</li>
|
|
{!isWorldFlatCanvas ? (
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
disabled={createRegionPending}
|
|
onClick={() => onPromptNewSubRegion()}
|
|
>
|
|
{t("topology.newSubRegion")}
|
|
</button>
|
|
</li>
|
|
) : null}
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
onClose();
|
|
void onToggleFullscreen();
|
|
}}
|
|
>
|
|
{fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
|
|
</button>
|
|
</li>
|
|
</>
|
|
) : ctxMenu.kind === "node" ? (
|
|
<>
|
|
<li className="topo-ctx__head" role="presentation">
|
|
{t("topology.nodeMenu")}
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
onClose();
|
|
void onToggleFullscreen();
|
|
}}
|
|
>
|
|
{fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
|
|
</button>
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
onClick={onRenameSelectedNode}
|
|
>
|
|
{t("topology.renameNode")}
|
|
</button>
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
disabled={discovering}
|
|
onClick={() => onDiscoverOne(selectedNode)}
|
|
>
|
|
{t("topology.discoverOne")}
|
|
</button>
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
onClick={() => onOpenWebcrt(selectedNode)}
|
|
>
|
|
{t("topology.openWebcrt")}
|
|
</button>
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
onClick={() => onOpenNe(selectedNode)}
|
|
>
|
|
{t("topology.openNe")}
|
|
</button>
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
onClick={() => onOpenAlarms(selectedNode)}
|
|
>
|
|
{t("topology.openAlarms")}
|
|
</button>
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
disabled={
|
|
connectTestBusy || !String(selectedNode?.data.managed_ne_id || "").trim()
|
|
}
|
|
title={
|
|
!String(selectedNode?.data.managed_ne_id || "").trim()
|
|
? t("topology.connectTestNeedManaged")
|
|
: undefined
|
|
}
|
|
onClick={() => onConnectTest(selectedNode)}
|
|
>
|
|
{connectTestBusy ? t("managedNe.connect.running") : t("topology.connectTest")}
|
|
</button>
|
|
</li>
|
|
<li className="topo-ctx__sep" aria-hidden />
|
|
{selectedNode &&
|
|
isPlaceholderSource(selectedNode.data.managed_source, selectedNode.data.ne_ip) ? (
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item topo-ctx__item--danger"
|
|
role="menuitem"
|
|
onClick={() => void onPurgePlaceholder(ctxMenu.id)}
|
|
>
|
|
{t("topology.deletePlaceholder")}
|
|
</button>
|
|
</li>
|
|
) : null}
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item topo-ctx__item--danger"
|
|
role="menuitem"
|
|
onClick={() => onRemoveNode(ctxMenu.id)}
|
|
>
|
|
{t("topology.removeNode")}
|
|
</button>
|
|
</li>
|
|
</>
|
|
) : (
|
|
<>
|
|
<li className="topo-ctx__head" role="presentation">
|
|
{t("topology.edgeMenu")}
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
onClick={() => {
|
|
onClose();
|
|
void onToggleFullscreen();
|
|
}}
|
|
>
|
|
{fullscreen ? t("topology.exitFullscreen") : t("topology.fullscreen")}
|
|
</button>
|
|
</li>
|
|
<li className="topo-ctx__section" role="none">
|
|
<div className="topo-ctx__style" onMouseDown={(e) => e.stopPropagation()}>
|
|
<div className="topo-ctx__style-title">{t("topology.edgeStyle")}</div>
|
|
{selectedEdgeResolved ? (
|
|
<div className="topo-ctx__style-grid">
|
|
{Number(selectedEdgeData.member_count || 1) > 1 ? (
|
|
<div className="topo-ctx__style-members">
|
|
<div className="topo-ctx__style-title">
|
|
{t("topology.linkMembers").replace(
|
|
"{{count}}",
|
|
String(selectedEdgeData.member_count || 0),
|
|
)}
|
|
</div>
|
|
<ul className="topo-ctx__member-list">
|
|
{(selectedEdgeData.members || []).map((m) => (
|
|
<li key={m.id}>
|
|
{formatPortPairLabel(m.a_port, m.b_port, m.display_label) ||
|
|
m.id.slice(0, 8)}
|
|
</li>
|
|
))}
|
|
</ul>
|
|
{!expandPhysicalLinks ? (
|
|
<button
|
|
type="button"
|
|
className="btn btn--sm"
|
|
onClick={() => {
|
|
onExpandPhysicalLinks();
|
|
onClose();
|
|
}}
|
|
>
|
|
{t("topology.expandPhysicalLinks")}
|
|
</button>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
<label className="topo-ctx__style-row">
|
|
<span>
|
|
{(selectedEdgeSourceNode?.data.label ||
|
|
selectedEdgeSourceNode?.data.ne_ip ||
|
|
t("topology.endpointA")) +
|
|
" · " +
|
|
t("topology.port")}
|
|
</span>
|
|
<input
|
|
type="text"
|
|
className="topo-ctx__style-input"
|
|
value={selectedEdgeData.source_port || ""}
|
|
placeholder="?"
|
|
disabled={
|
|
Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks
|
|
}
|
|
onFocus={() => onPushHistory()}
|
|
onChange={(e) =>
|
|
onPatchSelectedEdgeStyle({ source_port: e.target.value }, { skipHistory: true })
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="topo-ctx__style-row">
|
|
<span>
|
|
{(selectedEdgeTargetNode?.data.label ||
|
|
selectedEdgeTargetNode?.data.ne_ip ||
|
|
t("topology.endpointB")) +
|
|
" · " +
|
|
t("topology.port")}
|
|
</span>
|
|
<input
|
|
type="text"
|
|
className="topo-ctx__style-input"
|
|
value={selectedEdgeData.target_port || ""}
|
|
placeholder="?"
|
|
disabled={
|
|
Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks
|
|
}
|
|
onFocus={() => onPushHistory()}
|
|
onChange={(e) =>
|
|
onPatchSelectedEdgeStyle({ target_port: e.target.value }, { skipHistory: true })
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="topo-ctx__style-row">
|
|
<span>{t("topology.edgeColor")}</span>
|
|
<input
|
|
type="color"
|
|
value={selectedEdgeResolved.stroke}
|
|
onFocus={() => onPushHistory()}
|
|
onChange={(e) =>
|
|
onPatchSelectedEdgeStyle(
|
|
{ stroke_color: e.target.value },
|
|
{ skipHistory: true },
|
|
)
|
|
}
|
|
/>
|
|
</label>
|
|
<label className="topo-ctx__style-row">
|
|
<span>{t("topology.edgeLineStyle")}</span>
|
|
<select
|
|
value={
|
|
selectedEdgeData.line_style ||
|
|
(selectedEdgeResolved.strokeDasharray ? "dashed" : "solid")
|
|
}
|
|
onChange={(e) => onPatchSelectedEdgeStyle({ line_style: e.target.value })}
|
|
>
|
|
<option value="solid">{t("topology.edgeLineSolid")}</option>
|
|
<option value="dashed">{t("topology.edgeLineDashed")}</option>
|
|
<option value="dotted">{t("topology.edgeLineDotted")}</option>
|
|
</select>
|
|
</label>
|
|
<label className="topo-ctx__style-row">
|
|
<span>{t("topology.edgeWidth")}</span>
|
|
<select
|
|
value={String(
|
|
Number(selectedEdgeData.stroke_width || 0) > 0
|
|
? Number(selectedEdgeData.stroke_width)
|
|
: Math.round(Number(selectedEdgeResolved.strokeWidth || 2)),
|
|
)}
|
|
onChange={(e) =>
|
|
onPatchSelectedEdgeStyle({ stroke_width: Number(e.target.value) || 0 })
|
|
}
|
|
>
|
|
{[1, 2, 3, 4, 5, 6, 8].map((w) => (
|
|
<option key={w} value={w}>
|
|
{w}px
|
|
</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
</li>
|
|
<li className="topo-ctx__sep" aria-hidden />
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
onClick={() => onOpenPortTraffic(selectedEdge)}
|
|
>
|
|
{t("topology.openPortTraffic")}
|
|
</button>
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item"
|
|
role="menuitem"
|
|
onClick={() =>
|
|
onPatchSelectedEdgeStyle({ stroke_color: "", stroke_width: 0, line_style: "" })
|
|
}
|
|
>
|
|
{t("topology.edgeStyleReset")}
|
|
</button>
|
|
</li>
|
|
<li role="none">
|
|
<button
|
|
type="button"
|
|
className="topo-ctx__item topo-ctx__item--danger"
|
|
role="menuitem"
|
|
onClick={() => onRemoveEdge(ctxMenu.id)}
|
|
>
|
|
{t("topology.removeEdge")}
|
|
</button>
|
|
</li>
|
|
</>
|
|
)}
|
|
</ul>,
|
|
portalTarget || document.body,
|
|
);
|
|
}
|