mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 15:23:19 +08:00
Split topology page into modules and hooks; add SVG/XML export.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
54ca265430
commit
b1aee86701
31 changed files with 6874 additions and 4697 deletions
|
|
@ -1601,6 +1601,14 @@ const en = {
|
||||||
edgeDiscovered: "LLDP",
|
edgeDiscovered: "LLDP",
|
||||||
edgeStale: "Missing",
|
edgeStale: "Missing",
|
||||||
fit: "Fit view",
|
fit: "Fit view",
|
||||||
|
export: "Export",
|
||||||
|
exportSvg: "SVG vector",
|
||||||
|
exportXml: "XML data",
|
||||||
|
exporting: "Exporting…",
|
||||||
|
exportEmpty: "Nothing on the canvas to export",
|
||||||
|
exportedSvg: "SVG exported",
|
||||||
|
exportedXml: "XML exported",
|
||||||
|
exportFailed: "Export failed: {{detail}}",
|
||||||
liveSync: "Live sync",
|
liveSync: "Live sync",
|
||||||
liveSyncOn: "Syncing…",
|
liveSyncOn: "Syncing…",
|
||||||
liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default",
|
liveSyncHint: "Poll the tree about every 5s and the open map every 3s (watch MCP create/draw). Off by default",
|
||||||
|
|
|
||||||
|
|
@ -1586,6 +1586,14 @@ const zh = {
|
||||||
edgeDiscovered: "LLDP 发现",
|
edgeDiscovered: "LLDP 发现",
|
||||||
edgeStale: "未发现",
|
edgeStale: "未发现",
|
||||||
fit: "适应画布",
|
fit: "适应画布",
|
||||||
|
export: "导出",
|
||||||
|
exportSvg: "SVG 矢量",
|
||||||
|
exportXml: "XML 数据",
|
||||||
|
exporting: "导出中…",
|
||||||
|
exportEmpty: "画布上没有可导出的内容",
|
||||||
|
exportedSvg: "已导出 SVG",
|
||||||
|
exportedXml: "已导出 XML",
|
||||||
|
exportFailed: "导出失败:{{detail}}",
|
||||||
liveSync: "实时同步",
|
liveSync: "实时同步",
|
||||||
liveSyncOn: "同步中…",
|
liveSyncOn: "同步中…",
|
||||||
liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新当前画布(观看 MCP 建图/画拓扑);默认关闭",
|
liveSyncHint: "开启后约每 5 秒刷新左侧树、每 3 秒刷新当前画布(观看 MCP 建图/画拓扑);默认关闭",
|
||||||
|
|
|
||||||
|
|
@ -6433,6 +6433,73 @@ pre {
|
||||||
color: #e2e8f0;
|
color: #e2e8f0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-toolbar__export {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-toolbar__export > summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
height: 30px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #0b1220;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-toolbar__export > summary::-webkit-details-marker {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-toolbar__export[open] > summary {
|
||||||
|
border-color: rgba(59, 130, 246, 0.55);
|
||||||
|
background: rgba(37, 99, 235, 0.22);
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-export-menu {
|
||||||
|
position: absolute;
|
||||||
|
top: calc(100% + 4px);
|
||||||
|
right: 0;
|
||||||
|
z-index: 20;
|
||||||
|
min-width: 140px;
|
||||||
|
padding: 4px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #0b1220;
|
||||||
|
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.45);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-export-menu__btn {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: transparent;
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-export-menu__btn:hover:not(:disabled) {
|
||||||
|
background: rgba(37, 99, 235, 0.22);
|
||||||
|
color: #93c5fd;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-export-menu__btn:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-mode-hint {
|
.topo-mode-hint {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 10px;
|
top: 10px;
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
439
web/src/pages/topology/TopologyCtxMenu.tsx
Normal file
439
web/src/pages/topology/TopologyCtxMenu.tsx
Normal file
|
|
@ -0,0 +1,439 @@
|
||||||
|
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;
|
||||||
|
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;
|
||||||
|
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,
|
||||||
|
onOpenCreateNe,
|
||||||
|
onPromptNewSubRegion,
|
||||||
|
onRenameSelectedNode,
|
||||||
|
onDiscoverOne,
|
||||||
|
onOpenWebcrt,
|
||||||
|
onOpenNe,
|
||||||
|
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")}
|
||||||
|
</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 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 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,
|
||||||
|
);
|
||||||
|
}
|
||||||
118
web/src/pages/topology/TopologyDiscoverPanel.tsx
Normal file
118
web/src/pages/topology/TopologyDiscoverPanel.tsx
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import type { TopologyDiscoverOut } from "../../types";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||||
|
|
||||||
|
export type DiscoverProgress = {
|
||||||
|
index: number;
|
||||||
|
total: number;
|
||||||
|
neName: string;
|
||||||
|
neIp: string;
|
||||||
|
edgesAdded: number;
|
||||||
|
edgesUpdated: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DiscoverSummary = {
|
||||||
|
scanned: number;
|
||||||
|
added: number;
|
||||||
|
updated: number;
|
||||||
|
missing: number;
|
||||||
|
failed: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyDiscoverPanelProps = {
|
||||||
|
discovering: boolean;
|
||||||
|
discoverProgress: DiscoverProgress;
|
||||||
|
discoverPct: number;
|
||||||
|
discoverReport: TopologyDiscoverOut | null;
|
||||||
|
discoverError: string;
|
||||||
|
hasResults: boolean;
|
||||||
|
discoverSummary: DiscoverSummary;
|
||||||
|
onCancelDiscover: () => void;
|
||||||
|
onClose: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopologyDiscoverPanel({
|
||||||
|
discovering,
|
||||||
|
discoverProgress,
|
||||||
|
discoverPct,
|
||||||
|
discoverReport,
|
||||||
|
discoverError,
|
||||||
|
hasResults,
|
||||||
|
discoverSummary,
|
||||||
|
onCancelDiscover,
|
||||||
|
onClose,
|
||||||
|
}: TopologyDiscoverPanelProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="topo-discover">
|
||||||
|
<div className="topo-discover__head">
|
||||||
|
<strong>{t("topology.discoverReport")}</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => {
|
||||||
|
if (discovering) {
|
||||||
|
void onCancelDiscover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{discovering ? t("topology.discoverCancel") : t("topology.discoverClose")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{discovering && discoverProgress.total > 0 ? (
|
||||||
|
<div className="topo-discover__progress" aria-live="polite">
|
||||||
|
<div className="topo-discover__bar">
|
||||||
|
<div className="topo-discover__bar-fill" style={{ width: `${discoverPct}%` }} />
|
||||||
|
</div>
|
||||||
|
<div className="topo-discover__progress-meta">
|
||||||
|
<span>
|
||||||
|
{t("topology.discoverProgressLive")
|
||||||
|
.replace("{{index}}", String(discoverProgress.index))
|
||||||
|
.replace("{{total}}", String(discoverProgress.total))
|
||||||
|
.replace("{{name}}", discoverProgress.neName || discoverProgress.neIp || "?")}
|
||||||
|
</span>
|
||||||
|
<span>{discoverPct}%</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{discovering && discoverProgress.total <= 0 ? (
|
||||||
|
<p className="panel__hint panel__hint--live">{t("topology.discoverNoTargets")}</p>
|
||||||
|
) : null}
|
||||||
|
{!discovering && discoverReport && discoverReport.scanned === 0 ? (
|
||||||
|
<p className="panel__hint">{t("topology.discoverNoTargets")}</p>
|
||||||
|
) : null}
|
||||||
|
{discoverError ? <p className="topo-discover__error">{discoverError}</p> : null}
|
||||||
|
{hasResults ? (
|
||||||
|
<>
|
||||||
|
<p className="topo-discover__summary">
|
||||||
|
{t("topology.discoverSummary")
|
||||||
|
.replace("{{scanned}}", String(discoverSummary.scanned))
|
||||||
|
.replace("{{added}}", String(discoverSummary.added))
|
||||||
|
.replace("{{updated}}", String(discoverSummary.updated))
|
||||||
|
.replace("{{stale}}", String(discoverSummary.missing))
|
||||||
|
.replace("{{failed}}", String(discoverSummary.failed))}
|
||||||
|
</p>
|
||||||
|
<p className="panel__hint">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => {
|
||||||
|
const jobId = String(discoverReport?.job_id || "").trim();
|
||||||
|
const qs = jobId ? `?job_id=${encodeURIComponent(jobId)}` : "";
|
||||||
|
openOrFocusModule({
|
||||||
|
moduleId: "network",
|
||||||
|
path: `/network/topology/lldp${qs}`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("topology.discoverGoLldp")}
|
||||||
|
</button>
|
||||||
|
</p>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
314
web/src/pages/topology/TopologyHexBrowser.tsx
Normal file
314
web/src/pages/topology/TopologyHexBrowser.tsx
Normal file
|
|
@ -0,0 +1,314 @@
|
||||||
|
import { Fragment } from "react";
|
||||||
|
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import {
|
||||||
|
displayViewName,
|
||||||
|
folderNeCount,
|
||||||
|
formatNeCount,
|
||||||
|
isUmeWorldContainer,
|
||||||
|
regionDisplayName,
|
||||||
|
} from "./treeUtils";
|
||||||
|
import { LayerGlyph, RegionGlyph } from "./topologyIcons";
|
||||||
|
|
||||||
|
export type UmeWorldHexModules = {
|
||||||
|
drill: TopologyTreeFolderItem | null;
|
||||||
|
flatView: TopologyTreeViewItem | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyHexBrowserProps = {
|
||||||
|
titleText: string;
|
||||||
|
breadcrumbFolders: TopologyTreeFolderItem[];
|
||||||
|
hexBrowseRegion: TopologyTreeFolderItem | null;
|
||||||
|
umeWorldHexModules: UmeWorldHexModules | null;
|
||||||
|
browseEntries: TopologyTreeViewItem[];
|
||||||
|
regions: TopologyTreeFolderItem[];
|
||||||
|
rootFolderId: string;
|
||||||
|
selectedFolderId: string;
|
||||||
|
mapId: string;
|
||||||
|
hotBrowseKey: string;
|
||||||
|
treeLoading: boolean;
|
||||||
|
treeFailed: boolean;
|
||||||
|
treeError: unknown;
|
||||||
|
createRegionPending: boolean;
|
||||||
|
onTreeRetry: () => void;
|
||||||
|
onHotBrowseKey: (key: string) => void;
|
||||||
|
onClearHotBrowseKey: (key: string) => void;
|
||||||
|
goRoot: () => void;
|
||||||
|
goRegion: (folderId: string) => void;
|
||||||
|
goUmeWorldNav: () => void;
|
||||||
|
goCanvas: (viewId: string, folderId: string) => void;
|
||||||
|
promptNewRegion: () => void;
|
||||||
|
promptNewSubRegion: () => void;
|
||||||
|
onSelectFolder: (folderId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopologyHexBrowser({
|
||||||
|
titleText,
|
||||||
|
breadcrumbFolders,
|
||||||
|
hexBrowseRegion,
|
||||||
|
umeWorldHexModules,
|
||||||
|
browseEntries,
|
||||||
|
regions,
|
||||||
|
rootFolderId,
|
||||||
|
selectedFolderId,
|
||||||
|
mapId,
|
||||||
|
hotBrowseKey,
|
||||||
|
treeLoading,
|
||||||
|
treeFailed,
|
||||||
|
treeError,
|
||||||
|
createRegionPending,
|
||||||
|
onTreeRetry,
|
||||||
|
onHotBrowseKey,
|
||||||
|
onClearHotBrowseKey,
|
||||||
|
goRoot,
|
||||||
|
goRegion,
|
||||||
|
goUmeWorldNav,
|
||||||
|
goCanvas,
|
||||||
|
promptNewRegion,
|
||||||
|
promptNewSubRegion,
|
||||||
|
onSelectFolder,
|
||||||
|
}: TopologyHexBrowserProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const regionCountLabel = hexBrowseRegion
|
||||||
|
? isUmeWorldContainer(hexBrowseRegion) && umeWorldHexModules
|
||||||
|
? t("topology.browserRegionSub").replace(
|
||||||
|
"{{count}}",
|
||||||
|
String((umeWorldHexModules.drill ? 1 : 0) + (umeWorldHexModules.flatView ? 1 : 0)),
|
||||||
|
)
|
||||||
|
: t("topology.browserRegionSub").replace("{{count}}", String(browseEntries.length))
|
||||||
|
: t("topology.browserRegionsSub").replace("{{count}}", String(regions.length));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="topo-browser" aria-label={t("topology.browserTitle")}>
|
||||||
|
<div className="topo-browser__head">
|
||||||
|
<div>
|
||||||
|
{hexBrowseRegion ? (
|
||||||
|
<div className="topo-breadcrumb">
|
||||||
|
<button type="button" className="topo-breadcrumb__link" onClick={() => goRoot()}>
|
||||||
|
{t("topology.rootName")}
|
||||||
|
</button>
|
||||||
|
{breadcrumbFolders.map((folder, idx) => {
|
||||||
|
const isLast = idx === breadcrumbFolders.length - 1;
|
||||||
|
return (
|
||||||
|
<Fragment key={folder.id}>
|
||||||
|
<span className="topo-breadcrumb__sep">/</span>
|
||||||
|
{isLast ? (
|
||||||
|
<span className="topo-breadcrumb__current">{regionDisplayName(folder, t)}</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-breadcrumb__link"
|
||||||
|
onClick={() => goRegion(folder.id)}
|
||||||
|
>
|
||||||
|
{regionDisplayName(folder, t)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<strong>{titleText}</strong>
|
||||||
|
)}
|
||||||
|
<p className="topo-browser__sub">{regionCountLabel}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{!hexBrowseRegion ? (
|
||||||
|
treeLoading ? (
|
||||||
|
<div className="topo-browser__empty topo-browser__empty--loading" role="status">
|
||||||
|
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||||
|
<p>{t("topology.treeLoading")}</p>
|
||||||
|
</div>
|
||||||
|
) : treeFailed ? (
|
||||||
|
<div className="topo-browser__empty">
|
||||||
|
<p>{t("topology.treeLoadFailed")}</p>
|
||||||
|
{treeError ? <span className="muted"> ({String(treeError)})</span> : null}
|
||||||
|
<div className="topo-browser__empty-actions">
|
||||||
|
<button type="button" className="btn btn--sm btn--ghost" onClick={onTreeRetry}>
|
||||||
|
{t("topology.treeRetry")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
onClick={promptNewRegion}
|
||||||
|
disabled={createRegionPending}
|
||||||
|
>
|
||||||
|
{t("topology.newRegion")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : regions.length === 0 ? (
|
||||||
|
<div className="topo-browser__empty">
|
||||||
|
<span className="topo-browser__empty-icon" aria-hidden="true">
|
||||||
|
<RegionGlyph size={36} />
|
||||||
|
</span>
|
||||||
|
<p>{t("topology.emptyMaps")}</p>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
onClick={promptNewRegion}
|
||||||
|
disabled={createRegionPending}
|
||||||
|
>
|
||||||
|
{t("topology.newRegion")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="topo-browser__grid topo-browser__grid--regions">
|
||||||
|
{regions.map((region, idx) => (
|
||||||
|
<button
|
||||||
|
key={region.id}
|
||||||
|
type="button"
|
||||||
|
className={`topo-region-hex topo-region-hex--tone-${idx % 5}${
|
||||||
|
selectedFolderId === region.id ? " is-selected" : ""
|
||||||
|
}${hotBrowseKey === `region:${region.id}` ? " is-hot" : ""}`}
|
||||||
|
onMouseEnter={() => onHotBrowseKey(`region:${region.id}`)}
|
||||||
|
onMouseLeave={() => onClearHotBrowseKey(`region:${region.id}`)}
|
||||||
|
onClick={() =>
|
||||||
|
isUmeWorldContainer(region) ? goUmeWorldNav() : goRegion(region.id)
|
||||||
|
}
|
||||||
|
title={t("topology.openRegion")}
|
||||||
|
>
|
||||||
|
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||||
|
<RegionGlyph size={22} />
|
||||||
|
</span>
|
||||||
|
<span className="topo-region-hex__title">
|
||||||
|
<span className="topo-region-hex__name">{regionDisplayName(region, t)}</span>
|
||||||
|
<span className="topo-region-hex__meta">{formatNeCount(folderNeCount(region))}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-region-hex topo-region-hex--add"
|
||||||
|
onClick={promptNewRegion}
|
||||||
|
disabled={createRegionPending}
|
||||||
|
title={t("topology.newRegion")}
|
||||||
|
>
|
||||||
|
<span className="topo-region-hex__plus" aria-hidden="true">
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
<span className="topo-region-hex__name">{t("topology.newRegion")}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
) : isUmeWorldContainer(hexBrowseRegion) && umeWorldHexModules ? (
|
||||||
|
<div className="topo-browser__grid topo-browser__grid--regions">
|
||||||
|
{umeWorldHexModules.drill ? (
|
||||||
|
<button
|
||||||
|
key={umeWorldHexModules.drill.id}
|
||||||
|
type="button"
|
||||||
|
className={`topo-region-hex topo-region-hex--tone-0${
|
||||||
|
hotBrowseKey === `region:${umeWorldHexModules.drill.id}` ? " is-hot" : ""
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => onHotBrowseKey(`region:${umeWorldHexModules.drill!.id}`)}
|
||||||
|
onMouseLeave={() => onClearHotBrowseKey(`region:${umeWorldHexModules.drill!.id}`)}
|
||||||
|
onClick={() => goRegion(umeWorldHexModules.drill!.id)}
|
||||||
|
title={t("topology.openRegion")}
|
||||||
|
>
|
||||||
|
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||||
|
<RegionGlyph size={22} />
|
||||||
|
</span>
|
||||||
|
<span className="topo-region-hex__title">
|
||||||
|
<span className="topo-region-hex__name">
|
||||||
|
{regionDisplayName(umeWorldHexModules.drill, t)}
|
||||||
|
</span>
|
||||||
|
<span className="topo-region-hex__meta">
|
||||||
|
{formatNeCount(folderNeCount(umeWorldHexModules.drill))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{umeWorldHexModules.flatView ? (
|
||||||
|
<button
|
||||||
|
key={umeWorldHexModules.flatView.id}
|
||||||
|
type="button"
|
||||||
|
className={`topo-region-hex topo-region-hex--tone-1${
|
||||||
|
hotBrowseKey === `view:${umeWorldHexModules.flatView.id}` ? " is-hot" : ""
|
||||||
|
}`}
|
||||||
|
onMouseEnter={() => onHotBrowseKey(`view:${umeWorldHexModules.flatView!.id}`)}
|
||||||
|
onMouseLeave={() => onClearHotBrowseKey(`view:${umeWorldHexModules.flatView!.id}`)}
|
||||||
|
onClick={() => goCanvas(umeWorldHexModules.flatView!.id, hexBrowseRegion.id)}
|
||||||
|
title={t("topology.openMap")}
|
||||||
|
>
|
||||||
|
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||||
|
<RegionGlyph size={22} />
|
||||||
|
</span>
|
||||||
|
<span className="topo-region-hex__title">
|
||||||
|
<span className="topo-region-hex__name">{umeWorldHexModules.flatView.name}</span>
|
||||||
|
<span className="topo-region-hex__meta">
|
||||||
|
{formatNeCount(umeWorldHexModules.flatView.node_count)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="topo-browser__grid topo-browser__grid--regions">
|
||||||
|
{(hexBrowseRegion.children || []).map((region, idx) => (
|
||||||
|
<button
|
||||||
|
key={region.id}
|
||||||
|
type="button"
|
||||||
|
className={`topo-region-hex topo-region-hex--tone-${idx % 5}${
|
||||||
|
selectedFolderId === region.id ? " is-selected" : ""
|
||||||
|
}${hotBrowseKey === `region:${region.id}` ? " is-hot" : ""}`}
|
||||||
|
onMouseEnter={() => onHotBrowseKey(`region:${region.id}`)}
|
||||||
|
onMouseLeave={() => onClearHotBrowseKey(`region:${region.id}`)}
|
||||||
|
onClick={() => goRegion(region.id)}
|
||||||
|
title={t("topology.openRegion")}
|
||||||
|
>
|
||||||
|
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||||
|
<RegionGlyph size={22} />
|
||||||
|
</span>
|
||||||
|
<span className="topo-region-hex__title">
|
||||||
|
<span className="topo-region-hex__name">{regionDisplayName(region, t)}</span>
|
||||||
|
<span className="topo-region-hex__meta">{formatNeCount(folderNeCount(region))}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
{browseEntries.map((v, idx) => {
|
||||||
|
const isPhysical = String(v.kind) === "physical";
|
||||||
|
const tone = isPhysical ? "physical" : String((idx + 1) % 5);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={v.id}
|
||||||
|
type="button"
|
||||||
|
className={`topo-region-hex topo-region-hex--tone-${tone}${
|
||||||
|
mapId === v.id ? " is-selected" : ""
|
||||||
|
}${hotBrowseKey === `view:${v.id}` ? " is-hot" : ""}`}
|
||||||
|
onMouseEnter={() => onHotBrowseKey(`view:${v.id}`)}
|
||||||
|
onMouseLeave={() => onClearHotBrowseKey(`view:${v.id}`)}
|
||||||
|
onClick={() => goCanvas(v.id, hexBrowseRegion.id)}
|
||||||
|
title={t("topology.openMap")}
|
||||||
|
>
|
||||||
|
<span className="topo-region-hex__icon" aria-hidden="true">
|
||||||
|
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={22} />
|
||||||
|
</span>
|
||||||
|
<span className="topo-region-hex__title">
|
||||||
|
<span className="topo-region-hex__name">{displayViewName(v.name, t)}</span>
|
||||||
|
<span className="topo-region-hex__meta">{formatNeCount(v.node_count)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{String(hexBrowseRegion.parent_id || "") !== rootFolderId ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-region-hex topo-region-hex--add"
|
||||||
|
onClick={() => {
|
||||||
|
onSelectFolder(hexBrowseRegion.id);
|
||||||
|
promptNewSubRegion();
|
||||||
|
}}
|
||||||
|
title={t("topology.newSubRegion")}
|
||||||
|
>
|
||||||
|
<span className="topo-region-hex__plus" aria-hidden="true">
|
||||||
|
+
|
||||||
|
</span>
|
||||||
|
<span className="topo-region-hex__name">{t("topology.newSubRegion")}</span>
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
545
web/src/pages/topology/TopologyModals.tsx
Normal file
545
web/src/pages/topology/TopologyModals.tsx
Normal file
|
|
@ -0,0 +1,545 @@
|
||||||
|
import type { DragEvent } from "react";
|
||||||
|
import type { TopologyOutsidePeer } from "../../types";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import type { PaletteItem, PaletteSource } from "./pageTypes";
|
||||||
|
|
||||||
|
export type CreateNeDialogState = {
|
||||||
|
flowX: number;
|
||||||
|
flowY: number;
|
||||||
|
name: string;
|
||||||
|
ip_address: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type TopologyModalsProps = {
|
||||||
|
canvasMode: boolean;
|
||||||
|
newRootDialog: { name: string } | null;
|
||||||
|
onNewRootNameChange: (name: string) => void;
|
||||||
|
onCloseNewRoot: () => void;
|
||||||
|
onSubmitNewRoot: () => void;
|
||||||
|
createRegionPending: boolean;
|
||||||
|
createNeDialog: CreateNeDialogState | null;
|
||||||
|
createNeBusy: boolean;
|
||||||
|
onCreateNeChange: (patch: Partial<Pick<CreateNeDialogState, "name" | "ip_address">>) => void;
|
||||||
|
onCloseCreateNe: () => void;
|
||||||
|
onSubmitCreateNe: () => void;
|
||||||
|
outsidePeersOpen: 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;
|
||||||
|
onCloseOutsidePeers: () => void;
|
||||||
|
onAddOutsidePeers: (ids: string[]) => void;
|
||||||
|
addNeOpen: 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;
|
||||||
|
onCloseAddNe: () => void;
|
||||||
|
onPaletteDragStart: (e: DragEvent, item: PaletteItem) => void;
|
||||||
|
onAddSelectedPalette: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopologyModals({
|
||||||
|
canvasMode,
|
||||||
|
newRootDialog,
|
||||||
|
onNewRootNameChange,
|
||||||
|
onCloseNewRoot,
|
||||||
|
onSubmitNewRoot,
|
||||||
|
createRegionPending,
|
||||||
|
createNeDialog,
|
||||||
|
createNeBusy,
|
||||||
|
onCreateNeChange,
|
||||||
|
onCloseCreateNe,
|
||||||
|
onSubmitCreateNe,
|
||||||
|
outsidePeersOpen,
|
||||||
|
outsidePeers,
|
||||||
|
outsidePeersVisible,
|
||||||
|
outsidePeerQuery,
|
||||||
|
onOutsidePeerQueryChange,
|
||||||
|
outsidePeerSelectedIds,
|
||||||
|
onOutsidePeerSelectedIdsChange,
|
||||||
|
outsidePeerNameById,
|
||||||
|
outsidePeersAdding,
|
||||||
|
onCloseOutsidePeers,
|
||||||
|
onAddOutsidePeers,
|
||||||
|
addNeOpen,
|
||||||
|
paletteSource,
|
||||||
|
onPaletteSourceChange,
|
||||||
|
keyword,
|
||||||
|
onKeywordChange,
|
||||||
|
paletteVisible,
|
||||||
|
paletteSelectedKeys,
|
||||||
|
onPaletteSelectedKeysChange,
|
||||||
|
paletteLoading,
|
||||||
|
paletteAdding,
|
||||||
|
onCloseAddNe,
|
||||||
|
onPaletteDragStart,
|
||||||
|
onAddSelectedPalette,
|
||||||
|
}: TopologyModalsProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{newRootDialog ? (
|
||||||
|
<div
|
||||||
|
className="topo-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="topo-new-root-title"
|
||||||
|
>
|
||||||
|
<div className="topo-modal__backdrop" onClick={onCloseNewRoot} />
|
||||||
|
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
|
||||||
|
<div className="topo-modal__head">
|
||||||
|
<strong id="topo-new-root-title">{t("topology.newRegion")}</strong>
|
||||||
|
<button type="button" className="btn btn--sm btn--ghost" onClick={onCloseNewRoot}>
|
||||||
|
{t("topology.discoverClose")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="panel__hint topo-modal__hint">{t("topology.folderHint")}</p>
|
||||||
|
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
|
||||||
|
<label>
|
||||||
|
<span className="form-label">{t("topology.newRegionPrompt")}</span>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={newRootDialog.name}
|
||||||
|
disabled={createRegionPending}
|
||||||
|
onChange={(e) => onNewRootNameChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") onSubmitNewRoot();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="topo-modal__foot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={createRegionPending}
|
||||||
|
onClick={onCloseNewRoot}
|
||||||
|
>
|
||||||
|
{t("topology.discoverClose")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={createRegionPending || !String(newRootDialog.name || "").trim()}
|
||||||
|
onClick={onSubmitNewRoot}
|
||||||
|
>
|
||||||
|
{createRegionPending ? "…" : t("topology.newRegion")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{createNeDialog && canvasMode ? (
|
||||||
|
<div
|
||||||
|
className="topo-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="topo-create-ne-title"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="topo-modal__backdrop"
|
||||||
|
onClick={() => {
|
||||||
|
if (createNeBusy) return;
|
||||||
|
onCloseCreateNe();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="topo-modal__panel" style={{ maxWidth: 420 }}>
|
||||||
|
<div className="topo-modal__head">
|
||||||
|
<strong id="topo-create-ne-title">{t("topology.createNeTitle")}</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={createNeBusy}
|
||||||
|
onClick={onCloseCreateNe}
|
||||||
|
>
|
||||||
|
{t("topology.discoverClose")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="panel__hint topo-modal__hint">{t("topology.createNeHint")}</p>
|
||||||
|
<div className="form-grid" style={{ padding: "0 16px 8px" }}>
|
||||||
|
<label>
|
||||||
|
<span className="form-label">
|
||||||
|
{t("topology.createNeName")}
|
||||||
|
<span className="form-label__required" aria-hidden="true">
|
||||||
|
{" "}
|
||||||
|
*
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
autoFocus
|
||||||
|
value={createNeDialog.name}
|
||||||
|
placeholder={t("topology.createNeNamePh")}
|
||||||
|
disabled={createNeBusy}
|
||||||
|
onChange={(e) => onCreateNeChange({ name: e.target.value })}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "Enter") void onSubmitCreateNe();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</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">
|
||||||
|
<div className="topo-modal__head">
|
||||||
|
<strong>
|
||||||
|
{t("topology.outsidePeersTitle")}
|
||||||
|
<span className="topo-modal__count"> · {outsidePeers.length}</span>
|
||||||
|
</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={outsidePeersAdding}
|
||||||
|
onClick={onCloseOutsidePeers}
|
||||||
|
>
|
||||||
|
{t("topology.discoverClose")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="panel__hint topo-modal__hint">{t("topology.outsidePeersHint")}</p>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={outsidePeerQuery}
|
||||||
|
onChange={(e) => onOutsidePeerQueryChange(e.target.value)}
|
||||||
|
placeholder={t("topology.outsidePeersFilterPh")}
|
||||||
|
disabled={outsidePeersAdding}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{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">
|
||||||
|
<div className="topo-modal__head">
|
||||||
|
<strong>{t("topology.addNe")}</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={paletteAdding}
|
||||||
|
onClick={onCloseAddNe}
|
||||||
|
>
|
||||||
|
{t("topology.discoverClose")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<p className="panel__hint topo-modal__hint">{t("topology.paletteHint")}</p>
|
||||||
|
<div className="topo-palette-source" role="tablist">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={paletteSource === "managed"}
|
||||||
|
className={`topo-palette-source__btn${paletteSource === "managed" ? " is-active" : ""}`}
|
||||||
|
disabled={paletteAdding}
|
||||||
|
onClick={() => onPaletteSourceChange("managed")}
|
||||||
|
>
|
||||||
|
{t("topology.paletteManaged")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
aria-selected={paletteSource === "ume"}
|
||||||
|
className={`topo-palette-source__btn${paletteSource === "ume" ? " is-active" : ""}`}
|
||||||
|
disabled={paletteAdding}
|
||||||
|
onClick={() => onPaletteSourceChange("ume")}
|
||||||
|
>
|
||||||
|
{t("topology.paletteUme")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
value={keyword}
|
||||||
|
onChange={(e) => onKeywordChange(e.target.value)}
|
||||||
|
placeholder={t("topology.filterPh")}
|
||||||
|
disabled={paletteAdding}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
{paletteVisible.length > 0 ? (
|
||||||
|
<div className="topo-modal__selectbar">
|
||||||
|
<label className="topo-modal__selectall">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={
|
||||||
|
paletteVisible.length > 0 &&
|
||||||
|
paletteVisible.every((item) => paletteSelectedKeys.includes(item.key))
|
||||||
|
}
|
||||||
|
disabled={paletteAdding}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked) {
|
||||||
|
onPaletteSelectedKeysChange(paletteVisible.map((item) => item.key));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
onPaletteSelectedKeysChange([]);
|
||||||
|
}}
|
||||||
|
aria-label={t("topology.selectAllVisible")}
|
||||||
|
/>
|
||||||
|
<span>{t("topology.selectAllVisible")}</span>
|
||||||
|
</label>
|
||||||
|
<span className="panel__hint">
|
||||||
|
{t("topology.selectedCount").replace(
|
||||||
|
"{{count}}",
|
||||||
|
String(paletteSelectedKeys.length),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
<ul className="topo-palette topo-modal__list">
|
||||||
|
{paletteLoading ? (
|
||||||
|
<li className="topo-palette__empty">
|
||||||
|
<span className="panel__hint">{t("topology.paletteLoading")}</span>
|
||||||
|
</li>
|
||||||
|
) : paletteVisible.length === 0 ? (
|
||||||
|
<li className="topo-palette__empty">
|
||||||
|
<span className="panel__hint">{t("topology.paletteEmpty")}</span>
|
||||||
|
</li>
|
||||||
|
) : (
|
||||||
|
paletteVisible.map((item) => {
|
||||||
|
const checked = paletteSelectedKeys.includes(item.key);
|
||||||
|
return (
|
||||||
|
<li key={item.key}>
|
||||||
|
<div className={`topo-palette__row${checked ? " is-selected" : ""}`}>
|
||||||
|
<label className="topo-palette__check">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
disabled={paletteAdding}
|
||||||
|
onChange={(e) => {
|
||||||
|
onPaletteSelectedKeysChange((prev) =>
|
||||||
|
e.target.checked
|
||||||
|
? [...new Set([...prev, item.key])]
|
||||||
|
: prev.filter((key) => key !== item.key),
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
aria-label={item.name}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-palette__item"
|
||||||
|
draggable={!paletteAdding}
|
||||||
|
disabled={paletteAdding}
|
||||||
|
onClick={() => {
|
||||||
|
onPaletteSelectedKeysChange((prev) =>
|
||||||
|
prev.includes(item.key)
|
||||||
|
? prev.filter((key) => key !== item.key)
|
||||||
|
: [...prev, item.key],
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
onDragStart={(e) => onPaletteDragStart(e, item)}
|
||||||
|
title={t("topology.paletteDragHint")}
|
||||||
|
>
|
||||||
|
<span className="topo-palette__name">{item.name}</span>
|
||||||
|
<span className="topo-palette__meta">{item.meta}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
<div className="topo-modal__foot">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={paletteAdding}
|
||||||
|
onClick={onCloseAddNe}
|
||||||
|
>
|
||||||
|
{t("topology.discoverClose")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={paletteSelectedKeys.length === 0 || paletteAdding}
|
||||||
|
onClick={() => void onAddSelectedPalette()}
|
||||||
|
>
|
||||||
|
{paletteAdding
|
||||||
|
? t("topology.addingNe")
|
||||||
|
: t("topology.addSelected").replace(
|
||||||
|
"{{count}}",
|
||||||
|
String(paletteSelectedKeys.length),
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -64,31 +64,8 @@ const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
||||||
|
|
||||||
const SEP = " / ";
|
const SEP = " / ";
|
||||||
|
|
||||||
function nodeIconTone(vendor: string, managedNeId: string, umeNeId = ""): string {
|
import { isPlaceholderSource } from "./searchUtils";
|
||||||
const hasManaged = Boolean(String(managedNeId || "").trim());
|
import { nodeIconTone } from "./vendorTone";
|
||||||
const hasUme = Boolean(String(umeNeId || "").trim());
|
|
||||||
if (!hasManaged && !hasUme) return "gray";
|
|
||||||
const v = String(vendor || "").trim().toLowerCase();
|
|
||||||
if (!v || v === "other" || v === "unknown" || v === "generic") {
|
|
||||||
return hasUme ? "zte" : "gray";
|
|
||||||
}
|
|
||||||
if (v.includes("cisco")) return "cisco";
|
|
||||||
if (v.includes("huawei")) return "huawei";
|
|
||||||
if (v.includes("zte")) return "zte";
|
|
||||||
if (v.includes("juniper")) return "juniper";
|
|
||||||
if (v.includes("nokia") || v.includes("alcatel")) return "nokia";
|
|
||||||
if (v.includes("ericsson")) return "ericsson";
|
|
||||||
if (v.includes("h3c") || v.includes("comware")) return "h3c";
|
|
||||||
if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie";
|
|
||||||
if (v.includes("mikrotik")) return "mikrotik";
|
|
||||||
return hasUme ? "zte" : "gray";
|
|
||||||
}
|
|
||||||
|
|
||||||
function isPlaceholderSource(source: string | undefined, neIp: string): boolean {
|
|
||||||
const src = String(source || "").trim().toLowerCase();
|
|
||||||
if (src === "lldp" || src === "topology") return true;
|
|
||||||
return !String(neIp || "").trim() && Boolean(src);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { isPlaceholderSource };
|
export { isPlaceholderSource };
|
||||||
|
|
||||||
|
|
|
||||||
243
web/src/pages/topology/TopologySidebar.tsx
Normal file
243
web/src/pages/topology/TopologySidebar.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
||||||
|
import type { MouseEvent as ReactMouseEvent, RefObject } from "react";
|
||||||
|
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import { WORLD_MAP_ENABLED } from "./constants";
|
||||||
|
import { isWorldFlatViewName } from "./treeUtils";
|
||||||
|
import { PlusIcon, SidebarFoldIcon } from "./topologyIcons";
|
||||||
|
import {
|
||||||
|
TopologyTreeNavFolder,
|
||||||
|
type TopologyTreeNavFolderProps,
|
||||||
|
} from "./TopologyTreeNavFolder";
|
||||||
|
|
||||||
|
export type TopologySidebarProps = {
|
||||||
|
collapsed: boolean;
|
||||||
|
resizing: boolean;
|
||||||
|
treeSearchRef: RefObject<HTMLDivElement | null>;
|
||||||
|
treeNeQuery: string;
|
||||||
|
onTreeNeQueryChange: (value: string) => void;
|
||||||
|
treeSearchOpen: boolean;
|
||||||
|
onTreeSearchOpen: (open: boolean) => void;
|
||||||
|
debouncedTreeNeQuery: string;
|
||||||
|
treeSearchFetching: boolean;
|
||||||
|
treeSearchItems: FabricNodeSearchHit[];
|
||||||
|
treeSearchTotal: number;
|
||||||
|
onJumpToSearchHit: (
|
||||||
|
hit: FabricNodeSearchHit,
|
||||||
|
view?: NonNullable<FabricNodeSearchHit["views"]>[number],
|
||||||
|
) => void;
|
||||||
|
promptNewRegion: () => void;
|
||||||
|
createRegionPending: boolean;
|
||||||
|
onCollapse: () => void;
|
||||||
|
onExpand: () => void;
|
||||||
|
treeRoot: TopologyTreeFolderItem | null | undefined;
|
||||||
|
treeLoading: boolean;
|
||||||
|
treeFailed: boolean;
|
||||||
|
treeError: unknown;
|
||||||
|
onTreeRetry: () => void;
|
||||||
|
regions: TopologyTreeFolderItem[];
|
||||||
|
treeNav: Omit<TopologyTreeNavFolderProps, "folder" | "depth">;
|
||||||
|
onResizeDown: (e: ReactMouseEvent<HTMLDivElement>) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopologySidebar({
|
||||||
|
collapsed,
|
||||||
|
resizing,
|
||||||
|
treeSearchRef,
|
||||||
|
treeNeQuery,
|
||||||
|
onTreeNeQueryChange,
|
||||||
|
treeSearchOpen,
|
||||||
|
onTreeSearchOpen,
|
||||||
|
debouncedTreeNeQuery,
|
||||||
|
treeSearchFetching,
|
||||||
|
treeSearchItems,
|
||||||
|
treeSearchTotal,
|
||||||
|
onJumpToSearchHit,
|
||||||
|
promptNewRegion,
|
||||||
|
createRegionPending,
|
||||||
|
onCollapse,
|
||||||
|
onExpand,
|
||||||
|
treeRoot,
|
||||||
|
treeLoading,
|
||||||
|
treeFailed,
|
||||||
|
treeError,
|
||||||
|
onTreeRetry,
|
||||||
|
regions,
|
||||||
|
treeNav,
|
||||||
|
onResizeDown,
|
||||||
|
}: TopologySidebarProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="topo-sidebar" aria-label={t("topology.maps")}>
|
||||||
|
{collapsed ? (
|
||||||
|
<div className="topo-sidebar__rail-wrap">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-sidebar__rail"
|
||||||
|
title={t("topology.expandSidebar")}
|
||||||
|
aria-label={t("topology.expandSidebar")}
|
||||||
|
onClick={onExpand}
|
||||||
|
>
|
||||||
|
<span className="topo-sidebar__rail-icon" aria-hidden="true">
|
||||||
|
<SidebarFoldIcon expand />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="topo-sidebar__section">
|
||||||
|
<div className="topo-tree-search" ref={treeSearchRef}>
|
||||||
|
<div className="topo-tree-search__bar">
|
||||||
|
<input
|
||||||
|
className="input"
|
||||||
|
type="search"
|
||||||
|
value={treeNeQuery}
|
||||||
|
placeholder={t("topology.treeSearchPh")}
|
||||||
|
aria-label={t("topology.treeSearch")}
|
||||||
|
onChange={(e) => {
|
||||||
|
onTreeNeQueryChange(e.target.value);
|
||||||
|
onTreeSearchOpen(true);
|
||||||
|
}}
|
||||||
|
onFocus={() => onTreeSearchOpen(true)}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-sidebar__icon-btn"
|
||||||
|
onClick={promptNewRegion}
|
||||||
|
disabled={createRegionPending}
|
||||||
|
title={t("topology.newRegion")}
|
||||||
|
aria-label={t("topology.newRegion")}
|
||||||
|
>
|
||||||
|
<PlusIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-sidebar__icon-btn"
|
||||||
|
title={t("topology.collapseSidebar")}
|
||||||
|
aria-label={t("topology.collapseSidebar")}
|
||||||
|
onClick={onCollapse}
|
||||||
|
>
|
||||||
|
<SidebarFoldIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{treeSearchOpen && treeNeQuery.trim() ? (
|
||||||
|
<div className="topo-tree-search__panel" role="listbox">
|
||||||
|
{debouncedTreeNeQuery.length < 1 || treeSearchFetching ? (
|
||||||
|
<p className="topo-tree-search__hint muted">…</p>
|
||||||
|
) : !treeSearchItems.length ? (
|
||||||
|
<p className="topo-tree-search__hint muted">{t("topology.treeSearchEmpty")}</p>
|
||||||
|
) : (
|
||||||
|
treeSearchItems.map((hit) => {
|
||||||
|
const views = hit.views || [];
|
||||||
|
const title = hit.name || hit.ip || hit.id.slice(0, 8);
|
||||||
|
return (
|
||||||
|
<div key={hit.id} className="topo-tree-search__item">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-tree-search__ne"
|
||||||
|
onClick={() => onJumpToSearchHit(hit)}
|
||||||
|
title={
|
||||||
|
views.length
|
||||||
|
? t("topology.locateOnCanvas")
|
||||||
|
: t("topology.treeSearchNotOnMap")
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<span className="topo-tree-search__name">{title}</span>
|
||||||
|
{hit.ip ? (
|
||||||
|
<span className="topo-tree-search__ip muted">{hit.ip}</span>
|
||||||
|
) : null}
|
||||||
|
{!views.length ? (
|
||||||
|
<span className="topo-tree-search__meta muted">
|
||||||
|
{t("topology.treeSearchNoViews")}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="topo-tree-search__meta muted">
|
||||||
|
{t("topology.treeSearchViewCount").replace(
|
||||||
|
"{{count}}",
|
||||||
|
String(views.length),
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{views.length > 0 ? (
|
||||||
|
<div className="topo-tree-search__views">
|
||||||
|
{views
|
||||||
|
.filter(
|
||||||
|
(v) => WORLD_MAP_ENABLED || !isWorldFlatViewName(v.view_name),
|
||||||
|
)
|
||||||
|
.map((v) => (
|
||||||
|
<button
|
||||||
|
key={`${hit.id}-${v.view_id}`}
|
||||||
|
type="button"
|
||||||
|
className="topo-tree-search__view"
|
||||||
|
onClick={() => onJumpToSearchHit(hit, v)}
|
||||||
|
title={`${v.folder_name ? `${v.folder_name} / ` : ""}${v.view_name}`}
|
||||||
|
>
|
||||||
|
{v.folder_name
|
||||||
|
? `${v.folder_name} / ${v.view_name || v.view_id.slice(0, 8)}`
|
||||||
|
: v.view_name || v.view_id.slice(0, 8)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
{treeSearchTotal > 30 ? (
|
||||||
|
<p className="topo-tree-search__hint muted">
|
||||||
|
{t("topology.treeSearchTruncated").replace(
|
||||||
|
"{{total}}",
|
||||||
|
String(treeSearchTotal),
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{!treeRoot ? (
|
||||||
|
<p className="panel__hint topo-tree-status" role="status">
|
||||||
|
{treeLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||||
|
{t("topology.treeLoading")}
|
||||||
|
</>
|
||||||
|
) : treeFailed ? (
|
||||||
|
<>
|
||||||
|
{t("topology.treeLoadFailed")}
|
||||||
|
{treeError ? <span className="muted"> ({String(treeError)})</span> : null}{" "}
|
||||||
|
<button type="button" className="btn btn--sm btn--ghost" onClick={onTreeRetry}>
|
||||||
|
{t("topology.treeRetry")}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t("topology.emptyMaps")
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
) : regions.length === 0 ? (
|
||||||
|
<p className="panel__hint">{t("topology.emptyMaps")}</p>
|
||||||
|
) : (
|
||||||
|
<div className="topo-region-list-scroll">
|
||||||
|
<ul className="topo-map-list topo-region-list">
|
||||||
|
{regions.map((region) => (
|
||||||
|
<TopologyTreeNavFolder key={region.id} folder={region} depth={0} {...treeNav} />
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{!collapsed ? (
|
||||||
|
<div
|
||||||
|
className={`topo-sidebar__resizer${resizing ? " is-dragging" : ""}`}
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label={t("topology.resizeSidebar")}
|
||||||
|
title={t("topology.resizeSidebar")}
|
||||||
|
onMouseDown={onResizeDown}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</aside>
|
||||||
|
);
|
||||||
|
}
|
||||||
322
web/src/pages/topology/TopologyToolbar.tsx
Normal file
322
web/src/pages/topology/TopologyToolbar.tsx
Normal file
|
|
@ -0,0 +1,322 @@
|
||||||
|
import { Fragment, type RefObject } from "react";
|
||||||
|
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
|
||||||
|
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types";
|
||||||
|
import { HelpHint } from "../../components/HelpHint";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import { displayViewName, isRegionCanvasFolder, regionDisplayName } from "./treeUtils";
|
||||||
|
import type { NeNodeData } from "./TopologyReactFlowView";
|
||||||
|
import type { ToolMode } from "./toolMode";
|
||||||
|
|
||||||
|
export type TopologyToolbarProps = {
|
||||||
|
breadcrumbFolders: TopologyTreeFolderItem[];
|
||||||
|
activeView: TopologyTreeViewItem | null | undefined;
|
||||||
|
activeRegion: TopologyTreeFolderItem | null | undefined;
|
||||||
|
rootFolderId: string;
|
||||||
|
dirty: boolean;
|
||||||
|
selectedNodes: Node<NeNodeData>[];
|
||||||
|
selectedEdgeId: string | null;
|
||||||
|
liveSync: boolean;
|
||||||
|
onLiveSyncToggle: () => void;
|
||||||
|
isWorldFlatCanvas: boolean;
|
||||||
|
onAddNe: () => void;
|
||||||
|
onCreateNe: (flowX: number, flowY: number) => void;
|
||||||
|
nodes: Node<NeNodeData>[];
|
||||||
|
rfRef: RefObject<ReactFlowInstance<Node<NeNodeData>, Edge> | null>;
|
||||||
|
onBackUp: () => void;
|
||||||
|
canUndo: boolean;
|
||||||
|
onUndo: () => void;
|
||||||
|
canRedo: boolean;
|
||||||
|
onRedo: () => void;
|
||||||
|
savePending: boolean;
|
||||||
|
onSave: () => void;
|
||||||
|
onFit: () => void;
|
||||||
|
exportMenuRef: RefObject<HTMLDetailsElement | null>;
|
||||||
|
exporting: boolean;
|
||||||
|
onExport: (kind: "svg" | "xml") => void;
|
||||||
|
mapId: string;
|
||||||
|
staleEdgeCount: number;
|
||||||
|
onRemoveStale: () => void;
|
||||||
|
toolMode: ToolMode;
|
||||||
|
onToolModeChange: (mode: ToolMode) => void;
|
||||||
|
onConnectClickReset: () => void;
|
||||||
|
fullscreen: boolean;
|
||||||
|
viewToolsToolbarSlotRef: (el: HTMLDivElement | null) => void;
|
||||||
|
outsidePeerCount: number;
|
||||||
|
onOpenOutsidePeers: () => void;
|
||||||
|
goRoot: () => void;
|
||||||
|
goRegion: (folderId: string) => void;
|
||||||
|
primaryViewOfFolder: (
|
||||||
|
folder: TopologyTreeFolderItem | null | undefined,
|
||||||
|
) => TopologyTreeViewItem | null | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopologyToolbar({
|
||||||
|
breadcrumbFolders,
|
||||||
|
activeView,
|
||||||
|
activeRegion,
|
||||||
|
rootFolderId,
|
||||||
|
dirty,
|
||||||
|
selectedNodes,
|
||||||
|
selectedEdgeId,
|
||||||
|
liveSync,
|
||||||
|
onLiveSyncToggle,
|
||||||
|
isWorldFlatCanvas,
|
||||||
|
onAddNe,
|
||||||
|
onCreateNe,
|
||||||
|
nodes,
|
||||||
|
rfRef,
|
||||||
|
onBackUp,
|
||||||
|
canUndo,
|
||||||
|
onUndo,
|
||||||
|
canRedo,
|
||||||
|
onRedo,
|
||||||
|
savePending,
|
||||||
|
onSave,
|
||||||
|
onFit,
|
||||||
|
exportMenuRef,
|
||||||
|
exporting,
|
||||||
|
onExport,
|
||||||
|
mapId,
|
||||||
|
staleEdgeCount,
|
||||||
|
onRemoveStale,
|
||||||
|
toolMode,
|
||||||
|
onToolModeChange,
|
||||||
|
onConnectClickReset,
|
||||||
|
fullscreen,
|
||||||
|
viewToolsToolbarSlotRef,
|
||||||
|
outsidePeerCount,
|
||||||
|
onOpenOutsidePeers,
|
||||||
|
goRoot,
|
||||||
|
goRegion,
|
||||||
|
primaryViewOfFolder,
|
||||||
|
}: TopologyToolbarProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const handleCreateNe = () => {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
onCreateNe(flowX, flowY);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="topo-toolbar">
|
||||||
|
<div className="topo-toolbar__row">
|
||||||
|
<div className="topo-toolbar__title">
|
||||||
|
<div className="topo-breadcrumb">
|
||||||
|
<button type="button" className="topo-breadcrumb__link" onClick={() => goRoot()}>
|
||||||
|
{t("topology.rootName")}
|
||||||
|
</button>
|
||||||
|
{breadcrumbFolders.map((folder, idx) => {
|
||||||
|
const isLast = idx === breadcrumbFolders.length - 1;
|
||||||
|
const primaryView = primaryViewOfFolder(folder);
|
||||||
|
const showExtraView =
|
||||||
|
isLast &&
|
||||||
|
Boolean(activeView) &&
|
||||||
|
!(
|
||||||
|
isRegionCanvasFolder(folder, rootFolderId) &&
|
||||||
|
primaryView?.id === activeView?.id
|
||||||
|
);
|
||||||
|
const asCurrent = isLast && !showExtraView;
|
||||||
|
return (
|
||||||
|
<Fragment key={folder.id}>
|
||||||
|
<span className="topo-breadcrumb__sep">/</span>
|
||||||
|
{asCurrent ? (
|
||||||
|
<span className="topo-breadcrumb__current">
|
||||||
|
{regionDisplayName(folder, t)}
|
||||||
|
{dirty ? " *" : ""}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-breadcrumb__link"
|
||||||
|
onClick={() => goRegion(folder.id)}
|
||||||
|
>
|
||||||
|
{regionDisplayName(folder, t)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</Fragment>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{activeView &&
|
||||||
|
!(
|
||||||
|
activeRegion &&
|
||||||
|
isRegionCanvasFolder(activeRegion, rootFolderId) &&
|
||||||
|
primaryViewOfFolder(activeRegion)?.id === activeView.id
|
||||||
|
) ? (
|
||||||
|
<>
|
||||||
|
<span className="topo-breadcrumb__sep">/</span>
|
||||||
|
<span className="topo-breadcrumb__current">
|
||||||
|
{displayViewName(activeView.name, t)}
|
||||||
|
{dirty ? " *" : ""}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{selectedNodes.length > 0 || selectedEdgeId ? (
|
||||||
|
<span className="topo-toolbar__meta">
|
||||||
|
{selectedNodes.length > 0
|
||||||
|
? t("topology.selectedCount").replace("{{count}}", String(selectedNodes.length))
|
||||||
|
: t("topology.selectedEdge")}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
<HelpHint text={t("topology.canvasHint")} ariaLabel={t("common.help")} />
|
||||||
|
</div>
|
||||||
|
<div className="topo-toolbar__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn--sm${liveSync ? "" : " btn--ghost"}`}
|
||||||
|
aria-pressed={liveSync}
|
||||||
|
title={t("topology.liveSyncHint")}
|
||||||
|
onClick={onLiveSyncToggle}
|
||||||
|
>
|
||||||
|
{liveSync ? t("topology.liveSyncOn") : t("topology.liveSync")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={isWorldFlatCanvas}
|
||||||
|
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
|
||||||
|
onClick={onAddNe}
|
||||||
|
>
|
||||||
|
{t("topology.addNe")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm"
|
||||||
|
disabled={isWorldFlatCanvas}
|
||||||
|
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
|
||||||
|
onClick={handleCreateNe}
|
||||||
|
>
|
||||||
|
{t("topology.createNe")}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn--sm btn--ghost" onClick={onBackUp}>
|
||||||
|
{t("topology.backUp")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={!canUndo}
|
||||||
|
onClick={onUndo}
|
||||||
|
title="Ctrl+Z"
|
||||||
|
>
|
||||||
|
{t("topology.undo")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={!canRedo}
|
||||||
|
onClick={onRedo}
|
||||||
|
title="Ctrl+Y"
|
||||||
|
>
|
||||||
|
{t("topology.redo")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`btn btn--sm${dirty ? "" : " btn--ghost"}`}
|
||||||
|
disabled={savePending || !dirty}
|
||||||
|
onClick={onSave}
|
||||||
|
title="Ctrl+S"
|
||||||
|
>
|
||||||
|
{savePending ? t("topology.saving") : dirty ? t("topology.saveDirty") : t("topology.save")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={!isWorldFlatCanvas && nodes.length === 0}
|
||||||
|
onClick={onFit}
|
||||||
|
>
|
||||||
|
{t("topology.fit")}
|
||||||
|
</button>
|
||||||
|
<details className="topo-toolbar__export" ref={exportMenuRef}>
|
||||||
|
<summary>{exporting ? t("topology.exporting") : t("topology.export")}</summary>
|
||||||
|
<div className="topo-export-menu" role="menu" aria-label={t("topology.export")}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-export-menu__btn"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={exporting || (!isWorldFlatCanvas && nodes.length === 0)}
|
||||||
|
onClick={() => void onExport("svg")}
|
||||||
|
>
|
||||||
|
{t("topology.exportSvg")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-export-menu__btn"
|
||||||
|
role="menuitem"
|
||||||
|
disabled={exporting || !mapId}
|
||||||
|
onClick={() => void onExport("xml")}
|
||||||
|
>
|
||||||
|
{t("topology.exportXml")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
disabled={!staleEdgeCount}
|
||||||
|
title={t("topology.removeStaleHint")}
|
||||||
|
onClick={() => void onRemoveStale()}
|
||||||
|
>
|
||||||
|
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="topo-toolbar__row topo-toolbar__row--tools">
|
||||||
|
<div className="topo-tools" role="toolbar" aria-label={t("topology.toolModes")}>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["select", t("topology.toolSelect"), "V"],
|
||||||
|
["pan", t("topology.toolPan"), "H"],
|
||||||
|
["connect", t("topology.toolConnect"), "C"],
|
||||||
|
] as const
|
||||||
|
).map(([mode, label, key]) => (
|
||||||
|
<button
|
||||||
|
key={mode}
|
||||||
|
type="button"
|
||||||
|
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`}
|
||||||
|
title={`${label} (${key})`}
|
||||||
|
aria-pressed={toolMode === mode}
|
||||||
|
onClick={() => {
|
||||||
|
onToolModeChange(mode);
|
||||||
|
onConnectClickReset();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="topo-tools__label">{label}</span>
|
||||||
|
<kbd className="topo-tools__key">{key}</kbd>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{!fullscreen ? (
|
||||||
|
<div
|
||||||
|
className="topo-toolbar__cluster"
|
||||||
|
role="toolbar"
|
||||||
|
aria-label={t("topology.display")}
|
||||||
|
ref={viewToolsToolbarSlotRef}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{outsidePeerCount > 0 ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost topo-toolbar__outside-peers"
|
||||||
|
title={t("topology.outsidePeers").replace("{{count}}", String(outsidePeerCount))}
|
||||||
|
onClick={onOpenOutsidePeers}
|
||||||
|
>
|
||||||
|
{t("topology.outsidePeersView").replace("{{count}}", String(outsidePeerCount))}
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
393
web/src/pages/topology/TopologyTreeNavFolder.tsx
Normal file
393
web/src/pages/topology/TopologyTreeNavFolder.tsx
Normal file
|
|
@ -0,0 +1,393 @@
|
||||||
|
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../types";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import { WORLD_MAP_ENABLED } from "./constants";
|
||||||
|
import {
|
||||||
|
displayViewName,
|
||||||
|
folderNeCount,
|
||||||
|
formatNeCount,
|
||||||
|
isManualRootMapFolder,
|
||||||
|
isRegionCanvasFolder,
|
||||||
|
isUmeStructuralFolder,
|
||||||
|
isUmeSyncedSubRegion,
|
||||||
|
isUmeWorldNavFolder,
|
||||||
|
isWorldDrillFolder,
|
||||||
|
isWorldFlatViewName,
|
||||||
|
regionDisplayName,
|
||||||
|
} from "./treeUtils";
|
||||||
|
import {
|
||||||
|
ChevronIcon,
|
||||||
|
CloseIcon,
|
||||||
|
LayerGlyph,
|
||||||
|
PencilIcon,
|
||||||
|
RegionGlyph,
|
||||||
|
} from "./topologyIcons";
|
||||||
|
|
||||||
|
export type TopologyTreeNavFolderProps = {
|
||||||
|
folder: TopologyTreeFolderItem;
|
||||||
|
depth: number;
|
||||||
|
treeRootId: string;
|
||||||
|
expandedIds: Record<string, boolean>;
|
||||||
|
mapId: string;
|
||||||
|
worldViewId: string;
|
||||||
|
selectedFolderId: string;
|
||||||
|
hotBrowseKey: string;
|
||||||
|
dirty: boolean;
|
||||||
|
onToggleExpand: (folderId: string, nextOpen: boolean) => void;
|
||||||
|
onHotBrowseKey: (key: string) => void;
|
||||||
|
onClearHotBrowseKey: (key: string) => void;
|
||||||
|
onWorldFocusClear: () => void;
|
||||||
|
goUmeWorldNav: () => void;
|
||||||
|
goRegion: (folderId: string) => void;
|
||||||
|
goCanvas: (viewId: string, folderId: string) => void;
|
||||||
|
promptRenameRegion: (folderId: string, name: string) => void;
|
||||||
|
promptRenameMap: (viewId: string, name: string) => void;
|
||||||
|
renameRegionPending: boolean;
|
||||||
|
renameMapPending: boolean;
|
||||||
|
onDeleteFolder: (folderId: string) => void;
|
||||||
|
deleteFolderPending: boolean;
|
||||||
|
onDeleteMap: (viewId: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ViewRow({
|
||||||
|
v,
|
||||||
|
folder,
|
||||||
|
umeNav,
|
||||||
|
mapId,
|
||||||
|
dirty,
|
||||||
|
hotBrowseKey,
|
||||||
|
renameMapPending,
|
||||||
|
onHotBrowseKey,
|
||||||
|
onClearHotBrowseKey,
|
||||||
|
onWorldFocusClear,
|
||||||
|
goCanvas,
|
||||||
|
promptRenameMap,
|
||||||
|
onDeleteMap,
|
||||||
|
}: {
|
||||||
|
v: TopologyTreeViewItem;
|
||||||
|
folder: TopologyTreeFolderItem;
|
||||||
|
umeNav: boolean;
|
||||||
|
mapId: string;
|
||||||
|
dirty: boolean;
|
||||||
|
hotBrowseKey: string;
|
||||||
|
renameMapPending: boolean;
|
||||||
|
onHotBrowseKey: (key: string) => void;
|
||||||
|
onClearHotBrowseKey: (key: string) => void;
|
||||||
|
onWorldFocusClear: () => void;
|
||||||
|
goCanvas: (viewId: string, folderId: string) => void;
|
||||||
|
promptRenameMap: (viewId: string, name: string) => void;
|
||||||
|
onDeleteMap: (viewId: string) => void;
|
||||||
|
}) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const isPhysical = String(v.kind) === "physical";
|
||||||
|
const viewHot = hotBrowseKey === `view:${v.id}`;
|
||||||
|
const viewActive = mapId === v.id;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={v.id}
|
||||||
|
className={`topo-region-list__view${viewActive ? " is-active" : ""}${viewHot ? " is-hot" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="topo-map-list__row"
|
||||||
|
onMouseEnter={() => onHotBrowseKey(`view:${v.id}`)}
|
||||||
|
onMouseLeave={() => onClearHotBrowseKey(`view:${v.id}`)}
|
||||||
|
>
|
||||||
|
<span className="topo-region-list__chevron-spacer" aria-hidden="true" />
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-map-list__item"
|
||||||
|
onClick={() => {
|
||||||
|
onWorldFocusClear();
|
||||||
|
goCanvas(v.id, folder.id);
|
||||||
|
}}
|
||||||
|
onDoubleClick={() => (!umeNav ? promptRenameMap(v.id, v.name) : undefined)}
|
||||||
|
title={!umeNav ? t("topology.renameHint") : undefined}
|
||||||
|
>
|
||||||
|
<span className="topo-map-list__name">
|
||||||
|
<span
|
||||||
|
className={`topo-region-list__glyph topo-dir__icon--${
|
||||||
|
isPhysical ? "core" : "aggregation"
|
||||||
|
}`}
|
||||||
|
aria-hidden="true"
|
||||||
|
>
|
||||||
|
<LayerGlyph role={isPhysical ? "core" : "aggregation"} size={13} />
|
||||||
|
</span>
|
||||||
|
<span className="topo-map-list__title">
|
||||||
|
{displayViewName(v.name, t)}
|
||||||
|
{viewActive && dirty ? " *" : ""}
|
||||||
|
</span>
|
||||||
|
<span className="topo-map-list__count">{formatNeCount(v.node_count)}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{!umeNav ? (
|
||||||
|
<div className="topo-map-list__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-map-list__icon"
|
||||||
|
title={t("topology.rename")}
|
||||||
|
aria-label={t("topology.rename")}
|
||||||
|
disabled={renameMapPending}
|
||||||
|
onClick={() => promptRenameMap(v.id, v.name)}
|
||||||
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-map-list__icon"
|
||||||
|
title={t("topology.deleteMap")}
|
||||||
|
aria-label={t("topology.deleteMap")}
|
||||||
|
disabled={isPhysical}
|
||||||
|
onClick={() => {
|
||||||
|
const msg = t("topology.deleteMapConfirm").replace("{{name}}", v.name);
|
||||||
|
if (window.confirm(msg)) onDeleteMap(v.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TopologyTreeNavFolder({
|
||||||
|
folder,
|
||||||
|
depth,
|
||||||
|
treeRootId,
|
||||||
|
expandedIds,
|
||||||
|
mapId,
|
||||||
|
worldViewId,
|
||||||
|
selectedFolderId,
|
||||||
|
hotBrowseKey,
|
||||||
|
dirty,
|
||||||
|
onToggleExpand,
|
||||||
|
onHotBrowseKey,
|
||||||
|
onClearHotBrowseKey,
|
||||||
|
onWorldFocusClear,
|
||||||
|
goUmeWorldNav,
|
||||||
|
goRegion,
|
||||||
|
goCanvas,
|
||||||
|
promptRenameRegion,
|
||||||
|
promptRenameMap,
|
||||||
|
renameRegionPending,
|
||||||
|
renameMapPending,
|
||||||
|
onDeleteFolder,
|
||||||
|
deleteFolderPending,
|
||||||
|
onDeleteMap,
|
||||||
|
}: TopologyTreeNavFolderProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const kids = folder.children || [];
|
||||||
|
const containerFolder = folder.external_ref === "ume:world" || folder.name === "UME World";
|
||||||
|
const drillFolder = isWorldDrillFolder(folder);
|
||||||
|
const umeNav = isUmeWorldNavFolder(folder);
|
||||||
|
const canvasRegion = isRegionCanvasFolder(folder, treeRootId);
|
||||||
|
const visibleViews = (
|
||||||
|
canvasRegion
|
||||||
|
? (folder.views || []).filter((v) => String(v.kind) !== "physical")
|
||||||
|
: containerFolder
|
||||||
|
? (folder.views || []).filter((v) => isWorldFlatViewName(v.name))
|
||||||
|
: folder.views || []
|
||||||
|
).filter((v) => WORLD_MAP_ENABLED || !isWorldFlatViewName(v.name));
|
||||||
|
const hasKids = kids.length > 0 || visibleViews.length > 0;
|
||||||
|
const open = Boolean(expandedIds[folder.id]);
|
||||||
|
const viewActiveUnder =
|
||||||
|
(folder.views || []).some((v) => v.id === mapId) ||
|
||||||
|
(drillFolder && Boolean(worldViewId) && mapId === worldViewId);
|
||||||
|
const isSelected = selectedFolderId === folder.id;
|
||||||
|
const focused = viewActiveUnder || (isSelected && Boolean(mapId));
|
||||||
|
const regionActive = isSelected && !mapId;
|
||||||
|
const hot = hotBrowseKey === `region:${folder.id}`;
|
||||||
|
|
||||||
|
const canRenameFolder =
|
||||||
|
!isUmeStructuralFolder(folder) && (!folder.is_system || isManualRootMapFolder(folder));
|
||||||
|
const canDeleteFolder =
|
||||||
|
!isUmeStructuralFolder(folder) &&
|
||||||
|
!isManualRootMapFolder(folder) &&
|
||||||
|
(!folder.is_system || isUmeSyncedSubRegion(folder));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li
|
||||||
|
key={folder.id}
|
||||||
|
className={`topo-region-list__block${focused ? " is-branch-active" : ""}${
|
||||||
|
regionActive ? " is-active" : ""
|
||||||
|
}${hot ? " is-hot" : ""}`}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className="topo-map-list__row topo-region-list__row"
|
||||||
|
onMouseEnter={() => onHotBrowseKey(`region:${folder.id}`)}
|
||||||
|
onMouseLeave={() => onClearHotBrowseKey(`region:${folder.id}`)}
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-map-list__icon topo-region-list__chevron"
|
||||||
|
title={open ? t("topology.collapseRegion") : t("topology.expandRegion")}
|
||||||
|
aria-label={open ? t("topology.collapseRegion") : t("topology.expandRegion")}
|
||||||
|
aria-expanded={open}
|
||||||
|
disabled={!hasKids}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
if (!hasKids) return;
|
||||||
|
onToggleExpand(folder.id, !open);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ChevronIcon open={open} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-map-list__item"
|
||||||
|
onClick={() => {
|
||||||
|
if (containerFolder) {
|
||||||
|
goUmeWorldNav();
|
||||||
|
} else {
|
||||||
|
goRegion(folder.id);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("topology.openRegion")}
|
||||||
|
>
|
||||||
|
<span className="topo-map-list__name">
|
||||||
|
<span className="topo-region-list__glyph" aria-hidden="true">
|
||||||
|
<RegionGlyph size={14} />
|
||||||
|
</span>
|
||||||
|
<span className="topo-map-list__title">{regionDisplayName(folder, t)}</span>
|
||||||
|
<span className="topo-map-list__count">{formatNeCount(folderNeCount(folder))}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{canRenameFolder || canDeleteFolder ? (
|
||||||
|
<div className="topo-map-list__actions">
|
||||||
|
{canRenameFolder ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-map-list__icon"
|
||||||
|
title={t("topology.renameRegion")}
|
||||||
|
aria-label={t("topology.renameRegion")}
|
||||||
|
disabled={renameRegionPending}
|
||||||
|
onClick={() => promptRenameRegion(folder.id, folder.name)}
|
||||||
|
>
|
||||||
|
<PencilIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
{canDeleteFolder ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-map-list__icon"
|
||||||
|
title={t("topology.deleteRegion")}
|
||||||
|
aria-label={t("topology.deleteRegion")}
|
||||||
|
disabled={deleteFolderPending}
|
||||||
|
onClick={() => {
|
||||||
|
const msg = t("topology.deleteRegionConfirm").replace("{{name}}", folder.name);
|
||||||
|
if (window.confirm(msg)) onDeleteFolder(folder.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CloseIcon />
|
||||||
|
</button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{open ? (
|
||||||
|
<ul className="topo-map-list topo-region-list__maps">
|
||||||
|
{containerFolder ? (
|
||||||
|
<>
|
||||||
|
{kids.map((child) => (
|
||||||
|
<TopologyTreeNavFolder
|
||||||
|
key={child.id}
|
||||||
|
folder={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
treeRootId={treeRootId}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
mapId={mapId}
|
||||||
|
worldViewId={worldViewId}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
hotBrowseKey={hotBrowseKey}
|
||||||
|
dirty={dirty}
|
||||||
|
onToggleExpand={onToggleExpand}
|
||||||
|
onHotBrowseKey={onHotBrowseKey}
|
||||||
|
onClearHotBrowseKey={onClearHotBrowseKey}
|
||||||
|
onWorldFocusClear={onWorldFocusClear}
|
||||||
|
goUmeWorldNav={goUmeWorldNav}
|
||||||
|
goRegion={goRegion}
|
||||||
|
goCanvas={goCanvas}
|
||||||
|
promptRenameRegion={promptRenameRegion}
|
||||||
|
promptRenameMap={promptRenameMap}
|
||||||
|
renameRegionPending={renameRegionPending}
|
||||||
|
renameMapPending={renameMapPending}
|
||||||
|
onDeleteFolder={onDeleteFolder}
|
||||||
|
deleteFolderPending={deleteFolderPending}
|
||||||
|
onDeleteMap={onDeleteMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{visibleViews.map((v) => (
|
||||||
|
<ViewRow
|
||||||
|
key={v.id}
|
||||||
|
v={v}
|
||||||
|
folder={folder}
|
||||||
|
umeNav={umeNav}
|
||||||
|
mapId={mapId}
|
||||||
|
dirty={dirty}
|
||||||
|
hotBrowseKey={hotBrowseKey}
|
||||||
|
renameMapPending={renameMapPending}
|
||||||
|
onHotBrowseKey={onHotBrowseKey}
|
||||||
|
onClearHotBrowseKey={onClearHotBrowseKey}
|
||||||
|
onWorldFocusClear={onWorldFocusClear}
|
||||||
|
goCanvas={goCanvas}
|
||||||
|
promptRenameMap={promptRenameMap}
|
||||||
|
onDeleteMap={onDeleteMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{visibleViews.map((v) => (
|
||||||
|
<ViewRow
|
||||||
|
key={v.id}
|
||||||
|
v={v}
|
||||||
|
folder={folder}
|
||||||
|
umeNav={umeNav}
|
||||||
|
mapId={mapId}
|
||||||
|
dirty={dirty}
|
||||||
|
hotBrowseKey={hotBrowseKey}
|
||||||
|
renameMapPending={renameMapPending}
|
||||||
|
onHotBrowseKey={onHotBrowseKey}
|
||||||
|
onClearHotBrowseKey={onClearHotBrowseKey}
|
||||||
|
onWorldFocusClear={onWorldFocusClear}
|
||||||
|
goCanvas={goCanvas}
|
||||||
|
promptRenameMap={promptRenameMap}
|
||||||
|
onDeleteMap={onDeleteMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{kids.map((child) => (
|
||||||
|
<TopologyTreeNavFolder
|
||||||
|
key={child.id}
|
||||||
|
folder={child}
|
||||||
|
depth={depth + 1}
|
||||||
|
treeRootId={treeRootId}
|
||||||
|
expandedIds={expandedIds}
|
||||||
|
mapId={mapId}
|
||||||
|
worldViewId={worldViewId}
|
||||||
|
selectedFolderId={selectedFolderId}
|
||||||
|
hotBrowseKey={hotBrowseKey}
|
||||||
|
dirty={dirty}
|
||||||
|
onToggleExpand={onToggleExpand}
|
||||||
|
onHotBrowseKey={onHotBrowseKey}
|
||||||
|
onClearHotBrowseKey={onClearHotBrowseKey}
|
||||||
|
onWorldFocusClear={onWorldFocusClear}
|
||||||
|
goUmeWorldNav={goUmeWorldNav}
|
||||||
|
goRegion={goRegion}
|
||||||
|
goCanvas={goCanvas}
|
||||||
|
promptRenameRegion={promptRenameRegion}
|
||||||
|
promptRenameMap={promptRenameMap}
|
||||||
|
renameRegionPending={renameRegionPending}
|
||||||
|
renameMapPending={renameMapPending}
|
||||||
|
onDeleteFolder={onDeleteFolder}
|
||||||
|
deleteFolderPending={deleteFolderPending}
|
||||||
|
onDeleteMap={onDeleteMap}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
) : null}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
558
web/src/pages/topology/TopologyViewTools.tsx
Normal file
558
web/src/pages/topology/TopologyViewTools.tsx
Normal file
|
|
@ -0,0 +1,558 @@
|
||||||
|
import type { RefObject } from "react";
|
||||||
|
import { createPortal } from "react-dom";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Node } from "@xyflow/react";
|
||||||
|
import { queryKeys } from "../../constants/queryKeys";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
import { updateLldpCollectPolicy } from "../../services/api";
|
||||||
|
import {
|
||||||
|
DEFAULT_CANVAS_BG,
|
||||||
|
DISCOVER_AUTO_ADD_KEY,
|
||||||
|
DISCOVER_PROJECT_NEIGHBORS_KEY,
|
||||||
|
SCALE_BUNDLE_WIDTH_KEY,
|
||||||
|
SEP,
|
||||||
|
SHOW_PLACEHOLDER_BADGE_KEY,
|
||||||
|
} from "./constants";
|
||||||
|
import {
|
||||||
|
DEFAULT_LABEL_COLORS,
|
||||||
|
DEFAULT_VENDOR_COLORS,
|
||||||
|
VENDOR_TONE_KEYS,
|
||||||
|
isHexColor,
|
||||||
|
loadCanvasBg,
|
||||||
|
loadLabelColors,
|
||||||
|
loadVendorColors,
|
||||||
|
persistAutoLayoutAfterDiscover,
|
||||||
|
persistBoolFlag,
|
||||||
|
persistCanvasBg,
|
||||||
|
persistLabelColors,
|
||||||
|
persistVendorColors,
|
||||||
|
type LabelColors,
|
||||||
|
type VendorColors,
|
||||||
|
} from "./displayPrefs";
|
||||||
|
import type { EdgeDefaultStyle, EdgeDefaults, EdgeLineStyle, EdgeSourceKind } from "./edgeStyle";
|
||||||
|
import type { alignNodes } from "./layoutGraph";
|
||||||
|
import type { NeNodeData } from "./TopologyReactFlowView";
|
||||||
|
|
||||||
|
export type AlignKind = Parameters<typeof alignNodes>[2];
|
||||||
|
|
||||||
|
export type TopologyViewToolsProps = {
|
||||||
|
fullscreen: boolean;
|
||||||
|
toolbarSlot: HTMLDivElement | null;
|
||||||
|
displayMenuRef: RefObject<HTMLDetailsElement | null>;
|
||||||
|
findBoxRef: RefObject<HTMLDivElement | null>;
|
||||||
|
hideIp: boolean;
|
||||||
|
onHideIpChange: (v: boolean) => void;
|
||||||
|
hideVendor: boolean;
|
||||||
|
onHideVendorChange: (v: boolean) => void;
|
||||||
|
showPlaceholderBadge: boolean;
|
||||||
|
onShowPlaceholderBadgeChange: (v: boolean) => void;
|
||||||
|
hidePorts: boolean;
|
||||||
|
onHidePortsChange: (v: boolean) => void;
|
||||||
|
expandPhysicalLinks: boolean;
|
||||||
|
onExpandPhysicalLinksChange: (v: boolean) => void;
|
||||||
|
scaleBundleWidth: boolean;
|
||||||
|
onScaleBundleWidthChange: (v: boolean) => void;
|
||||||
|
edgeFlow: boolean;
|
||||||
|
onEdgeFlowChange: (v: boolean) => void;
|
||||||
|
snapToGrid: boolean;
|
||||||
|
onSnapToGridChange: (v: boolean) => void;
|
||||||
|
autoLayoutAfterDiscover: boolean;
|
||||||
|
onAutoLayoutAfterDiscoverChange: (v: boolean) => void;
|
||||||
|
discoverAutoAddUnmatched: boolean;
|
||||||
|
onDiscoverAutoAddUnmatchedChange: (v: boolean) => void;
|
||||||
|
discoverProjectNeighbors: boolean;
|
||||||
|
onDiscoverProjectNeighborsChange: (v: boolean) => void;
|
||||||
|
canvasBg: string;
|
||||||
|
onCanvasBgChange: (v: string) => void;
|
||||||
|
labelColors: LabelColors;
|
||||||
|
onLabelColorsChange: (v: LabelColors) => void;
|
||||||
|
vendorColors: VendorColors;
|
||||||
|
onVendorColorsChange: (v: VendorColors) => void;
|
||||||
|
edgeDefaults: EdgeDefaults;
|
||||||
|
onUpdateEdgeDefault: (kind: EdgeSourceKind, patch: Partial<EdgeDefaultStyle>) => void;
|
||||||
|
onResetEdgeDefaults: () => void;
|
||||||
|
selectedNodeCount: number;
|
||||||
|
onAlign: (kind: AlignKind) => void;
|
||||||
|
mapId: string;
|
||||||
|
nodeCount: number;
|
||||||
|
canvasQuery: string;
|
||||||
|
onCanvasQueryChange: (v: string) => void;
|
||||||
|
findOpen: boolean;
|
||||||
|
onFindOpenChange: (v: boolean) => void;
|
||||||
|
findActiveIdx: number;
|
||||||
|
onFindActiveIdxChange: (v: number | ((i: number) => number)) => void;
|
||||||
|
canvasHits: Node<NeNodeData>[];
|
||||||
|
onFindOnCanvas: (nodeId?: string) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopologyViewTools(props: TopologyViewToolsProps) {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const {
|
||||||
|
fullscreen,
|
||||||
|
toolbarSlot,
|
||||||
|
displayMenuRef,
|
||||||
|
findBoxRef,
|
||||||
|
hideIp,
|
||||||
|
onHideIpChange,
|
||||||
|
hideVendor,
|
||||||
|
onHideVendorChange,
|
||||||
|
showPlaceholderBadge,
|
||||||
|
onShowPlaceholderBadgeChange,
|
||||||
|
hidePorts,
|
||||||
|
onHidePortsChange,
|
||||||
|
expandPhysicalLinks,
|
||||||
|
onExpandPhysicalLinksChange,
|
||||||
|
scaleBundleWidth,
|
||||||
|
onScaleBundleWidthChange,
|
||||||
|
edgeFlow,
|
||||||
|
onEdgeFlowChange,
|
||||||
|
snapToGrid,
|
||||||
|
onSnapToGridChange,
|
||||||
|
autoLayoutAfterDiscover,
|
||||||
|
onAutoLayoutAfterDiscoverChange,
|
||||||
|
discoverAutoAddUnmatched,
|
||||||
|
onDiscoverAutoAddUnmatchedChange,
|
||||||
|
discoverProjectNeighbors,
|
||||||
|
onDiscoverProjectNeighborsChange,
|
||||||
|
canvasBg,
|
||||||
|
onCanvasBgChange,
|
||||||
|
labelColors,
|
||||||
|
onLabelColorsChange,
|
||||||
|
vendorColors,
|
||||||
|
onVendorColorsChange,
|
||||||
|
edgeDefaults,
|
||||||
|
onUpdateEdgeDefault,
|
||||||
|
onResetEdgeDefaults,
|
||||||
|
selectedNodeCount,
|
||||||
|
onAlign,
|
||||||
|
mapId,
|
||||||
|
nodeCount,
|
||||||
|
canvasQuery,
|
||||||
|
onCanvasQueryChange,
|
||||||
|
findOpen,
|
||||||
|
onFindOpenChange,
|
||||||
|
findActiveIdx,
|
||||||
|
onFindActiveIdxChange,
|
||||||
|
canvasHits,
|
||||||
|
onFindOnCanvas,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<details className="topo-toolbar__display" ref={displayMenuRef}>
|
||||||
|
<summary>{t("topology.display")}</summary>
|
||||||
|
<div className="topo-display-toggles" role="group" aria-label={t("topology.display")}>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input type="checkbox" checked={hideIp} onChange={(e) => onHideIpChange(e.target.checked)} />
|
||||||
|
{t("topology.hideIp")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={hideVendor}
|
||||||
|
onChange={(e) => onHideVendorChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("topology.hideVendor")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showPlaceholderBadge}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked;
|
||||||
|
onShowPlaceholderBadgeChange(next);
|
||||||
|
persistBoolFlag(SHOW_PLACEHOLDER_BADGE_KEY, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("topology.showPlaceholderBadge")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input type="checkbox" checked={hidePorts} onChange={(e) => onHidePortsChange(e.target.checked)} />
|
||||||
|
{t("topology.hidePorts")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={expandPhysicalLinks}
|
||||||
|
onChange={(e) => onExpandPhysicalLinksChange(e.target.checked)}
|
||||||
|
/>
|
||||||
|
{t("topology.expandPhysicalLinks")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={scaleBundleWidth}
|
||||||
|
disabled={expandPhysicalLinks}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked;
|
||||||
|
onScaleBundleWidthChange(next);
|
||||||
|
persistBoolFlag(SCALE_BUNDLE_WIDTH_KEY, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("topology.scaleBundleWidth")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input type="checkbox" checked={edgeFlow} onChange={(e) => onEdgeFlowChange(e.target.checked)} />
|
||||||
|
{t("topology.edgeFlow")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input type="checkbox" checked={snapToGrid} onChange={(e) => onSnapToGridChange(e.target.checked)} />
|
||||||
|
{t("topology.snapGrid")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={autoLayoutAfterDiscover}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked;
|
||||||
|
onAutoLayoutAfterDiscoverChange(next);
|
||||||
|
persistAutoLayoutAfterDiscover(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("topology.autoLayoutDiscover")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={discoverAutoAddUnmatched}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked;
|
||||||
|
onDiscoverAutoAddUnmatchedChange(next);
|
||||||
|
persistBoolFlag(DISCOVER_AUTO_ADD_KEY, next);
|
||||||
|
void updateLldpCollectPolicy({ auto_add_unmatched: next })
|
||||||
|
.then(() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard });
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* local toggle still applies to this canvas discover */
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("topology.discoverAutoAddUnmatched")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={discoverProjectNeighbors}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked;
|
||||||
|
onDiscoverProjectNeighborsChange(next);
|
||||||
|
persistBoolFlag(DISCOVER_PROJECT_NEIGHBORS_KEY, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("topology.discoverProjectNeighbors")}
|
||||||
|
</label>
|
||||||
|
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
|
||||||
|
<div className="topo-display-defaults__head">
|
||||||
|
<strong>{t("topology.canvasBg")}</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => {
|
||||||
|
onCanvasBgChange(DEFAULT_CANVAS_BG);
|
||||||
|
persistCanvasBg(DEFAULT_CANVAS_BG);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("topology.canvasBgReset")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="topo-display-defaults__row">
|
||||||
|
<span className="topo-display-defaults__name">{t("topology.canvasBgColor")}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={canvasBg}
|
||||||
|
title={t("topology.canvasBgColor")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
onCanvasBgChange(next);
|
||||||
|
persistCanvasBg(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="topo-toolbar__select topo-canvas-bg-hex"
|
||||||
|
value={canvasBg}
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label={t("topology.canvasBgColor")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.trim();
|
||||||
|
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
|
||||||
|
onCanvasBgChange(raw);
|
||||||
|
if (isHexColor(raw)) persistCanvasBg(raw.toLowerCase());
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!isHexColor(canvasBg)) onCanvasBgChange(loadCanvasBg());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
|
||||||
|
<div className="topo-display-defaults__head">
|
||||||
|
<strong>{t("topology.textColors")}</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => {
|
||||||
|
onLabelColorsChange({ ...DEFAULT_LABEL_COLORS });
|
||||||
|
persistLabelColors({ ...DEFAULT_LABEL_COLORS });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("topology.textColorsReset")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["name", t("topology.labelColor")],
|
||||||
|
["edgeLabel", t("topology.edgeLabelColor")],
|
||||||
|
] as const
|
||||||
|
).map(([key, label]) => (
|
||||||
|
<div key={key} className="topo-display-defaults__row">
|
||||||
|
<span className="topo-display-defaults__name">{label}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={labelColors[key]}
|
||||||
|
title={label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = { ...labelColors, [key]: e.target.value.toLowerCase() };
|
||||||
|
onLabelColorsChange(next);
|
||||||
|
persistLabelColors(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="topo-toolbar__select topo-canvas-bg-hex"
|
||||||
|
value={labelColors[key]}
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.trim();
|
||||||
|
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
|
||||||
|
const next = { ...labelColors, [key]: raw };
|
||||||
|
onLabelColorsChange(next);
|
||||||
|
if (isHexColor(raw)) persistLabelColors({ ...next, [key]: raw.toLowerCase() });
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!isHexColor(labelColors[key])) onLabelColorsChange(loadLabelColors());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="topo-display-defaults topo-display-defaults--canvas-bg topo-display-defaults--colors">
|
||||||
|
<div className="topo-display-defaults__head">
|
||||||
|
<strong>{t("topology.vendorColors")}</strong>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn--sm btn--ghost"
|
||||||
|
onClick={() => {
|
||||||
|
onVendorColorsChange({ ...DEFAULT_VENDOR_COLORS });
|
||||||
|
persistVendorColors({ ...DEFAULT_VENDOR_COLORS });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t("topology.vendorColorsReset")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{VENDOR_TONE_KEYS.map((key) => {
|
||||||
|
const label = t(`topology.vendorTone.${key}`);
|
||||||
|
return (
|
||||||
|
<div key={key} className="topo-display-defaults__row">
|
||||||
|
<span className="topo-display-defaults__name">{label}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={vendorColors[key]}
|
||||||
|
title={label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = { ...vendorColors, [key]: e.target.value.toLowerCase() };
|
||||||
|
onVendorColorsChange(next);
|
||||||
|
persistVendorColors(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
className="topo-toolbar__select topo-canvas-bg-hex"
|
||||||
|
value={vendorColors[key]}
|
||||||
|
spellCheck={false}
|
||||||
|
aria-label={label}
|
||||||
|
onChange={(e) => {
|
||||||
|
const raw = e.target.value.trim();
|
||||||
|
if (!/^#[0-9a-fA-F]{0,6}$/.test(raw)) return;
|
||||||
|
const next = { ...vendorColors, [key]: raw };
|
||||||
|
onVendorColorsChange(next);
|
||||||
|
if (isHexColor(raw)) {
|
||||||
|
persistVendorColors({ ...next, [key]: raw.toLowerCase() });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
onBlur={() => {
|
||||||
|
if (!isHexColor(vendorColors[key])) onVendorColorsChange(loadVendorColors());
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="topo-display-defaults">
|
||||||
|
<div className="topo-display-defaults__head">
|
||||||
|
<strong>{t("topology.edgeDefaults")}</strong>
|
||||||
|
<button type="button" className="btn btn--sm btn--ghost" onClick={onResetEdgeDefaults}>
|
||||||
|
{t("topology.edgeDefaultsReset")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{(
|
||||||
|
[
|
||||||
|
["ume", t("topology.edgeUme")],
|
||||||
|
["discovered", t("topology.edgeDiscovered")],
|
||||||
|
["stale", t("topology.edgeStale")],
|
||||||
|
["manual", t("topology.edgeManual")],
|
||||||
|
] as const
|
||||||
|
).map(([kind, label]) => {
|
||||||
|
const d = edgeDefaults[kind];
|
||||||
|
return (
|
||||||
|
<div key={kind} className="topo-display-defaults__row">
|
||||||
|
<span className="topo-display-defaults__name">{label}</span>
|
||||||
|
<input
|
||||||
|
type="color"
|
||||||
|
value={d.stroke_color}
|
||||||
|
title={t("topology.edgeColor")}
|
||||||
|
onChange={(e) => onUpdateEdgeDefault(kind, { stroke_color: e.target.value })}
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
className="topo-toolbar__select"
|
||||||
|
aria-label={t("topology.edgeLineStyle")}
|
||||||
|
value={d.line_style}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateEdgeDefault(kind, {
|
||||||
|
line_style: e.target.value as EdgeLineStyle,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<option value="solid">{t("topology.edgeLineSolid")}</option>
|
||||||
|
<option value="dashed">{t("topology.edgeLineDashed")}</option>
|
||||||
|
<option value="dotted">{t("topology.edgeLineDotted")}</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
className="topo-toolbar__select"
|
||||||
|
aria-label={t("topology.edgeWidth")}
|
||||||
|
value={String(d.stroke_width)}
|
||||||
|
onChange={(e) =>
|
||||||
|
onUpdateEdgeDefault(kind, { stroke_width: Number(e.target.value) || 2 })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 5, 6, 8].map((w) => (
|
||||||
|
<option key={w} value={w}>
|
||||||
|
{w}px
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<select
|
||||||
|
className="topo-toolbar__select"
|
||||||
|
aria-label={t("topology.align")}
|
||||||
|
disabled={selectedNodeCount < 2}
|
||||||
|
defaultValue=""
|
||||||
|
onChange={(e) => {
|
||||||
|
const v = e.target.value as AlignKind | "";
|
||||||
|
e.target.value = "";
|
||||||
|
if (v) onAlign(v);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="" disabled>
|
||||||
|
{t("topology.align")}
|
||||||
|
</option>
|
||||||
|
<option value="left">{t("topology.alignLeft")}</option>
|
||||||
|
<option value="right">{t("topology.alignRight")}</option>
|
||||||
|
<option value="top">{t("topology.alignTop")}</option>
|
||||||
|
<option value="bottom">{t("topology.alignBottom")}</option>
|
||||||
|
<option value="h-center">{t("topology.alignHCenter")}</option>
|
||||||
|
<option value="v-center">{t("topology.alignVCenter")}</option>
|
||||||
|
<option value="h-distribute">{t("topology.alignHDistribute")}</option>
|
||||||
|
<option value="v-distribute">{t("topology.alignVDistribute")}</option>
|
||||||
|
</select>
|
||||||
|
<div
|
||||||
|
className="topo-toolbar__group topo-toolbar__group--find"
|
||||||
|
aria-label={t("topology.findNode")}
|
||||||
|
ref={findBoxRef}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
className="topo-toolbar__find"
|
||||||
|
value={canvasQuery}
|
||||||
|
onChange={(e) => onCanvasQueryChange(e.target.value)}
|
||||||
|
onFocus={() => {
|
||||||
|
if (canvasQuery.trim()) onFindOpenChange(true);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canvasHits.length) return;
|
||||||
|
onFindOpenChange(true);
|
||||||
|
onFindActiveIdxChange((i) => (i + 1) % canvasHits.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!canvasHits.length) return;
|
||||||
|
onFindOpenChange(true);
|
||||||
|
onFindActiveIdxChange((i) => (i - 1 + canvasHits.length) % canvasHits.length);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
onFindOpenChange(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
onFindOnCanvas();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
placeholder={t("topology.findNodePh")}
|
||||||
|
disabled={!mapId || nodeCount === 0}
|
||||||
|
aria-label={t("topology.findNode")}
|
||||||
|
aria-autocomplete="list"
|
||||||
|
aria-expanded={findOpen}
|
||||||
|
/>
|
||||||
|
{findOpen && canvasQuery.trim() ? (
|
||||||
|
<div className="topo-find-suggest" role="listbox">
|
||||||
|
{canvasHits.length === 0 ? (
|
||||||
|
<div className="topo-find-suggest__empty">{t("topology.findNoMatch")}</div>
|
||||||
|
) : (
|
||||||
|
canvasHits.slice(0, 12).map((n, idx) => (
|
||||||
|
<button
|
||||||
|
key={n.id}
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={idx === findActiveIdx}
|
||||||
|
className={`topo-find-suggest__item${idx === findActiveIdx ? " is-active" : ""}`}
|
||||||
|
onMouseEnter={() => onFindActiveIdxChange(idx)}
|
||||||
|
onClick={() => onFindOnCanvas(n.id)}
|
||||||
|
>
|
||||||
|
<span className="topo-find-suggest__name">{n.data.label || n.id}</span>
|
||||||
|
<span className="topo-find-suggest__meta">
|
||||||
|
{[n.data.ne_ip, n.data.vendor].filter(Boolean).join(SEP)}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
{canvasHits.length > 12 ? (
|
||||||
|
<div className="topo-find-suggest__more">
|
||||||
|
{t("topology.findMore").replace("{{count}}", String(canvasHits.length - 12))}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!fullscreen) {
|
||||||
|
if (!toolbarSlot) return null;
|
||||||
|
return createPortal(body, toolbarSlot);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="topo-view-tools" role="toolbar" aria-label={t("topology.display")}>
|
||||||
|
{body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
30
web/src/pages/topology/constants.ts
Normal file
30
web/src/pages/topology/constants.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
/** Flat world-map canvas (LOD / scatter). Temporarily offline in the UI. */
|
||||||
|
export const WORLD_MAP_ENABLED = false;
|
||||||
|
|
||||||
|
export const LAST_LEAF_KEY = "netx.topology.lastLeafViewId";
|
||||||
|
export const TREE_EXPAND_KEY = "netx.topology.treeExpanded";
|
||||||
|
export const SIDEBAR_WIDTH_KEY = "netx.topology.sidebarWidth";
|
||||||
|
export const SIDEBAR_WIDTH_DEFAULT = 260;
|
||||||
|
export const SIDEBAR_WIDTH_MIN = 180;
|
||||||
|
export const SIDEBAR_WIDTH_MAX = 560;
|
||||||
|
|
||||||
|
export const UNDO_MAX = 40;
|
||||||
|
export const PALETTE_DND = "application/x-netx-topo-palette";
|
||||||
|
export const SEP = " / ";
|
||||||
|
|
||||||
|
export const FIT_VIEW_OPTS = { padding: 0.2, includeHiddenNodes: true } as const;
|
||||||
|
export const WORLD_LOCATE_ZOOM = 0.22;
|
||||||
|
export const WORLD_LOCATE_HALF = 12000;
|
||||||
|
export const WORLD_FLAT_ACCUM_CAP = 8000;
|
||||||
|
|
||||||
|
export const EDGE_DEFAULTS_KEY = "netx.topology.edgeDefaults";
|
||||||
|
export const AUTO_LAYOUT_DISCOVER_KEY = "netx.topology.autoLayoutAfterDiscover";
|
||||||
|
export const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched.v2";
|
||||||
|
export const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors.v2";
|
||||||
|
export const SCALE_BUNDLE_WIDTH_KEY = "netx.topology.scaleBundleWidth";
|
||||||
|
export const SHOW_PLACEHOLDER_BADGE_KEY = "netx.topology.showPlaceholderBadge";
|
||||||
|
export const CANVAS_BG_KEY = "netx.topology.canvasBg";
|
||||||
|
export const DEFAULT_CANVAS_BG = "#0f172a";
|
||||||
|
export const LEGACY_CANVAS_BG = "#dbeafe";
|
||||||
|
export const LABEL_COLORS_KEY = "netx.topology.labelColors";
|
||||||
|
export const VENDOR_COLORS_KEY = "netx.topology.vendorColors";
|
||||||
146
web/src/pages/topology/displayPrefs.ts
Normal file
146
web/src/pages/topology/displayPrefs.ts
Normal file
|
|
@ -0,0 +1,146 @@
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import {
|
||||||
|
AUTO_LAYOUT_DISCOVER_KEY,
|
||||||
|
CANVAS_BG_KEY,
|
||||||
|
DEFAULT_CANVAS_BG,
|
||||||
|
LABEL_COLORS_KEY,
|
||||||
|
LEGACY_CANVAS_BG,
|
||||||
|
VENDOR_COLORS_KEY,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
export const VENDOR_TONE_KEYS = [
|
||||||
|
"cisco",
|
||||||
|
"huawei",
|
||||||
|
"zte",
|
||||||
|
"juniper",
|
||||||
|
"nokia",
|
||||||
|
"ericsson",
|
||||||
|
"h3c",
|
||||||
|
"ruijie",
|
||||||
|
"mikrotik",
|
||||||
|
"gray",
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export type VendorToneKey = (typeof VENDOR_TONE_KEYS)[number];
|
||||||
|
export type VendorColors = Record<VendorToneKey, string>;
|
||||||
|
export type LabelColors = { name: string; edgeLabel: string };
|
||||||
|
|
||||||
|
export const DEFAULT_LABEL_COLORS: LabelColors = {
|
||||||
|
name: "#f1f5f9",
|
||||||
|
edgeLabel: "#e2e8f0",
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_VENDOR_COLORS: VendorColors = {
|
||||||
|
cisco: "#049fd9",
|
||||||
|
huawei: "#cf0a2c",
|
||||||
|
zte: "#0091da",
|
||||||
|
juniper: "#84b135",
|
||||||
|
nokia: "#124191",
|
||||||
|
ericsson: "#1e3a5f",
|
||||||
|
h3c: "#7ac143",
|
||||||
|
ruijie: "#7c3aed",
|
||||||
|
mikrotik: "#ea580c",
|
||||||
|
gray: "#94a3b8",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function isHexColor(value: string): boolean {
|
||||||
|
return /^#[0-9a-fA-F]{6}$/.test(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadCanvasBg(): string {
|
||||||
|
try {
|
||||||
|
const raw = String(localStorage.getItem(CANVAS_BG_KEY) || "").trim().toLowerCase();
|
||||||
|
if (raw === LEGACY_CANVAS_BG) {
|
||||||
|
persistCanvasBg(DEFAULT_CANVAS_BG);
|
||||||
|
return DEFAULT_CANVAS_BG;
|
||||||
|
}
|
||||||
|
if (isHexColor(raw)) return raw;
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return DEFAULT_CANVAS_BG;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistCanvasBg(value: string) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CANVAS_BG_KEY, value);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadLabelColors(): LabelColors {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(LABEL_COLORS_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_LABEL_COLORS };
|
||||||
|
const parsed = JSON.parse(raw) as Partial<LabelColors>;
|
||||||
|
return {
|
||||||
|
name: isHexColor(String(parsed.name || "")) ? String(parsed.name).toLowerCase() : DEFAULT_LABEL_COLORS.name,
|
||||||
|
edgeLabel: isHexColor(String(parsed.edgeLabel || ""))
|
||||||
|
? String(parsed.edgeLabel).toLowerCase()
|
||||||
|
: DEFAULT_LABEL_COLORS.edgeLabel,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_LABEL_COLORS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistLabelColors(value: LabelColors) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(LABEL_COLORS_KEY, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadVendorColors(): VendorColors {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(VENDOR_COLORS_KEY);
|
||||||
|
if (!raw) return { ...DEFAULT_VENDOR_COLORS };
|
||||||
|
const parsed = JSON.parse(raw) as Partial<VendorColors>;
|
||||||
|
const out = { ...DEFAULT_VENDOR_COLORS };
|
||||||
|
for (const key of VENDOR_TONE_KEYS) {
|
||||||
|
const c = String(parsed?.[key] || "").trim().toLowerCase();
|
||||||
|
if (isHexColor(c)) out[key] = c;
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_VENDOR_COLORS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistVendorColors(value: VendorColors) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VENDOR_COLORS_KEY, JSON.stringify(value));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadBoolFlag(key: string, defaultValue: boolean): boolean {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(key);
|
||||||
|
if (raw === null) return defaultValue;
|
||||||
|
return raw === "1" || raw === "true";
|
||||||
|
} catch {
|
||||||
|
return defaultValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistBoolFlag(key: string, value: boolean) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(key, value ? "1" : "0");
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadAutoLayoutAfterDiscover(): boolean {
|
||||||
|
return loadBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistAutoLayoutAfterDiscover(value: boolean) {
|
||||||
|
persistBoolFlag(AUTO_LAYOUT_DISCOVER_KEY, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { CSSProperties };
|
||||||
118
web/src/pages/topology/edgeStyle.ts
Normal file
118
web/src/pages/topology/edgeStyle.ts
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
import type { Edge } from "@xyflow/react";
|
||||||
|
import type { LinkEdgeData } from "./linkDisplay";
|
||||||
|
import { EDGE_DEFAULTS_KEY } from "./constants";
|
||||||
|
|
||||||
|
export type EdgeLineStyle = "solid" | "dashed" | "dotted";
|
||||||
|
export type EdgeSourceKind = "manual" | "ume" | "discovered" | "stale";
|
||||||
|
|
||||||
|
export type EdgeDefaultStyle = {
|
||||||
|
stroke_color: string;
|
||||||
|
stroke_width: number;
|
||||||
|
line_style: EdgeLineStyle;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EdgeDefaults = Record<EdgeSourceKind, EdgeDefaultStyle>;
|
||||||
|
export type EdgeStyleData = LinkEdgeData;
|
||||||
|
|
||||||
|
const BUILTIN_EDGE_DEFAULTS: EdgeDefaults = {
|
||||||
|
ume: { stroke_color: "#2563eb", stroke_width: 2, line_style: "solid" },
|
||||||
|
discovered: { stroke_color: "#0ea5e9", stroke_width: 2, line_style: "dashed" },
|
||||||
|
stale: { stroke_color: "#dc2626", stroke_width: 2, line_style: "dashed" },
|
||||||
|
manual: { stroke_color: "#64748b", stroke_width: 2, line_style: "solid" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export function sourceKind(source: string): EdgeSourceKind {
|
||||||
|
const src = (source || "manual").toLowerCase();
|
||||||
|
if (src === "stale" || src === "missing") return "stale";
|
||||||
|
if (src === "ume") return "ume";
|
||||||
|
if (src === "lldp" || src === "cdp") return "discovered";
|
||||||
|
return "manual";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadEdgeDefaults(): EdgeDefaults {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(EDGE_DEFAULTS_KEY);
|
||||||
|
if (!raw) {
|
||||||
|
return {
|
||||||
|
ume: { ...BUILTIN_EDGE_DEFAULTS.ume },
|
||||||
|
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
|
||||||
|
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
|
||||||
|
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const parsed = JSON.parse(raw) as Partial<EdgeDefaults>;
|
||||||
|
const pick = (kind: EdgeSourceKind): EdgeDefaultStyle => {
|
||||||
|
const base = BUILTIN_EDGE_DEFAULTS[kind];
|
||||||
|
const cur = parsed?.[kind];
|
||||||
|
const color = String(cur?.stroke_color || base.stroke_color).trim() || base.stroke_color;
|
||||||
|
const width = Math.max(1, Math.min(12, Number(cur?.stroke_width || base.stroke_width) || base.stroke_width));
|
||||||
|
const line = String(cur?.line_style || base.line_style).toLowerCase();
|
||||||
|
const line_style: EdgeLineStyle =
|
||||||
|
line === "dashed" || line === "dotted" || line === "solid" ? line : base.line_style;
|
||||||
|
return { stroke_color: color, stroke_width: width, line_style };
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ume: pick("ume"),
|
||||||
|
discovered: pick("discovered"),
|
||||||
|
stale: pick("stale"),
|
||||||
|
manual: pick("manual"),
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return {
|
||||||
|
ume: { ...BUILTIN_EDGE_DEFAULTS.ume },
|
||||||
|
discovered: { ...BUILTIN_EDGE_DEFAULTS.discovered },
|
||||||
|
stale: { ...BUILTIN_EDGE_DEFAULTS.stale },
|
||||||
|
manual: { ...BUILTIN_EDGE_DEFAULTS.manual },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function persistEdgeDefaults(next: EdgeDefaults) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(EDGE_DEFAULTS_KEY, JSON.stringify(next));
|
||||||
|
} catch {
|
||||||
|
/* ignore quota */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dashForLineStyle(lineStyle: string): string | undefined {
|
||||||
|
const s = (lineStyle || "").trim().toLowerCase();
|
||||||
|
if (s === "dashed") return "6 4";
|
||||||
|
if (s === "dotted") return "2 2";
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function edgeStyleBySource(
|
||||||
|
source: string,
|
||||||
|
defaults: EdgeDefaults,
|
||||||
|
): { stroke: string; strokeDasharray?: string; strokeWidth: number } {
|
||||||
|
const d = defaults[sourceKind(source)];
|
||||||
|
return {
|
||||||
|
stroke: d.stroke_color,
|
||||||
|
strokeWidth: d.stroke_width,
|
||||||
|
strokeDasharray: dashForLineStyle(d.line_style),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveEdgeStyle(
|
||||||
|
data: EdgeStyleData | undefined,
|
||||||
|
defaults: EdgeDefaults,
|
||||||
|
): { stroke: string; strokeDasharray?: string; strokeWidth: number } {
|
||||||
|
const base = edgeStyleBySource(data?.source || "manual", defaults);
|
||||||
|
const color = String(data?.stroke_color || "").trim();
|
||||||
|
const width = Number(data?.stroke_width || 0);
|
||||||
|
const line = String(data?.line_style || "").trim().toLowerCase();
|
||||||
|
return {
|
||||||
|
stroke: color || base.stroke,
|
||||||
|
strokeWidth: width > 0 ? width : base.strokeWidth,
|
||||||
|
strokeDasharray: line ? dashForLineStyle(line) : base.strokeDasharray,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function withEdgeVisual(edge: Edge, defaults: EdgeDefaults): Edge {
|
||||||
|
const data = (edge.data || {}) as EdgeStyleData;
|
||||||
|
const style = resolveEdgeStyle(data, defaults);
|
||||||
|
return { ...edge, style, markerEnd: undefined, labelShowBg: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export { BUILTIN_EDGE_DEFAULTS };
|
||||||
459
web/src/pages/topology/exportTopology.ts
Normal file
459
web/src/pages/topology/exportTopology.ts
Normal file
|
|
@ -0,0 +1,459 @@
|
||||||
|
import { getStraightPath } from "@xyflow/react";
|
||||||
|
import type { Edge, Node } from "@xyflow/react";
|
||||||
|
import type { TopologyViewGraph, TopologyViewItem, TopologyViewNodeItem } from "../../types";
|
||||||
|
import type { NeNodeData } from "./TopologyReactFlowView";
|
||||||
|
import { isAggregateEdgeId, type LinkEdgeData } from "./linkDisplay";
|
||||||
|
import {
|
||||||
|
TOPO_HANDLE_X,
|
||||||
|
TOPO_HANDLE_Y,
|
||||||
|
TOPO_ICON,
|
||||||
|
TOPO_NODE_W,
|
||||||
|
TOPO_REGION_HANDLE_X,
|
||||||
|
TOPO_REGION_HANDLE_Y,
|
||||||
|
TOPO_REGION_ICON_H,
|
||||||
|
} from "./topoGeometry";
|
||||||
|
import type { VendorColors } from "./displayPrefs";
|
||||||
|
import { vendorColorForNode } from "./vendorTone";
|
||||||
|
|
||||||
|
const NE_ICON_URL = "/topo/ne-router.png";
|
||||||
|
const REGION_ICON_URL = "/topo/region-building.png";
|
||||||
|
|
||||||
|
export type TopologyVectorSvgTheme = {
|
||||||
|
labelColor: string;
|
||||||
|
edgeLabelColor: string;
|
||||||
|
vendorColors: VendorColors;
|
||||||
|
hideIp: boolean;
|
||||||
|
hideVendor: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type IconAssets = {
|
||||||
|
neTinted: Map<string, string>;
|
||||||
|
regionPlain: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
function sanitizeFilename(name: string): string {
|
||||||
|
const trimmed = String(name || "topology")
|
||||||
|
.trim()
|
||||||
|
.replace(/[<>:"/\\|?*\x00-\x1f]/g, "_")
|
||||||
|
.replace(/\s+/g, "_")
|
||||||
|
.slice(0, 80);
|
||||||
|
return trimmed || "topology";
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBlob(filename: string, blob: Blob): void {
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
window.setTimeout(() => URL.revokeObjectURL(url), 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadText(filename: string, text: string, mime: string): void {
|
||||||
|
downloadBlob(filename, new Blob([text], { type: mime }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadImage(url: string): Promise<HTMLImageElement> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => resolve(img);
|
||||||
|
img.onerror = () => reject(new Error(`failed to load icon: ${url}`));
|
||||||
|
img.src = url;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Match canvas: `.topo-node__icon-art` + masked `.topo-node__icon-tint` (mix-blend-mode: color). */
|
||||||
|
async function tintedIconDataUrl(
|
||||||
|
img: HTMLImageElement,
|
||||||
|
color: string,
|
||||||
|
w: number,
|
||||||
|
h: number,
|
||||||
|
): Promise<string> {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("canvas unsupported");
|
||||||
|
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
|
||||||
|
const tint = document.createElement("canvas");
|
||||||
|
tint.width = w;
|
||||||
|
tint.height = h;
|
||||||
|
const tctx = tint.getContext("2d");
|
||||||
|
if (!tctx) throw new Error("canvas unsupported");
|
||||||
|
tctx.fillStyle = color;
|
||||||
|
tctx.fillRect(0, 0, w, h);
|
||||||
|
tctx.globalCompositeOperation = "destination-in";
|
||||||
|
tctx.drawImage(img, 0, 0, w, h);
|
||||||
|
|
||||||
|
ctx.globalCompositeOperation = "color";
|
||||||
|
ctx.drawImage(tint, 0, 0);
|
||||||
|
|
||||||
|
return canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function plainIconDataUrl(img: HTMLImageElement, w: number, h: number): Promise<string> {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = w;
|
||||||
|
canvas.height = h;
|
||||||
|
const ctx = canvas.getContext("2d");
|
||||||
|
if (!ctx) throw new Error("canvas unsupported");
|
||||||
|
ctx.drawImage(img, 0, 0, w, h);
|
||||||
|
return canvas.toDataURL("image/png");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prepareIconAssets(
|
||||||
|
nodes: Node<NeNodeData>[],
|
||||||
|
theme: TopologyVectorSvgTheme,
|
||||||
|
): Promise<IconAssets> {
|
||||||
|
const [neImg, regionImg] = await Promise.all([loadImage(NE_ICON_URL), loadImage(REGION_ICON_URL)]);
|
||||||
|
const colors = new Set<string>();
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (!isRegionNode(n)) colors.add(vendorColor(theme, n.data));
|
||||||
|
}
|
||||||
|
const neTinted = new Map<string, string>();
|
||||||
|
await Promise.all(
|
||||||
|
[...colors].map(async (color) => {
|
||||||
|
neTinted.set(color, await tintedIconDataUrl(neImg, color, TOPO_ICON, TOPO_ICON));
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
const regionPlain = await plainIconDataUrl(regionImg, TOPO_ICON, TOPO_REGION_ICON_H);
|
||||||
|
return { neTinted, regionPlain };
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRegionNode(node: Node<NeNodeData>): boolean {
|
||||||
|
return node.data.kind === "region" || String(node.id || "").startsWith("region:");
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeApiPosition(node: Node<NeNodeData>): { x: number; y: number } {
|
||||||
|
const isRegion = isRegionNode(node);
|
||||||
|
const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X;
|
||||||
|
const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y;
|
||||||
|
return { x: node.position.x + hx, y: node.position.y + hy };
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeGlyphBox(node: Node<NeNodeData>): { x: number; y: number; w: number; h: number } {
|
||||||
|
const isRegion = isRegionNode(node);
|
||||||
|
const w = TOPO_ICON;
|
||||||
|
const h = isRegion ? TOPO_REGION_ICON_H : TOPO_ICON;
|
||||||
|
return {
|
||||||
|
x: node.position.x + (TOPO_NODE_W - w) / 2,
|
||||||
|
y: node.position.y,
|
||||||
|
w,
|
||||||
|
h,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeHandleCenter(node: Node<NeNodeData>): { x: number; y: number } {
|
||||||
|
const box = nodeGlyphBox(node);
|
||||||
|
return { x: box.x + box.w / 2, y: box.y + box.h / 2 };
|
||||||
|
}
|
||||||
|
|
||||||
|
function vendorColor(theme: TopologyVectorSvgTheme, data: NeNodeData): string {
|
||||||
|
return vendorColorForNode(data, theme.vendorColors);
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlEscape(value: string): string {
|
||||||
|
return String(value ?? "")
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """)
|
||||||
|
.replace(/'/g, "'");
|
||||||
|
}
|
||||||
|
|
||||||
|
function xmlAttr(name: string, value: string | number | boolean | null | undefined): string {
|
||||||
|
if (value === null || value === undefined || value === "") return "";
|
||||||
|
return ` ${name}="${xmlEscape(String(value))}"`;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Bounds = { minX: number; minY: number; maxX: number; maxY: number };
|
||||||
|
|
||||||
|
function growBounds(bounds: Bounds, x: number, y: number, pad = 0): void {
|
||||||
|
bounds.minX = Math.min(bounds.minX, x - pad);
|
||||||
|
bounds.minY = Math.min(bounds.minY, y - pad);
|
||||||
|
bounds.maxX = Math.max(bounds.maxX, x + pad);
|
||||||
|
bounds.maxY = Math.max(bounds.maxY, y + pad);
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeCaptionLines(node: Node<NeNodeData>, theme: TopologyVectorSvgTheme): string[] {
|
||||||
|
const data = node.data;
|
||||||
|
const isRegion = isRegionNode(node);
|
||||||
|
const name = data.label || (!theme.hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE");
|
||||||
|
const lines = [name];
|
||||||
|
if (!isRegion) {
|
||||||
|
const meta = [
|
||||||
|
theme.hideIp || !data.ne_ip || data.ne_ip === name ? "" : data.ne_ip,
|
||||||
|
theme.hideVendor || !data.vendor ? "" : data.vendor,
|
||||||
|
].filter(Boolean);
|
||||||
|
if (meta.length) lines.push(meta.join(" / "));
|
||||||
|
}
|
||||||
|
return lines;
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeGraphBounds(
|
||||||
|
nodes: Node<NeNodeData>[],
|
||||||
|
displayEdges: Edge[],
|
||||||
|
centers: Map<string, { x: number; y: number }>,
|
||||||
|
theme: TopologyVectorSvgTheme,
|
||||||
|
): Bounds {
|
||||||
|
const bounds: Bounds = { minX: Infinity, minY: Infinity, maxX: -Infinity, maxY: -Infinity };
|
||||||
|
for (const node of nodes) {
|
||||||
|
const box = nodeGlyphBox(node);
|
||||||
|
growBounds(bounds, box.x, box.y);
|
||||||
|
growBounds(bounds, box.x + box.w, box.y + box.h);
|
||||||
|
const lines = nodeCaptionLines(node, theme);
|
||||||
|
const captionY = box.y + box.h + 1;
|
||||||
|
for (let i = 0; i < lines.length; i += 1) {
|
||||||
|
const line = lines[i];
|
||||||
|
const approxWidth = Math.max(24, line.length * (i === 0 ? 5 : 4.2));
|
||||||
|
const lineY = captionY + i * (i === 0 ? 10 : 8);
|
||||||
|
growBounds(bounds, box.x + box.w / 2 - approxWidth / 2, lineY);
|
||||||
|
growBounds(bounds, box.x + box.w / 2 + approxWidth / 2, lineY + (i === 0 ? 10 : 8));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const edge of displayEdges) {
|
||||||
|
const a = centers.get(edge.source);
|
||||||
|
const b = centers.get(edge.target);
|
||||||
|
if (!a || !b) continue;
|
||||||
|
growBounds(bounds, a.x, a.y);
|
||||||
|
growBounds(bounds, b.x, b.y);
|
||||||
|
const label = String(edge.label || "").trim();
|
||||||
|
if (label) {
|
||||||
|
const mx = (a.x + b.x) / 2;
|
||||||
|
const my = (a.y + b.y) / 2;
|
||||||
|
growBounds(bounds, mx, my, Math.max(20, label.length * 3));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(bounds.minX)) {
|
||||||
|
return { minX: 0, minY: 0, maxX: 800, maxY: 600 };
|
||||||
|
}
|
||||||
|
return bounds;
|
||||||
|
}
|
||||||
|
|
||||||
|
function edgeVisual(edge: Edge): { stroke: string; strokeWidth: number; strokeDasharray?: string } {
|
||||||
|
const style = (edge.style || {}) as {
|
||||||
|
stroke?: string;
|
||||||
|
strokeWidth?: number;
|
||||||
|
strokeDasharray?: string;
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
stroke: String(style.stroke || "#64748b"),
|
||||||
|
strokeWidth: Number(style.strokeWidth || 2),
|
||||||
|
strokeDasharray: style.strokeDasharray ? String(style.strokeDasharray) : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNodeIcon(
|
||||||
|
node: Node<NeNodeData>,
|
||||||
|
theme: TopologyVectorSvgTheme,
|
||||||
|
icons: IconAssets,
|
||||||
|
): string {
|
||||||
|
const box = nodeGlyphBox(node);
|
||||||
|
if (isRegionNode(node)) {
|
||||||
|
return `<image href="${icons.regionPlain}" x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" preserveAspectRatio="xMidYMid meet"/>`;
|
||||||
|
}
|
||||||
|
const color = vendorColor(theme, node.data);
|
||||||
|
const href = icons.neTinted.get(color);
|
||||||
|
if (!href) return "";
|
||||||
|
return `<image href="${href}" x="${box.x}" y="${box.y}" width="${box.w}" height="${box.h}" preserveAspectRatio="xMidYMid meet"/>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderNode(node: Node<NeNodeData>, theme: TopologyVectorSvgTheme, icons: IconAssets): string {
|
||||||
|
const box = nodeGlyphBox(node);
|
||||||
|
const centerX = box.x + box.w / 2;
|
||||||
|
const icon = renderNodeIcon(node, theme, icons);
|
||||||
|
const lines = nodeCaptionLines(node, theme);
|
||||||
|
const captionParts = lines.map((line, idx) => {
|
||||||
|
const y = box.y + box.h + 1 + (idx === 0 ? 8 : 8 + idx * 8);
|
||||||
|
const fontSize = idx === 0 ? 8 : 7;
|
||||||
|
const opacity = idx === 0 ? 1 : 0.72;
|
||||||
|
return `<text x="${centerX}" y="${y}" text-anchor="middle" font-family="Segoe UI, system-ui, sans-serif" font-size="${fontSize}" font-weight="${idx === 0 ? 600 : 400}" fill="${xmlEscape(theme.labelColor)}" opacity="${opacity}">${xmlEscape(line)}</text>`;
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
`<g id="node-${xmlEscape(node.id)}" class="topo-export-node">`,
|
||||||
|
icon,
|
||||||
|
...captionParts,
|
||||||
|
`</g>`,
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEdge(
|
||||||
|
edge: Edge,
|
||||||
|
centers: Map<string, { x: number; y: number }>,
|
||||||
|
theme: TopologyVectorSvgTheme,
|
||||||
|
): string {
|
||||||
|
const a = centers.get(edge.source);
|
||||||
|
const b = centers.get(edge.target);
|
||||||
|
if (!a || !b) return "";
|
||||||
|
const data = (edge.data || {}) as LinkEdgeData;
|
||||||
|
const index = Number(data.parallelIndex || 0);
|
||||||
|
const count = Math.max(1, Number(data.parallelCount || 1));
|
||||||
|
const offset = (index - (count - 1) / 2) * 10;
|
||||||
|
const dx = b.x - a.x;
|
||||||
|
const dy = b.y - a.y;
|
||||||
|
const len = Math.hypot(dx, dy) || 1;
|
||||||
|
const ox = (-dy / len) * offset;
|
||||||
|
const oy = (dx / len) * offset;
|
||||||
|
const sx = a.x + ox;
|
||||||
|
const sy = a.y + oy;
|
||||||
|
const tx = b.x + ox;
|
||||||
|
const ty = b.y + oy;
|
||||||
|
const visual = edgeVisual(edge);
|
||||||
|
const dash = visual.strokeDasharray
|
||||||
|
? ` stroke-dasharray="${xmlEscape(visual.strokeDasharray)}"`
|
||||||
|
: "";
|
||||||
|
const [path, labelX, labelY] = getStraightPath({ sourceX: sx, sourceY: sy, targetX: tx, targetY: ty });
|
||||||
|
const label = String(edge.label || "").trim();
|
||||||
|
const labelMarkup = label
|
||||||
|
? `<text x="${labelX}" y="${labelY}" text-anchor="middle" dominant-baseline="middle" font-family="ui-monospace, SFMono-Regular, Consolas, monospace" font-size="7" fill="${xmlEscape(theme.edgeLabelColor)}">${xmlEscape(label)}</text>`
|
||||||
|
: "";
|
||||||
|
return [
|
||||||
|
`<g id="edge-${xmlEscape(edge.id)}" class="topo-export-edge">`,
|
||||||
|
`<path d="${xmlEscape(path)}" fill="none" stroke="${xmlEscape(visual.stroke)}" stroke-width="${visual.strokeWidth}" stroke-linecap="round"${dash}/>`,
|
||||||
|
labelMarkup,
|
||||||
|
`</g>`,
|
||||||
|
].join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build a vector SVG using the same PNG icons + vendor tint as the canvas. */
|
||||||
|
export async function buildTopologyVectorSvg(
|
||||||
|
nodes: Node<NeNodeData>[],
|
||||||
|
displayEdges: Edge[],
|
||||||
|
theme: TopologyVectorSvgTheme,
|
||||||
|
title?: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const icons = await prepareIconAssets(nodes, theme);
|
||||||
|
const centers = new Map(nodes.map((n) => [n.id, nodeHandleCenter(n)] as const));
|
||||||
|
const bounds = computeGraphBounds(nodes, displayEdges, centers, theme);
|
||||||
|
const pad = 48;
|
||||||
|
const minX = bounds.minX - pad;
|
||||||
|
const minY = bounds.minY - pad;
|
||||||
|
const width = bounds.maxX - bounds.minX + pad * 2;
|
||||||
|
const height = bounds.maxY - bounds.minY + pad * 2;
|
||||||
|
const edgeMarkup = displayEdges.map((e) => renderEdge(e, centers, theme)).join("");
|
||||||
|
const nodeMarkup = nodes.map((n) => renderNode(n, theme, icons)).join("");
|
||||||
|
const titleMarkup = title ? `<title>${xmlEscape(title)}</title>` : "";
|
||||||
|
return [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
`<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="${minX} ${minY} ${width} ${height}" width="${Math.round(width)}" height="${Math.round(height)}">`,
|
||||||
|
titleMarkup,
|
||||||
|
`<desc>netx topology vector export</desc>`,
|
||||||
|
`<g id="edges">${edgeMarkup}</g>`,
|
||||||
|
`<g id="nodes">${nodeMarkup}</g>`,
|
||||||
|
`</svg>`,
|
||||||
|
].join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildExportGraph(
|
||||||
|
view: TopologyViewItem,
|
||||||
|
nodes: Node<NeNodeData>[],
|
||||||
|
edges: Edge[],
|
||||||
|
serverGraph?: TopologyViewGraph | null,
|
||||||
|
): TopologyViewGraph {
|
||||||
|
const serverNodeMap = new Map(
|
||||||
|
(serverGraph?.nodes || []).map((n) => [n.fabric_node_id, n] as const),
|
||||||
|
);
|
||||||
|
const serverEdgeMap = new Map((serverGraph?.edges || []).map((e) => [e.id, e] as const));
|
||||||
|
|
||||||
|
const exportNodes: TopologyViewNodeItem[] = nodes.map((n) => {
|
||||||
|
const pos = nodeApiPosition(n);
|
||||||
|
const server = serverNodeMap.get(n.id);
|
||||||
|
return {
|
||||||
|
fabric_node_id: n.id,
|
||||||
|
managed_ne_id: n.data.managed_ne_id || server?.managed_ne_id || "",
|
||||||
|
ume_ne_id: n.data.ume_ne_id || server?.ume_ne_id || "",
|
||||||
|
label: n.data.label || server?.label || "",
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
locked: server?.locked || false,
|
||||||
|
name: server?.name || n.data.label || "",
|
||||||
|
ip: n.data.ne_ip || server?.ip || "",
|
||||||
|
vendor: n.data.vendor || server?.vendor || "",
|
||||||
|
device_type: server?.device_type || (isRegionNode(n) ? "region" : ""),
|
||||||
|
connect_status: n.data.connect_status || server?.connect_status || "",
|
||||||
|
managed_source: n.data.managed_source || server?.managed_source,
|
||||||
|
kind: n.data.kind || server?.kind,
|
||||||
|
folder_id: n.data.folder_id || server?.folder_id,
|
||||||
|
view_id: n.data.view_id || server?.view_id,
|
||||||
|
node_count: n.data.node_count ?? server?.node_count,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const physicalEdges = edges.filter((e) => !isAggregateEdgeId(String(e.id)));
|
||||||
|
const exportEdges = physicalEdges.map((e) => {
|
||||||
|
const data = (e.data || {}) as LinkEdgeData;
|
||||||
|
const server = serverEdgeMap.get(e.id);
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
a_node_id: e.source,
|
||||||
|
b_node_id: e.target,
|
||||||
|
a_port: data.source_port || server?.a_port || "",
|
||||||
|
b_port: data.target_port || server?.b_port || "",
|
||||||
|
source: data.source || server?.source || "manual",
|
||||||
|
status: server?.status || "active",
|
||||||
|
layer: server?.layer || "physical",
|
||||||
|
stroke_color: data.stroke_color || server?.stroke_color,
|
||||||
|
stroke_width: data.stroke_width || server?.stroke_width,
|
||||||
|
line_style: data.line_style || server?.line_style,
|
||||||
|
discovered_at: data.discovered_at ?? server?.discovered_at ?? null,
|
||||||
|
display_label: data.display_label || server?.display_label,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
view,
|
||||||
|
nodes: exportNodes,
|
||||||
|
edges: exportEdges,
|
||||||
|
truncated: serverGraph?.truncated,
|
||||||
|
truncate_reason: serverGraph?.truncate_reason,
|
||||||
|
outside_peers: serverGraph?.outside_peers,
|
||||||
|
world_transform: serverGraph?.world_transform,
|
||||||
|
scatter: serverGraph?.scatter,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function topologyGraphToXml(graph: TopologyViewGraph): string {
|
||||||
|
const view = graph.view;
|
||||||
|
const lines: string[] = [
|
||||||
|
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||||
|
`<topology xmlns="urn:netx:topology:export" version="1"${xmlAttr("exported-at", new Date().toISOString())}>`,
|
||||||
|
" <view",
|
||||||
|
`${xmlAttr("id", view.id)}${xmlAttr("name", view.name)}${xmlAttr("kind", view.kind)}${xmlAttr("folder_id", view.folder_id)}${xmlAttr("remark", view.remark || "")} />`,
|
||||||
|
` <nodes count="${graph.nodes.length}">`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const n of graph.nodes) {
|
||||||
|
lines.push(
|
||||||
|
` <node${xmlAttr("id", n.fabric_node_id)}${xmlAttr("x", n.x)}${xmlAttr("y", n.y)}${xmlAttr("label", n.label)}${xmlAttr("name", n.name)}${xmlAttr("ip", n.ip)}${xmlAttr("vendor", n.vendor)}${xmlAttr("kind", n.kind)}${xmlAttr("managed_ne_id", n.managed_ne_id)}${xmlAttr("ume_ne_id", n.ume_ne_id)}${xmlAttr("folder_id", n.folder_id)}${xmlAttr("view_id", n.view_id)} />`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push(" </nodes>");
|
||||||
|
lines.push(` <edges count="${graph.edges.length}">`);
|
||||||
|
|
||||||
|
for (const e of graph.edges) {
|
||||||
|
lines.push(
|
||||||
|
` <edge${xmlAttr("id", e.id)}${xmlAttr("a_node_id", e.a_node_id)}${xmlAttr("b_node_id", e.b_node_id)}${xmlAttr("a_port", e.a_port)}${xmlAttr("b_port", e.b_port)}${xmlAttr("source", e.source)}${xmlAttr("status", e.status)}${xmlAttr("layer", e.layer)}${xmlAttr("display_label", e.display_label || "")} />`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
lines.push(" </edges>");
|
||||||
|
lines.push("</topology>");
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function exportTopologySvg(
|
||||||
|
nodes: Node<NeNodeData>[],
|
||||||
|
displayEdges: Edge[],
|
||||||
|
filenameBase: string,
|
||||||
|
theme: TopologyVectorSvgTheme,
|
||||||
|
title?: string,
|
||||||
|
): Promise<void> {
|
||||||
|
const svg = await buildTopologyVectorSvg(nodes, displayEdges, theme, title);
|
||||||
|
downloadText(`${sanitizeFilename(filenameBase)}.svg`, svg, "image/svg+xml;charset=utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function exportTopologyXml(graph: TopologyViewGraph, filenameBase: string): void {
|
||||||
|
const xml = topologyGraphToXml(graph);
|
||||||
|
downloadText(`${sanitizeFilename(filenameBase)}.xml`, xml, "application/xml;charset=utf-8");
|
||||||
|
}
|
||||||
135
web/src/pages/topology/graphFlow.ts
Normal file
135
web/src/pages/topology/graphFlow.ts
Normal file
|
|
@ -0,0 +1,135 @@
|
||||||
|
import type { Edge, Node, Position } from "@xyflow/react";
|
||||||
|
import type { TopologyViewEdgeItem, TopologyViewGraph, TopologyViewNodeItem } from "../../types";
|
||||||
|
import type { NeNodeData } from "./TopologyReactFlowView";
|
||||||
|
import { formatPortPairLabel } from "./linkDisplay";
|
||||||
|
import type { EdgeDefaults, EdgeStyleData } from "./edgeStyle";
|
||||||
|
import { withEdgeVisual } from "./edgeStyle";
|
||||||
|
import {
|
||||||
|
TOPO_NODE_H,
|
||||||
|
TOPO_NODE_W,
|
||||||
|
TOPO_REGION_HANDLE_X,
|
||||||
|
TOPO_REGION_HANDLE_Y,
|
||||||
|
TOPO_REGION_ICON_H,
|
||||||
|
TOPO_HANDLE_X,
|
||||||
|
TOPO_HANDLE_Y,
|
||||||
|
neApiPosition,
|
||||||
|
neFlowPosition,
|
||||||
|
regionApiPosition,
|
||||||
|
regionFlowPosition,
|
||||||
|
} from "./topoGeometry";
|
||||||
|
|
||||||
|
export function graphToFlow(
|
||||||
|
nodes: TopologyViewNodeItem[],
|
||||||
|
edges: TopologyViewEdgeItem[],
|
||||||
|
defaults: EdgeDefaults,
|
||||||
|
) {
|
||||||
|
const rfNodes: Node<NeNodeData>[] = nodes.map((n) => {
|
||||||
|
const isRegion =
|
||||||
|
n.kind === "region" || n.device_type === "region" || String(n.fabric_node_id || "").startsWith("region:");
|
||||||
|
const apiX = Number(n.x) || 0;
|
||||||
|
const apiY = Number(n.y) || 0;
|
||||||
|
const position = isRegion ? regionFlowPosition(apiX, apiY) : neFlowPosition(apiX, apiY);
|
||||||
|
const hx = isRegion ? TOPO_REGION_HANDLE_X : TOPO_HANDLE_X;
|
||||||
|
const hy = isRegion ? TOPO_REGION_HANDLE_Y : TOPO_HANDLE_Y;
|
||||||
|
return {
|
||||||
|
id: n.fabric_node_id,
|
||||||
|
type: "neNode",
|
||||||
|
position,
|
||||||
|
width: TOPO_NODE_W,
|
||||||
|
height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H,
|
||||||
|
handles: [
|
||||||
|
{ type: "target", position: "left" as Position, x: hx, y: hy },
|
||||||
|
{ type: "source", position: "right" as Position, x: hx, y: hy },
|
||||||
|
],
|
||||||
|
data: {
|
||||||
|
label: n.label || n.name || n.ip || n.fabric_node_id,
|
||||||
|
managed_ne_id: n.managed_ne_id || "",
|
||||||
|
ume_ne_id: n.ume_ne_id || "",
|
||||||
|
ne_ip: n.ip || "",
|
||||||
|
vendor: n.vendor || "",
|
||||||
|
connect_status: n.connect_status || "",
|
||||||
|
managed_source: n.managed_source || "",
|
||||||
|
kind: (n.kind as NeNodeData["kind"]) || (isRegion ? "region" : "ne"),
|
||||||
|
folder_id: n.folder_id || "",
|
||||||
|
view_id: n.view_id || "",
|
||||||
|
node_count: n.node_count || 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const rfEdges: Edge[] = edges.map((e) => {
|
||||||
|
const src = e.status === "stale" || e.status === "missing" ? "stale" : e.source || "manual";
|
||||||
|
const label = formatPortPairLabel(e.a_port || "", e.b_port || "", e.display_label || "");
|
||||||
|
const data: EdgeStyleData = {
|
||||||
|
source: src,
|
||||||
|
source_port: e.a_port || "",
|
||||||
|
target_port: e.b_port || "",
|
||||||
|
stroke_color: e.stroke_color || "",
|
||||||
|
stroke_width: Number(e.stroke_width || 0),
|
||||||
|
line_style: e.line_style || "",
|
||||||
|
discovered_at: e.discovered_at ?? null,
|
||||||
|
display_label: e.display_label || "",
|
||||||
|
};
|
||||||
|
return withEdgeVisual(
|
||||||
|
{
|
||||||
|
id: e.id,
|
||||||
|
source: e.a_node_id,
|
||||||
|
target: e.b_node_id,
|
||||||
|
type: "straight",
|
||||||
|
label: label || undefined,
|
||||||
|
animated: false,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
defaults,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
return { rfNodes, rfEdges };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flowToPositions(nodes: Node<NeNodeData>[]) {
|
||||||
|
return nodes.map((n) => {
|
||||||
|
const isRegion = n.data.kind === "region" || String(n.id || "").startsWith("region:");
|
||||||
|
const pos = isRegion
|
||||||
|
? regionApiPosition(n.position.x, n.position.y)
|
||||||
|
: neApiPosition(n.position.x, n.position.y);
|
||||||
|
return {
|
||||||
|
fabric_node_id: n.id,
|
||||||
|
x: pos.x,
|
||||||
|
y: pos.y,
|
||||||
|
label: n.data.label || "",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function graphFingerprint(graph: TopologyViewGraph): string {
|
||||||
|
const nodes = [...graph.nodes]
|
||||||
|
.map(
|
||||||
|
(n) =>
|
||||||
|
`${n.fabric_node_id}:${Math.round(Number(n.x) || 0)}:${Math.round(Number(n.y) || 0)}:${n.label || n.name || ""}`,
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
const edges = [...graph.edges]
|
||||||
|
.map((e) => `${e.id}:${e.a_node_id}:${e.b_node_id}:${e.a_port || ""}:${e.b_port || ""}:${e.status || ""}`)
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
return `${nodes}#${edges}#${graph.outside_peers?.length || 0}#${graph.world_transform?.lod || ""}#${graph.scatter?.length || 0}#${graph.world_transform?.total || 0}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyViewGraph(
|
||||||
|
graph: TopologyViewGraph,
|
||||||
|
defaults: EdgeDefaults,
|
||||||
|
setNodes: (ns: Node<NeNodeData>[]) => void,
|
||||||
|
setEdges: (es: Edge[]) => void,
|
||||||
|
localPositions?: Map<string, { x: number; y: number }>,
|
||||||
|
) {
|
||||||
|
const { rfNodes, rfEdges } = graphToFlow(graph.nodes, graph.edges, defaults);
|
||||||
|
const merged = localPositions?.size
|
||||||
|
? rfNodes.map((n) => {
|
||||||
|
const p = localPositions.get(n.id);
|
||||||
|
return p ? { ...n, position: { ...p } } : n;
|
||||||
|
})
|
||||||
|
: rfNodes;
|
||||||
|
setNodes(merged);
|
||||||
|
setEdges(rfEdges);
|
||||||
|
return { rfNodes: merged, rfEdges };
|
||||||
|
}
|
||||||
560
web/src/pages/topology/hooks/useTopologyCanvas.ts
Normal file
560
web/src/pages/topology/hooks/useTopologyCanvas.ts
Normal file
|
|
@ -0,0 +1,560 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useQuery, type QueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
|
||||||
|
import { fetchTopologyGraph } from "../../../services/api";
|
||||||
|
import { queryKeys } from "../../../constants/queryKeys";
|
||||||
|
import type { TopologyTreeViewItem, TopologyViewGraph, TopologyWorldTransform } from "../../../types";
|
||||||
|
import {
|
||||||
|
FIT_VIEW_OPTS,
|
||||||
|
WORLD_LOCATE_HALF,
|
||||||
|
WORLD_LOCATE_ZOOM,
|
||||||
|
WORLD_MAP_ENABLED,
|
||||||
|
} from "../constants";
|
||||||
|
import type { EdgeDefaults, EdgeStyleData } from "../edgeStyle";
|
||||||
|
import { withEdgeVisual } from "../edgeStyle";
|
||||||
|
import { graphFingerprint, graphToFlow } from "../graphFlow";
|
||||||
|
import {
|
||||||
|
buildLinkDisplayEdges,
|
||||||
|
type LinkMember,
|
||||||
|
} from "../linkDisplay";
|
||||||
|
import type { NeNodeData } from "../TopologyReactFlowView";
|
||||||
|
import { TOPO_HANDLE_X, TOPO_HANDLE_Y } from "../topoGeometry";
|
||||||
|
import type { ToolMode } from "../toolMode";
|
||||||
|
import {
|
||||||
|
displayViewName,
|
||||||
|
isWorldFlatViewName,
|
||||||
|
worldDisplayBounds,
|
||||||
|
worldVisualLodFromZoom,
|
||||||
|
} from "../treeUtils";
|
||||||
|
import { mergeFlatWorldGraph } from "../worldGraph";
|
||||||
|
import type { HistorySnap } from "../pageTypes";
|
||||||
|
|
||||||
|
export type UseTopologyCanvasOptions = {
|
||||||
|
mapId: string;
|
||||||
|
treeFlatMap: boolean;
|
||||||
|
liveSync: boolean;
|
||||||
|
canvasMode: boolean;
|
||||||
|
activeView: TopologyTreeViewItem | null;
|
||||||
|
nodes: Node<NeNodeData>[];
|
||||||
|
edges: Edge[];
|
||||||
|
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
|
||||||
|
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
|
||||||
|
edgeDefaults: EdgeDefaults;
|
||||||
|
toolMode: ToolMode;
|
||||||
|
hideIp: boolean;
|
||||||
|
hideVendor: boolean;
|
||||||
|
hidePorts: boolean;
|
||||||
|
showPlaceholderBadge: boolean;
|
||||||
|
expandPhysicalLinks: boolean;
|
||||||
|
scaleBundleWidth: boolean;
|
||||||
|
edgeFlow: boolean;
|
||||||
|
selectedEdgeId: string | null;
|
||||||
|
setSelectedEdgeId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||||
|
setSearchHitIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||||
|
searchHitTimerRef: React.MutableRefObject<number | null>;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
dirtyRef: React.MutableRefObject<boolean>;
|
||||||
|
historyLockRef: React.MutableRefObject<boolean>;
|
||||||
|
historyRef: React.MutableRefObject<HistorySnap[]>;
|
||||||
|
redoRef: React.MutableRefObject<HistorySnap[]>;
|
||||||
|
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
|
||||||
|
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
|
||||||
|
clearDirty: () => void;
|
||||||
|
bumpHistory: () => void;
|
||||||
|
showError: (msg: string) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
|
const {
|
||||||
|
mapId,
|
||||||
|
treeFlatMap,
|
||||||
|
liveSync,
|
||||||
|
canvasMode,
|
||||||
|
activeView,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
edgeDefaults,
|
||||||
|
toolMode,
|
||||||
|
hideIp,
|
||||||
|
hideVendor,
|
||||||
|
hidePorts,
|
||||||
|
showPlaceholderBadge,
|
||||||
|
expandPhysicalLinks,
|
||||||
|
scaleBundleWidth,
|
||||||
|
edgeFlow,
|
||||||
|
selectedEdgeId,
|
||||||
|
setSelectedEdgeId,
|
||||||
|
setSearchHitIds,
|
||||||
|
searchHitTimerRef,
|
||||||
|
queryClient,
|
||||||
|
dirtyRef,
|
||||||
|
historyLockRef,
|
||||||
|
historyRef,
|
||||||
|
redoRef,
|
||||||
|
pendingEdgeDeletesRef,
|
||||||
|
pendingEdgeCreatesRef,
|
||||||
|
clearDirty,
|
||||||
|
bumpHistory,
|
||||||
|
showError,
|
||||||
|
t,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const rfRef = useRef<ReactFlowInstance<Node<NeNodeData>, Edge> | null>(null);
|
||||||
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const appliedMapIdRef = useRef("");
|
||||||
|
const appliedGraphFpRef = useRef("");
|
||||||
|
const pendingFitRef = useRef(false);
|
||||||
|
const nodesRef = useRef<Node<NeNodeData>[]>([]);
|
||||||
|
const [canvasZoom, setCanvasZoom] = useState(1);
|
||||||
|
const worldTransformRef = useRef<TopologyWorldTransform | null>(null);
|
||||||
|
const flatLodFetchGenRef = useRef(0);
|
||||||
|
const flatLodTimerRef = useRef<number | null>(null);
|
||||||
|
const isWorldFlatCanvasRef = useRef(false);
|
||||||
|
const mapIdRef = useRef(mapId);
|
||||||
|
|
||||||
|
nodesRef.current = nodes;
|
||||||
|
|
||||||
|
const graphQuery = useQuery({
|
||||||
|
queryKey: queryKeys.topologyGraph(mapId),
|
||||||
|
queryFn: () =>
|
||||||
|
fetchTopologyGraph(
|
||||||
|
mapId,
|
||||||
|
WORLD_MAP_ENABLED && treeFlatMap ? { lod: "overview" } : undefined,
|
||||||
|
),
|
||||||
|
enabled: Boolean(mapId) && (WORLD_MAP_ENABLED || !treeFlatMap),
|
||||||
|
staleTime: liveSync ? 0 : 30_000,
|
||||||
|
refetchOnWindowFocus: liveSync && !treeFlatMap,
|
||||||
|
refetchInterval: liveSync && mapId && !treeFlatMap ? 3000 : false,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const isWorldFlatCanvas =
|
||||||
|
WORLD_MAP_ENABLED &&
|
||||||
|
(treeFlatMap ||
|
||||||
|
isWorldFlatViewName(graphQuery.data?.view?.name) ||
|
||||||
|
Boolean(graphQuery.data?.view?.filter?.world_flat));
|
||||||
|
|
||||||
|
isWorldFlatCanvasRef.current = isWorldFlatCanvas;
|
||||||
|
mapIdRef.current = mapId;
|
||||||
|
|
||||||
|
const worldVisualLod = isWorldFlatCanvas ? worldVisualLodFromZoom(canvasZoom) : "full";
|
||||||
|
|
||||||
|
const displayOpts = useMemo(
|
||||||
|
() => ({
|
||||||
|
hideIp,
|
||||||
|
hideVendor,
|
||||||
|
hidePorts,
|
||||||
|
connectMode: toolMode === "connect",
|
||||||
|
showPlaceholderBadge,
|
||||||
|
worldVisualLod,
|
||||||
|
}),
|
||||||
|
[hideIp, hideVendor, hidePorts, toolMode, showPlaceholderBadge, worldVisualLod],
|
||||||
|
);
|
||||||
|
|
||||||
|
const displayEdges = useMemo(() => {
|
||||||
|
if (isWorldFlatCanvas && worldVisualLod !== "full") return [];
|
||||||
|
const built = buildLinkDisplayEdges(edges, expandPhysicalLinks, hidePorts, scaleBundleWidth).map((e) =>
|
||||||
|
withEdgeVisual(e, edgeDefaults),
|
||||||
|
);
|
||||||
|
return built.map((e) => {
|
||||||
|
const d = (e.data || {}) as EdgeStyleData;
|
||||||
|
const selected =
|
||||||
|
e.id === selectedEdgeId ||
|
||||||
|
Boolean(d.members?.some((m: LinkMember) => m.id === selectedEdgeId));
|
||||||
|
return {
|
||||||
|
...e,
|
||||||
|
selected,
|
||||||
|
label: e.label ? String(e.label) : "",
|
||||||
|
animated: edgeFlow,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [
|
||||||
|
edges,
|
||||||
|
expandPhysicalLinks,
|
||||||
|
hidePorts,
|
||||||
|
scaleBundleWidth,
|
||||||
|
edgeFlow,
|
||||||
|
edgeDefaults,
|
||||||
|
selectedEdgeId,
|
||||||
|
isWorldFlatCanvas,
|
||||||
|
worldVisualLod,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (graphQuery.data?.world_transform) {
|
||||||
|
worldTransformRef.current = graphQuery.data.world_transform;
|
||||||
|
}
|
||||||
|
}, [graphQuery.data?.world_transform]);
|
||||||
|
|
||||||
|
const refreshFlatViewport = useCallback(async () => {
|
||||||
|
if (!WORLD_MAP_ENABLED || !mapId || !isWorldFlatCanvas || dirtyRef.current) return;
|
||||||
|
const inst = rfRef.current;
|
||||||
|
if (!inst) return;
|
||||||
|
const zoom = inst.getZoom();
|
||||||
|
setCanvasZoom(zoom);
|
||||||
|
const gen = ++flatLodFetchGenRef.current;
|
||||||
|
const commit = (g: TopologyViewGraph, centerX: number, centerY: number) => {
|
||||||
|
if (g.world_transform) worldTransformRef.current = g.world_transform;
|
||||||
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), (prev: TopologyViewGraph | undefined) =>
|
||||||
|
mergeFlatWorldGraph(prev, g, { centerX, centerY }),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
try {
|
||||||
|
if (zoom < 0.12) {
|
||||||
|
const prev = queryClient.getQueryData<TopologyViewGraph>(queryKeys.topologyGraph(mapId));
|
||||||
|
if (!prev?.scatter?.length) {
|
||||||
|
const g = await fetchTopologyGraph(mapId, { lod: "overview" });
|
||||||
|
if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return;
|
||||||
|
const bounds = worldDisplayBounds(g.world_transform);
|
||||||
|
const cx = bounds ? bounds.x + bounds.width / 2 : 0;
|
||||||
|
const cy = bounds ? bounds.y + bounds.height / 2 : 0;
|
||||||
|
commit(g, cx, cy);
|
||||||
|
} else if (prev.nodes.length > 0) {
|
||||||
|
commit(
|
||||||
|
{
|
||||||
|
...prev,
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
scatter: prev.scatter,
|
||||||
|
world_transform: prev.world_transform
|
||||||
|
? { ...prev.world_transform, lod: "overview" }
|
||||||
|
: prev.world_transform,
|
||||||
|
},
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const pane = canvasRef.current?.getBoundingClientRect();
|
||||||
|
const w = pane?.width || 1200;
|
||||||
|
const h = pane?.height || 800;
|
||||||
|
const vp = inst.getViewport();
|
||||||
|
const z = Math.max(vp.zoom || 0.01, 0.01);
|
||||||
|
const min_x = -vp.x / z;
|
||||||
|
const min_y = -vp.y / z;
|
||||||
|
const max_x = (w - vp.x) / z;
|
||||||
|
const max_y = (h - vp.y) / z;
|
||||||
|
const g = await fetchTopologyGraph(mapId, {
|
||||||
|
lod: "detail",
|
||||||
|
min_x,
|
||||||
|
max_x,
|
||||||
|
min_y,
|
||||||
|
max_y,
|
||||||
|
});
|
||||||
|
if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return;
|
||||||
|
commit(g, (min_x + max_x) / 2, (min_y + max_y) / 2);
|
||||||
|
} catch {
|
||||||
|
/* keep last good sample */
|
||||||
|
}
|
||||||
|
}, [mapId, isWorldFlatCanvas, queryClient, dirtyRef]);
|
||||||
|
|
||||||
|
const scheduleFlatViewportRefresh = useCallback(() => {
|
||||||
|
if (!WORLD_MAP_ENABLED || !isWorldFlatCanvas) return;
|
||||||
|
if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current);
|
||||||
|
flatLodTimerRef.current = window.setTimeout(() => {
|
||||||
|
flatLodTimerRef.current = null;
|
||||||
|
void refreshFlatViewport();
|
||||||
|
}, 200);
|
||||||
|
}, [isWorldFlatCanvas, refreshFlatViewport]);
|
||||||
|
|
||||||
|
const fitCanvas = useCallback(() => {
|
||||||
|
const inst = rfRef.current;
|
||||||
|
if (!inst) return;
|
||||||
|
const bounds = isWorldFlatCanvas
|
||||||
|
? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform)
|
||||||
|
: null;
|
||||||
|
if (bounds) inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
||||||
|
else inst.fitView({ ...FIT_VIEW_OPTS });
|
||||||
|
setCanvasZoom(inst.getZoom());
|
||||||
|
}, [isWorldFlatCanvas, graphQuery.data?.world_transform]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (flatLodTimerRef.current) window.clearTimeout(flatLodTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasMode) return;
|
||||||
|
void import("../TopologyReactFlowView");
|
||||||
|
}, [canvasMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
appliedMapIdRef.current = "";
|
||||||
|
appliedGraphFpRef.current = "";
|
||||||
|
}, [mapId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!canvasMode) return;
|
||||||
|
if (!mapId || !graphQuery.data) return;
|
||||||
|
const entering = appliedMapIdRef.current !== mapId;
|
||||||
|
if (!entering && dirtyRef.current) return;
|
||||||
|
|
||||||
|
const fp = graphFingerprint(graphQuery.data);
|
||||||
|
if (!entering && fp === appliedGraphFpRef.current) return;
|
||||||
|
|
||||||
|
const selectedIds = entering
|
||||||
|
? new Set<string>()
|
||||||
|
: new Set(nodesRef.current.filter((n) => n.selected).map((n) => n.id));
|
||||||
|
const { rfNodes, rfEdges } = graphToFlow(graphQuery.data.nodes, graphQuery.data.edges, edgeDefaults);
|
||||||
|
const nextNodes =
|
||||||
|
selectedIds.size > 0
|
||||||
|
? rfNodes.map((n) => (selectedIds.has(n.id) ? { ...n, selected: true } : n))
|
||||||
|
: rfNodes;
|
||||||
|
|
||||||
|
historyLockRef.current = true;
|
||||||
|
setNodes(nextNodes);
|
||||||
|
setEdges(rfEdges);
|
||||||
|
appliedGraphFpRef.current = fp;
|
||||||
|
if (entering) {
|
||||||
|
appliedMapIdRef.current = mapId;
|
||||||
|
historyRef.current = [];
|
||||||
|
redoRef.current = [];
|
||||||
|
pendingEdgeDeletesRef.current = new Set();
|
||||||
|
pendingEdgeCreatesRef.current = new Set();
|
||||||
|
clearDirty();
|
||||||
|
bumpHistory();
|
||||||
|
const hasScatter = (graphQuery.data.scatter?.length || 0) > 0;
|
||||||
|
const hasWorld = Boolean(worldDisplayBounds(graphQuery.data.world_transform));
|
||||||
|
pendingFitRef.current = nextNodes.length > 0 || hasScatter || hasWorld;
|
||||||
|
}
|
||||||
|
historyLockRef.current = false;
|
||||||
|
}, [
|
||||||
|
canvasMode,
|
||||||
|
mapId,
|
||||||
|
graphQuery.data,
|
||||||
|
edgeDefaults,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
clearDirty,
|
||||||
|
bumpHistory,
|
||||||
|
dirtyRef,
|
||||||
|
historyLockRef,
|
||||||
|
historyRef,
|
||||||
|
redoRef,
|
||||||
|
pendingEdgeDeletesRef,
|
||||||
|
pendingEdgeCreatesRef,
|
||||||
|
]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!pendingFitRef.current) return;
|
||||||
|
const wt = graphQuery.data?.world_transform;
|
||||||
|
const bounds = isWorldFlatCanvas ? worldDisplayBounds(wt) : null;
|
||||||
|
const canFitNodes = nodes.length > 0;
|
||||||
|
if (!bounds && !canFitNodes) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let tries = 0;
|
||||||
|
const run = () => {
|
||||||
|
if (cancelled) return;
|
||||||
|
const inst = rfRef.current;
|
||||||
|
if (!inst) {
|
||||||
|
if (tries++ < 40) window.setTimeout(run, 40);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
pendingFitRef.current = false;
|
||||||
|
if (bounds) {
|
||||||
|
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
||||||
|
} else {
|
||||||
|
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
|
||||||
|
}
|
||||||
|
setCanvasZoom(inst.getZoom());
|
||||||
|
};
|
||||||
|
const timer = window.setTimeout(run, 0);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearTimeout(timer);
|
||||||
|
};
|
||||||
|
}, [nodes, mapId, isWorldFlatCanvas, graphQuery.data?.world_transform]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setEdges((eds) => eds.map((e) => withEdgeVisual(e, edgeDefaults)));
|
||||||
|
}, [edgeDefaults, setEdges]);
|
||||||
|
|
||||||
|
const focusNode = useCallback(
|
||||||
|
(nodeId: string, additive: boolean) => {
|
||||||
|
setSelectedEdgeId(null);
|
||||||
|
setNodes((ns) =>
|
||||||
|
ns.map((n) => {
|
||||||
|
if (additive) {
|
||||||
|
if (n.id === nodeId) return { ...n, selected: !n.selected };
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
return { ...n, selected: n.id === nodeId };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
setEdges((es) => es.map((e) => ({ ...e, selected: false })));
|
||||||
|
},
|
||||||
|
[setNodes, setEdges, setSelectedEdgeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const focusWorldAround = useCallback(
|
||||||
|
async (nodeId: string, displayX: number, displayY: number) => {
|
||||||
|
const viewId = mapIdRef.current;
|
||||||
|
if (!viewId || !isWorldFlatCanvasRef.current || dirtyRef.current) return;
|
||||||
|
if (flatLodTimerRef.current) {
|
||||||
|
window.clearTimeout(flatLodTimerRef.current);
|
||||||
|
flatLodTimerRef.current = null;
|
||||||
|
}
|
||||||
|
const gen = ++flatLodFetchGenRef.current;
|
||||||
|
const inst = rfRef.current;
|
||||||
|
if (inst) {
|
||||||
|
setCanvasZoom(WORLD_LOCATE_ZOOM);
|
||||||
|
inst.setCenter(displayX, displayY, { zoom: WORLD_LOCATE_ZOOM, duration: 200 });
|
||||||
|
}
|
||||||
|
const half = WORLD_LOCATE_HALF;
|
||||||
|
try {
|
||||||
|
const g = await fetchTopologyGraph(viewId, {
|
||||||
|
lod: "detail",
|
||||||
|
min_x: displayX - half,
|
||||||
|
max_x: displayX + half,
|
||||||
|
min_y: displayY - half,
|
||||||
|
max_y: displayY + half,
|
||||||
|
});
|
||||||
|
if (gen !== flatLodFetchGenRef.current || dirtyRef.current) return;
|
||||||
|
if (g.world_transform) worldTransformRef.current = g.world_transform;
|
||||||
|
queryClient.setQueryData(queryKeys.topologyGraph(viewId), (prev: TopologyViewGraph | undefined) =>
|
||||||
|
mergeFlatWorldGraph(prev, g, { centerX: displayX, centerY: displayY }),
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
/* keep last sample */
|
||||||
|
}
|
||||||
|
if (gen !== flatLodFetchGenRef.current) return;
|
||||||
|
window.requestAnimationFrame(() => {
|
||||||
|
if (flatLodFetchGenRef.current !== gen) return;
|
||||||
|
rfRef.current?.setCenter(displayX, displayY, { zoom: WORLD_LOCATE_ZOOM, duration: 0 });
|
||||||
|
setCanvasZoom(WORLD_LOCATE_ZOOM);
|
||||||
|
focusNode(nodeId, false);
|
||||||
|
setSearchHitIds([nodeId]);
|
||||||
|
if (searchHitTimerRef.current) window.clearTimeout(searchHitTimerRef.current);
|
||||||
|
searchHitTimerRef.current = window.setTimeout(() => {
|
||||||
|
setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur));
|
||||||
|
searchHitTimerRef.current = null;
|
||||||
|
}, 8000);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[queryClient, focusNode, dirtyRef, setSearchHitIds, searchHitTimerRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const locateNode = useCallback(
|
||||||
|
(nodeId: string, opts?: { worldX?: number; worldY?: number }) => {
|
||||||
|
const onCanvas = nodesRef.current.find((n) => n.id === nodeId);
|
||||||
|
const wt = worldTransformRef.current;
|
||||||
|
const originX = Number(wt?.origin_x) || 0;
|
||||||
|
const originY = Number(wt?.origin_y) || 0;
|
||||||
|
const scale = Math.max(Number(wt?.scale) || 1, 1e-9);
|
||||||
|
|
||||||
|
let displayX: number;
|
||||||
|
let displayY: number;
|
||||||
|
if (onCanvas) {
|
||||||
|
displayX = onCanvas.position.x + TOPO_HANDLE_X;
|
||||||
|
displayY = onCanvas.position.y + TOPO_HANDLE_Y;
|
||||||
|
} else if (opts?.worldX != null && opts?.worldY != null) {
|
||||||
|
displayX = (Number(opts.worldX) - originX) * scale;
|
||||||
|
displayY = (Number(opts.worldY) - originY) * scale;
|
||||||
|
} else {
|
||||||
|
showError(t("topology.locateNotOnCanvas"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
focusNode(nodeId, false);
|
||||||
|
setSearchHitIds([nodeId]);
|
||||||
|
if (searchHitTimerRef.current) window.clearTimeout(searchHitTimerRef.current);
|
||||||
|
searchHitTimerRef.current = window.setTimeout(() => {
|
||||||
|
setSearchHitIds((cur) => (cur.length === 1 && cur[0] === nodeId ? [] : cur));
|
||||||
|
searchHitTimerRef.current = null;
|
||||||
|
}, 8000);
|
||||||
|
|
||||||
|
if (isWorldFlatCanvasRef.current) {
|
||||||
|
void focusWorldAround(nodeId, displayX, displayY);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.setTimeout(() => {
|
||||||
|
rfRef.current?.fitView({
|
||||||
|
nodes: [{ id: nodeId }],
|
||||||
|
padding: 0.45,
|
||||||
|
duration: 280,
|
||||||
|
includeHiddenNodes: true,
|
||||||
|
});
|
||||||
|
}, 30);
|
||||||
|
},
|
||||||
|
[focusNode, focusWorldAround, showError, t, setSearchHitIds, searchHitTimerRef],
|
||||||
|
);
|
||||||
|
|
||||||
|
const graphTruncated = Boolean(graphQuery.data?.truncated);
|
||||||
|
const truncateReason = String(graphQuery.data?.truncate_reason || "").trim();
|
||||||
|
const worldScatter = graphQuery.data?.scatter || [];
|
||||||
|
const worldTotal = Number(graphQuery.data?.world_transform?.total || 0);
|
||||||
|
const worldDockMe = Number(graphQuery.data?.world_transform?.dock_me_count || 0);
|
||||||
|
const showWorldScatter =
|
||||||
|
isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0;
|
||||||
|
const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data);
|
||||||
|
const canvasGraphEmpty =
|
||||||
|
Boolean(mapId) &&
|
||||||
|
graphQuery.isSuccess &&
|
||||||
|
!graphQuery.isFetching &&
|
||||||
|
(graphQuery.data?.nodes?.length ?? 0) === 0 &&
|
||||||
|
worldScatter.length === 0 &&
|
||||||
|
nodes.length === 0 &&
|
||||||
|
worldTotal === 0;
|
||||||
|
const worldNeedsApply = isWorldFlatCanvas && canvasGraphEmpty && worldDockMe > 0;
|
||||||
|
const canvasGraphRefreshing =
|
||||||
|
Boolean(mapId) && liveSync && graphQuery.isFetching && Boolean(graphQuery.data) && !canvasGraphLoading;
|
||||||
|
|
||||||
|
const truncateBannerText = useMemo(() => {
|
||||||
|
if (!graphTruncated) return "";
|
||||||
|
if (isWorldFlatViewName(activeView?.name) || Boolean(graphQuery.data?.view?.filter?.world_flat)) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
if (truncateReason === "membership_cap") return t("topology.truncatedMembership");
|
||||||
|
if (truncateReason === "membership_frozen") return t("topology.truncatedFrozen");
|
||||||
|
if (truncateReason === "too_many_view_nodes") return t("topology.truncatedNodes");
|
||||||
|
if (truncateReason === "too_many_edges") return t("topology.truncatedEdges");
|
||||||
|
return t("topology.truncatedGeneric");
|
||||||
|
}, [graphTruncated, truncateReason, activeView?.name, graphQuery.data?.view?.filter?.world_flat, t]);
|
||||||
|
|
||||||
|
const activeLeafName = useMemo(() => {
|
||||||
|
if (!mapId) return "";
|
||||||
|
if (activeView?.name) return displayViewName(activeView.name, t);
|
||||||
|
return displayViewName(graphQuery.data?.view?.name, t);
|
||||||
|
}, [mapId, activeView, graphQuery.data?.view?.name, t]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
rfRef,
|
||||||
|
canvasRef,
|
||||||
|
nodesRef,
|
||||||
|
appliedMapIdRef,
|
||||||
|
pendingFitRef,
|
||||||
|
isWorldFlatCanvasRef,
|
||||||
|
mapIdRef,
|
||||||
|
worldTransformRef,
|
||||||
|
graphQuery,
|
||||||
|
isWorldFlatCanvas,
|
||||||
|
canvasZoom,
|
||||||
|
setCanvasZoom,
|
||||||
|
worldVisualLod,
|
||||||
|
displayOpts,
|
||||||
|
displayEdges,
|
||||||
|
refreshFlatViewport,
|
||||||
|
scheduleFlatViewportRefresh,
|
||||||
|
fitCanvas,
|
||||||
|
focusNode,
|
||||||
|
locateNode,
|
||||||
|
graphTruncated,
|
||||||
|
truncateReason,
|
||||||
|
worldScatter,
|
||||||
|
worldTotal,
|
||||||
|
worldDockMe,
|
||||||
|
showWorldScatter,
|
||||||
|
canvasGraphLoading,
|
||||||
|
canvasGraphEmpty,
|
||||||
|
worldNeedsApply,
|
||||||
|
canvasGraphRefreshing,
|
||||||
|
truncateBannerText,
|
||||||
|
activeLeafName,
|
||||||
|
};
|
||||||
|
}
|
||||||
390
web/src/pages/topology/hooks/useTopologyDiscover.ts
Normal file
390
web/src/pages/topology/hooks/useTopologyDiscover.ts
Normal file
|
|
@ -0,0 +1,390 @@
|
||||||
|
import { useCallback, useMemo, useRef, useState } from "react";
|
||||||
|
import type { QueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Edge, Node } from "@xyflow/react";
|
||||||
|
import { queryKeys } from "../../../constants/queryKeys";
|
||||||
|
import {
|
||||||
|
addTopologyViewNodes,
|
||||||
|
createFabricManualEdge,
|
||||||
|
deleteFabricEdges,
|
||||||
|
fetchLldpDiscoverJob,
|
||||||
|
fetchTopologyGraph,
|
||||||
|
patchTopologyPositions,
|
||||||
|
projectTopologyNeighbors,
|
||||||
|
removeTopologyViewNodes,
|
||||||
|
startLldpDiscover,
|
||||||
|
stopLldpCollectJob,
|
||||||
|
} from "../../../services/api";
|
||||||
|
import type {
|
||||||
|
TopologyDiscoverJob,
|
||||||
|
TopologyDiscoverNeResult,
|
||||||
|
TopologyDiscoverOut,
|
||||||
|
TopologyViewGraph,
|
||||||
|
} from "../../../types";
|
||||||
|
import type { DiscoverProgress, DiscoverSummary } from "../TopologyDiscoverPanel";
|
||||||
|
import type { EdgeDefaults, EdgeStyleData } from "../edgeStyle";
|
||||||
|
import { flowToPositions, graphToFlow } from "../graphFlow";
|
||||||
|
import { discoverResultKind, isLocalPendingEdgeId } from "../idUtils";
|
||||||
|
import { layoutGraph } from "../layoutGraph";
|
||||||
|
import type { NeNodeData } from "../TopologyReactFlowView";
|
||||||
|
|
||||||
|
export type UseTopologyDiscoverOptions = {
|
||||||
|
mapId: string;
|
||||||
|
nodes: Node<NeNodeData>[];
|
||||||
|
edges: Edge[];
|
||||||
|
graphNodes: TopologyViewGraph["nodes"] | undefined;
|
||||||
|
edgeDefaults: EdgeDefaults;
|
||||||
|
autoLayoutAfterDiscover: boolean;
|
||||||
|
discoverAutoAddUnmatched: boolean;
|
||||||
|
discoverProjectNeighbors: boolean;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
|
||||||
|
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
|
||||||
|
dirtyRef: React.MutableRefObject<boolean>;
|
||||||
|
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
|
||||||
|
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
|
||||||
|
historyLockRef: React.MutableRefObject<boolean>;
|
||||||
|
appliedMapIdRef: React.MutableRefObject<string>;
|
||||||
|
markDirty: () => void;
|
||||||
|
clearDirty: () => void;
|
||||||
|
showOk: (msg: string) => void;
|
||||||
|
showError: (msg: string) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
||||||
|
const {
|
||||||
|
mapId,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
graphNodes,
|
||||||
|
edgeDefaults,
|
||||||
|
autoLayoutAfterDiscover,
|
||||||
|
discoverAutoAddUnmatched,
|
||||||
|
discoverProjectNeighbors,
|
||||||
|
queryClient,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
dirtyRef,
|
||||||
|
pendingEdgeDeletesRef,
|
||||||
|
pendingEdgeCreatesRef,
|
||||||
|
historyLockRef,
|
||||||
|
appliedMapIdRef,
|
||||||
|
markDirty,
|
||||||
|
clearDirty,
|
||||||
|
showOk,
|
||||||
|
showError,
|
||||||
|
t,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const [discoverOpen, setDiscoverOpen] = useState(false);
|
||||||
|
const [discovering, setDiscovering] = useState(false);
|
||||||
|
const [discoverReport, setDiscoverReport] = useState<TopologyDiscoverOut | null>(null);
|
||||||
|
const [discoverLiveResults, setDiscoverLiveResults] = useState<TopologyDiscoverNeResult[]>([]);
|
||||||
|
const [discoverProgress, setDiscoverProgress] = useState<DiscoverProgress>({
|
||||||
|
index: 0,
|
||||||
|
total: 0,
|
||||||
|
neName: "",
|
||||||
|
neIp: "",
|
||||||
|
edgesAdded: 0,
|
||||||
|
edgesUpdated: 0,
|
||||||
|
});
|
||||||
|
const [discoverError, setDiscoverError] = useState("");
|
||||||
|
const [discoverJobId, setDiscoverJobId] = useState("");
|
||||||
|
const discoverAbortRef = useRef(false);
|
||||||
|
|
||||||
|
const flushDirtyCanvas = useCallback(async () => {
|
||||||
|
if (!mapId || !dirtyRef.current) return;
|
||||||
|
const createIds = [...pendingEdgeCreatesRef.current];
|
||||||
|
for (const id of createIds) {
|
||||||
|
const e = edges.find((x) => x.id === id);
|
||||||
|
if (!e) continue;
|
||||||
|
const d = (e.data || {}) as EdgeStyleData;
|
||||||
|
await createFabricManualEdge({
|
||||||
|
a_node_id: e.source,
|
||||||
|
b_node_id: e.target,
|
||||||
|
a_port: String(d.source_port || ""),
|
||||||
|
b_port: String(d.target_port || ""),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pendingEdgeCreatesRef.current.clear();
|
||||||
|
const pendingEdges = [...pendingEdgeDeletesRef.current].filter((id) => !isLocalPendingEdgeId(id));
|
||||||
|
if (pendingEdges.length) {
|
||||||
|
await deleteFabricEdges(pendingEdges);
|
||||||
|
}
|
||||||
|
pendingEdgeDeletesRef.current.clear();
|
||||||
|
const serverIds = (graphNodes || []).map((n) => n.fabric_node_id).filter(Boolean);
|
||||||
|
const localIds = nodes.map((n) => n.id);
|
||||||
|
const localSet = new Set(localIds);
|
||||||
|
const serverSet = new Set(serverIds);
|
||||||
|
const toAdd = localIds.filter((id) => !serverSet.has(id));
|
||||||
|
const toRemove = serverIds.filter((id) => !localSet.has(id));
|
||||||
|
if (toAdd.length) {
|
||||||
|
await addTopologyViewNodes(mapId, { fabric_node_ids: toAdd });
|
||||||
|
}
|
||||||
|
if (toRemove.length) {
|
||||||
|
await removeTopologyViewNodes(mapId, toRemove);
|
||||||
|
}
|
||||||
|
await patchTopologyPositions(mapId, flowToPositions(nodes));
|
||||||
|
clearDirty();
|
||||||
|
}, [
|
||||||
|
mapId,
|
||||||
|
edges,
|
||||||
|
graphNodes,
|
||||||
|
nodes,
|
||||||
|
dirtyRef,
|
||||||
|
pendingEdgeCreatesRef,
|
||||||
|
pendingEdgeDeletesRef,
|
||||||
|
clearDirty,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const runDiscover = useCallback(
|
||||||
|
async (neIds?: string[]) => {
|
||||||
|
if (!mapId || discovering) return;
|
||||||
|
const filterIds = (neIds || []).map((x) => String(x || "").trim()).filter(Boolean);
|
||||||
|
if (!filterIds.length) {
|
||||||
|
showError(t("topology.discoverOneNeedNe"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDiscoverOpen(true);
|
||||||
|
setDiscovering(true);
|
||||||
|
setDiscoverReport(null);
|
||||||
|
setDiscoverLiveResults([]);
|
||||||
|
setDiscoverError("");
|
||||||
|
setDiscoverJobId("");
|
||||||
|
discoverAbortRef.current = false;
|
||||||
|
const scannable = nodes.filter((n) => Boolean(n.data.managed_ne_id || n.data.ume_ne_id));
|
||||||
|
const scoped = scannable.filter(
|
||||||
|
(n) =>
|
||||||
|
filterIds.includes(String(n.data.managed_ne_id || "")) ||
|
||||||
|
filterIds.includes(String(n.data.ume_ne_id || "")),
|
||||||
|
);
|
||||||
|
setDiscoverProgress({
|
||||||
|
index: 0,
|
||||||
|
total: scoped.length,
|
||||||
|
neName: "",
|
||||||
|
neIp: "",
|
||||||
|
edgesAdded: 0,
|
||||||
|
edgesUpdated: 0,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
await flushDirtyCanvas();
|
||||||
|
if (!scoped.length) {
|
||||||
|
throw new Error(t("topology.discoverOneNeedNe"));
|
||||||
|
}
|
||||||
|
const jobStart = await startLldpDiscover({
|
||||||
|
scope: "ne_ids",
|
||||||
|
ne_ids: filterIds,
|
||||||
|
concurrency: 4,
|
||||||
|
auto_add_unmatched: discoverAutoAddUnmatched,
|
||||||
|
trigger_mode: "topology",
|
||||||
|
});
|
||||||
|
setDiscoverJobId(jobStart.id);
|
||||||
|
let job: TopologyDiscoverJob = jobStart;
|
||||||
|
let cancelled = false;
|
||||||
|
for (let i = 0; i < 600; i++) {
|
||||||
|
if (discoverAbortRef.current) {
|
||||||
|
cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
await new Promise((r) => window.setTimeout(r, 500));
|
||||||
|
if (discoverAbortRef.current) {
|
||||||
|
cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
job = await fetchLldpDiscoverJob(jobStart.id, { page: 1, pageSize: 5 });
|
||||||
|
setDiscoverProgress((p) => ({
|
||||||
|
...p,
|
||||||
|
index: job.done,
|
||||||
|
total: job.total || p.total,
|
||||||
|
edgesAdded: job.edges_added,
|
||||||
|
edgesUpdated: job.edges_updated,
|
||||||
|
neName: job.items?.[job.items.length - 1]?.ne_name || p.neName,
|
||||||
|
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
|
||||||
|
}));
|
||||||
|
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
|
||||||
|
if (
|
||||||
|
job.status === "done" ||
|
||||||
|
job.status === "failed" ||
|
||||||
|
job.status === "cancelled" ||
|
||||||
|
job.status === "stopped"
|
||||||
|
) {
|
||||||
|
if (job.status === "cancelled" || job.status === "stopped") cancelled = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cancelled || discoverAbortRef.current) {
|
||||||
|
setDiscoverError(t("topology.discoverCancelled"));
|
||||||
|
setDiscoverReport({
|
||||||
|
map_id: mapId,
|
||||||
|
protocol: "lldp",
|
||||||
|
job_id: job.id,
|
||||||
|
scanned: job.done,
|
||||||
|
edges_added: job.edges_added,
|
||||||
|
edges_updated: job.edges_updated,
|
||||||
|
edges_stale: job.edges_missing ?? job.edges_stale,
|
||||||
|
results: (job.items || []) as TopologyDiscoverNeResult[],
|
||||||
|
graph: null,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (job.status === "failed") {
|
||||||
|
throw new Error(job.error || "discover_failed");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
job = await fetchLldpDiscoverJob(jobStart.id, { page: 1, pageSize: 100 });
|
||||||
|
} catch {
|
||||||
|
/* keep last polled job */
|
||||||
|
}
|
||||||
|
const baseGraph = await fetchTopologyGraph(mapId);
|
||||||
|
const projected = discoverProjectNeighbors
|
||||||
|
? await projectTopologyNeighbors(mapId, {
|
||||||
|
seed_fabric_node_ids: scoped.map((n) => n.id),
|
||||||
|
dry_run: true,
|
||||||
|
})
|
||||||
|
: baseGraph;
|
||||||
|
queryClient.setQueryData(queryKeys.topologyGraph(mapId), baseGraph);
|
||||||
|
if (discoverProjectNeighbors && projected.truncate_reason === "membership_frozen") {
|
||||||
|
showError(t("topology.truncatedFrozen"));
|
||||||
|
}
|
||||||
|
appliedMapIdRef.current = mapId;
|
||||||
|
let { rfNodes, rfEdges } = graphToFlow(projected.nodes, projected.edges, edgeDefaults);
|
||||||
|
const localPos = new Map(nodes.map((n) => [n.id, n.position]));
|
||||||
|
const serverNodeIds = new Set((baseGraph.nodes || []).map((n) => n.fabric_node_id));
|
||||||
|
rfNodes = rfNodes.map((n) => {
|
||||||
|
const p = localPos.get(n.id);
|
||||||
|
return p ? { ...n, position: { ...p } } : n;
|
||||||
|
});
|
||||||
|
let didAutoLayout = false;
|
||||||
|
if (autoLayoutAfterDiscover && rfNodes.length > 1) {
|
||||||
|
rfNodes = await layoutGraph(rfNodes, rfEdges, "hierarchical-tb");
|
||||||
|
didAutoLayout = true;
|
||||||
|
}
|
||||||
|
historyLockRef.current = true;
|
||||||
|
setNodes(rfNodes);
|
||||||
|
setEdges(rfEdges);
|
||||||
|
historyLockRef.current = false;
|
||||||
|
const localOnly = rfNodes.some((n) => !serverNodeIds.has(n.id));
|
||||||
|
if (localOnly || didAutoLayout) {
|
||||||
|
markDirty();
|
||||||
|
} else {
|
||||||
|
clearDirty();
|
||||||
|
}
|
||||||
|
const out: TopologyDiscoverOut = {
|
||||||
|
map_id: mapId,
|
||||||
|
protocol: "lldp",
|
||||||
|
job_id: job.id,
|
||||||
|
scanned: job.done,
|
||||||
|
edges_added: job.edges_added,
|
||||||
|
edges_updated: job.edges_updated,
|
||||||
|
edges_stale: job.edges_missing ?? job.edges_stale,
|
||||||
|
results: (job.items || []) as TopologyDiscoverNeResult[],
|
||||||
|
graph: projected,
|
||||||
|
};
|
||||||
|
setDiscoverReport(out);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.fabricSummary });
|
||||||
|
showOk(
|
||||||
|
t("topology.discovered")
|
||||||
|
.replace("{{added}}", String(out.edges_added))
|
||||||
|
.replace("{{updated}}", String(out.edges_updated))
|
||||||
|
.replace("{{stale}}", String(out.edges_stale || 0)),
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
if (discoverAbortRef.current) {
|
||||||
|
setDiscoverError(t("topology.discoverCancelled"));
|
||||||
|
} else {
|
||||||
|
setDiscoverError(String(err));
|
||||||
|
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setDiscovering(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
mapId,
|
||||||
|
discovering,
|
||||||
|
nodes,
|
||||||
|
queryClient,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
showOk,
|
||||||
|
showError,
|
||||||
|
t,
|
||||||
|
autoLayoutAfterDiscover,
|
||||||
|
discoverAutoAddUnmatched,
|
||||||
|
discoverProjectNeighbors,
|
||||||
|
edgeDefaults,
|
||||||
|
clearDirty,
|
||||||
|
markDirty,
|
||||||
|
flushDirtyCanvas,
|
||||||
|
historyLockRef,
|
||||||
|
appliedMapIdRef,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const cancelDiscover = useCallback(async () => {
|
||||||
|
discoverAbortRef.current = true;
|
||||||
|
const jobId = discoverJobId;
|
||||||
|
if (jobId) {
|
||||||
|
try {
|
||||||
|
await stopLldpCollectJob(jobId);
|
||||||
|
} catch {
|
||||||
|
/* best-effort */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [discoverJobId]);
|
||||||
|
|
||||||
|
const discoverResults = discoverReport?.results?.length
|
||||||
|
? discoverReport.results
|
||||||
|
: discoverLiveResults;
|
||||||
|
|
||||||
|
const discoverCounts = useMemo(() => {
|
||||||
|
let ok = 0;
|
||||||
|
let warn = 0;
|
||||||
|
let fail = 0;
|
||||||
|
for (const r of discoverResults) {
|
||||||
|
const k = discoverResultKind(r);
|
||||||
|
if (k === "fail") fail += 1;
|
||||||
|
else if (k === "warn") warn += 1;
|
||||||
|
else ok += 1;
|
||||||
|
}
|
||||||
|
return { ok, warn, fail, issues: warn + fail };
|
||||||
|
}, [discoverResults]);
|
||||||
|
|
||||||
|
const discoverSummary: DiscoverSummary = discoverReport
|
||||||
|
? {
|
||||||
|
scanned: discoverReport.scanned,
|
||||||
|
added: discoverReport.edges_added,
|
||||||
|
updated: discoverReport.edges_updated,
|
||||||
|
missing: discoverReport.edges_stale || 0,
|
||||||
|
failed: discoverCounts.fail,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
scanned: discoverLiveResults.length,
|
||||||
|
added: discoverProgress.edgesAdded,
|
||||||
|
updated: discoverProgress.edgesUpdated,
|
||||||
|
missing: 0,
|
||||||
|
failed: discoverCounts.fail,
|
||||||
|
};
|
||||||
|
|
||||||
|
const discoverPct =
|
||||||
|
discovering && discoverProgress.total > 0
|
||||||
|
? Math.min(100, Math.round((discoverProgress.index / discoverProgress.total) * 100))
|
||||||
|
: discoverReport
|
||||||
|
? 100
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return {
|
||||||
|
discoverOpen,
|
||||||
|
setDiscoverOpen,
|
||||||
|
discovering,
|
||||||
|
discoverReport,
|
||||||
|
discoverProgress,
|
||||||
|
discoverError,
|
||||||
|
discoverResults,
|
||||||
|
discoverSummary,
|
||||||
|
discoverPct,
|
||||||
|
runDiscover,
|
||||||
|
cancelDiscover,
|
||||||
|
};
|
||||||
|
}
|
||||||
108
web/src/pages/topology/hooks/useTopologyHistory.ts
Normal file
108
web/src/pages/topology/hooks/useTopologyHistory.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
import { useCallback, useRef, useState } from "react";
|
||||||
|
import type { Edge, Node } from "@xyflow/react";
|
||||||
|
import { UNDO_MAX } from "../constants";
|
||||||
|
import type { HistorySnap } from "../pageTypes";
|
||||||
|
import type { NeNodeData } from "../TopologyReactFlowView";
|
||||||
|
|
||||||
|
export function useTopologyHistory(
|
||||||
|
nodes: Node<NeNodeData>[],
|
||||||
|
edges: Edge[],
|
||||||
|
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>,
|
||||||
|
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>,
|
||||||
|
t: (key: string) => string,
|
||||||
|
) {
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
const [historyTick, setHistoryTick] = useState(0);
|
||||||
|
const dirtyRef = useRef(false);
|
||||||
|
const historyRef = useRef<HistorySnap[]>([]);
|
||||||
|
const redoRef = useRef<HistorySnap[]>([]);
|
||||||
|
const historyLockRef = useRef(false);
|
||||||
|
const pendingEdgeDeletesRef = useRef<Set<string>>(new Set());
|
||||||
|
const pendingEdgeCreatesRef = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
|
||||||
|
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
|
||||||
|
|
||||||
|
const markDirty = useCallback(() => {
|
||||||
|
dirtyRef.current = true;
|
||||||
|
setDirty(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const clearDirty = useCallback(() => {
|
||||||
|
dirtyRef.current = false;
|
||||||
|
setDirty(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const bumpHistory = useCallback(() => {
|
||||||
|
setHistoryTick((n) => n + 1);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const confirmDiscardIfDirty = useCallback(() => {
|
||||||
|
if (!dirtyRef.current) return true;
|
||||||
|
return window.confirm(t("topology.unsavedConfirm"));
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const snapshot = useCallback(
|
||||||
|
(): HistorySnap => ({
|
||||||
|
nodes: nodes.map((n) => ({ ...n, position: { ...n.position }, data: { ...n.data } })),
|
||||||
|
edges: edges.map((e) => ({ ...e })),
|
||||||
|
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
||||||
|
pendingEdgeCreates: [...pendingEdgeCreatesRef.current],
|
||||||
|
}),
|
||||||
|
[nodes, edges],
|
||||||
|
);
|
||||||
|
|
||||||
|
const pushHistory = useCallback(() => {
|
||||||
|
if (historyLockRef.current) return;
|
||||||
|
historyRef.current = [...historyRef.current.slice(-(UNDO_MAX - 1)), snapshot()];
|
||||||
|
redoRef.current = [];
|
||||||
|
bumpHistory();
|
||||||
|
}, [snapshot, bumpHistory]);
|
||||||
|
|
||||||
|
const undo = useCallback(() => {
|
||||||
|
const prev = historyRef.current.pop();
|
||||||
|
if (!prev) return;
|
||||||
|
redoRef.current.push(snapshot());
|
||||||
|
historyLockRef.current = true;
|
||||||
|
setNodes(prev.nodes);
|
||||||
|
setEdges(prev.edges);
|
||||||
|
pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes);
|
||||||
|
pendingEdgeCreatesRef.current = new Set(prev.pendingEdgeCreates || []);
|
||||||
|
markDirty();
|
||||||
|
bumpHistory();
|
||||||
|
historyLockRef.current = false;
|
||||||
|
}, [snapshot, setNodes, setEdges, markDirty, bumpHistory]);
|
||||||
|
|
||||||
|
const redo = useCallback(() => {
|
||||||
|
const next = redoRef.current.pop();
|
||||||
|
if (!next) return;
|
||||||
|
historyRef.current.push(snapshot());
|
||||||
|
historyLockRef.current = true;
|
||||||
|
setNodes(next.nodes);
|
||||||
|
setEdges(next.edges);
|
||||||
|
pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes);
|
||||||
|
pendingEdgeCreatesRef.current = new Set(next.pendingEdgeCreates || []);
|
||||||
|
markDirty();
|
||||||
|
bumpHistory();
|
||||||
|
historyLockRef.current = false;
|
||||||
|
}, [snapshot, setNodes, setEdges, markDirty, bumpHistory]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dirty,
|
||||||
|
canUndo,
|
||||||
|
canRedo,
|
||||||
|
dirtyRef,
|
||||||
|
historyRef,
|
||||||
|
redoRef,
|
||||||
|
historyLockRef,
|
||||||
|
pendingEdgeDeletesRef,
|
||||||
|
pendingEdgeCreatesRef,
|
||||||
|
markDirty,
|
||||||
|
clearDirty,
|
||||||
|
bumpHistory,
|
||||||
|
confirmDiscardIfDirty,
|
||||||
|
pushHistory,
|
||||||
|
undo,
|
||||||
|
redo,
|
||||||
|
};
|
||||||
|
}
|
||||||
670
web/src/pages/topology/hooks/useTopologyTreeNav.ts
Normal file
670
web/src/pages/topology/hooks/useTopologyTreeNav.ts
Normal file
|
|
@ -0,0 +1,670 @@
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useMutation, useQuery, type QueryClient } from "@tanstack/react-query";
|
||||||
|
import type { Edge, Node } from "@xyflow/react";
|
||||||
|
import type { SetURLSearchParams } from "react-router-dom";
|
||||||
|
import { queryKeys } from "../../../constants/queryKeys";
|
||||||
|
import {
|
||||||
|
createTopologyFolder,
|
||||||
|
deleteTopologyFolder,
|
||||||
|
deleteTopologyMap,
|
||||||
|
fetchTopologyTree,
|
||||||
|
fetchTopologyWorld,
|
||||||
|
searchFabricNodes,
|
||||||
|
updateTopologyFolder,
|
||||||
|
updateTopologyMap,
|
||||||
|
} from "../../../services/api";
|
||||||
|
import type { TopologyTreeFolderItem, TopologyTreeViewItem } from "../../../types";
|
||||||
|
import { LAST_LEAF_KEY, TREE_EXPAND_KEY, WORLD_MAP_ENABLED } from "../constants";
|
||||||
|
import type { CtxMenu } from "../pageTypes";
|
||||||
|
import {
|
||||||
|
findFolderInTree,
|
||||||
|
findViewInRegion,
|
||||||
|
folderPathFolders,
|
||||||
|
folderPathIds,
|
||||||
|
isRegionCanvasFolder,
|
||||||
|
isUmeWorldContainer,
|
||||||
|
isWorldDrillFolder,
|
||||||
|
isWorldFlatViewName,
|
||||||
|
regionDisplayName,
|
||||||
|
} from "../treeUtils";
|
||||||
|
import type { NeNodeData } from "../TopologyReactFlowView";
|
||||||
|
|
||||||
|
export type UseTopologyTreeNavOptions = {
|
||||||
|
liveSync: boolean;
|
||||||
|
searchParams: URLSearchParams;
|
||||||
|
setSearchParams: SetURLSearchParams;
|
||||||
|
locale: string;
|
||||||
|
queryClient: QueryClient;
|
||||||
|
confirmDiscardIfDirty: () => boolean;
|
||||||
|
clearDirty: () => void;
|
||||||
|
showOk: (msg: string) => void;
|
||||||
|
showError: (msg: string) => void;
|
||||||
|
t: (key: string) => string;
|
||||||
|
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
|
||||||
|
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
|
||||||
|
setCtxMenu: React.Dispatch<React.SetStateAction<CtxMenu | null>>;
|
||||||
|
activeViewName?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useTopologyTreeNav(opts: UseTopologyTreeNavOptions) {
|
||||||
|
const {
|
||||||
|
liveSync,
|
||||||
|
searchParams,
|
||||||
|
setSearchParams,
|
||||||
|
locale,
|
||||||
|
queryClient,
|
||||||
|
confirmDiscardIfDirty,
|
||||||
|
clearDirty,
|
||||||
|
showOk,
|
||||||
|
showError,
|
||||||
|
t,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
setCtxMenu,
|
||||||
|
activeViewName,
|
||||||
|
} = opts;
|
||||||
|
|
||||||
|
const [mapId, setMapId] = useState("");
|
||||||
|
const [worldFocusFolderId, setWorldFocusFolderId] = useState("");
|
||||||
|
const [worldViewId, setWorldViewId] = useState("");
|
||||||
|
const [selectedFolderId, setSelectedFolderId] = useState("");
|
||||||
|
const [hotBrowseKey, setHotBrowseKey] = useState("");
|
||||||
|
const [pendingHighlightNe, setPendingHighlightNe] = useState("");
|
||||||
|
const [expandedIds, setExpandedIds] = useState<Record<string, boolean>>(() => {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(TREE_EXPAND_KEY);
|
||||||
|
return raw ? (JSON.parse(raw) as Record<string, boolean>) : {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const [treeNeQuery, setTreeNeQuery] = useState("");
|
||||||
|
const [debouncedTreeNeQuery, setDebouncedTreeNeQuery] = useState("");
|
||||||
|
const [treeSearchOpen, setTreeSearchOpen] = useState(false);
|
||||||
|
const treeSearchRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [newRootDialog, setNewRootDialog] = useState<{ name: string } | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const timer = window.setTimeout(() => setDebouncedTreeNeQuery(treeNeQuery.trim()), 200);
|
||||||
|
return () => window.clearTimeout(timer);
|
||||||
|
}, [treeNeQuery]);
|
||||||
|
|
||||||
|
const treeQuery = useQuery({
|
||||||
|
queryKey: queryKeys.topologyTree,
|
||||||
|
queryFn: fetchTopologyTree,
|
||||||
|
staleTime: liveSync ? 0 : 30_000,
|
||||||
|
refetchOnWindowFocus: liveSync,
|
||||||
|
refetchInterval: liveSync ? 5000 : false,
|
||||||
|
refetchIntervalInBackground: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const treeFlatMap = useMemo(() => {
|
||||||
|
if (!mapId) return false;
|
||||||
|
const kids = treeQuery.data?.root?.children || [];
|
||||||
|
return isWorldFlatViewName(findViewInRegion(kids, mapId)?.view?.name);
|
||||||
|
}, [mapId, treeQuery.data?.root?.children]);
|
||||||
|
|
||||||
|
const treeNeSearchQuery = useQuery({
|
||||||
|
queryKey: queryKeys.fabricNodeSearch(debouncedTreeNeQuery, 1),
|
||||||
|
queryFn: () => searchFabricNodes({ q: debouncedTreeNeQuery, page: 1, pageSize: 30 }),
|
||||||
|
enabled: debouncedTreeNeQuery.length >= 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const treeRoot = treeQuery.data?.root || null;
|
||||||
|
const regions = useMemo(() => treeRoot?.children || [], [treeRoot]);
|
||||||
|
const treeLoading = treeQuery.isPending && !treeQuery.data;
|
||||||
|
const treeFailed = treeQuery.isError && !treeQuery.data;
|
||||||
|
const rootFolderId = String(treeRoot?.id || "").trim();
|
||||||
|
const canvasModeLocal = Boolean(mapId);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!treeRoot || !regions.length) return;
|
||||||
|
if (mapId) {
|
||||||
|
const hit = findViewInRegion(regions, mapId);
|
||||||
|
if (hit && selectedFolderId !== hit.region.id) {
|
||||||
|
setSelectedFolderId(hit.region.id);
|
||||||
|
setExpandedIds((prev) => ({ ...prev, [hit.region.id]: true }));
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selectedFolderId && !regions.some((r) => r.id === selectedFolderId)) {
|
||||||
|
setSelectedFolderId("");
|
||||||
|
}
|
||||||
|
}, [mapId, selectedFolderId, treeRoot, regions]);
|
||||||
|
|
||||||
|
const activeRegion = useMemo(() => {
|
||||||
|
if (!selectedFolderId) return null;
|
||||||
|
return findFolderInTree(regions, selectedFolderId);
|
||||||
|
}, [regions, selectedFolderId]);
|
||||||
|
|
||||||
|
const activeView = useMemo(() => {
|
||||||
|
if (!mapId) return null;
|
||||||
|
return findViewInRegion(regions, mapId)?.view || null;
|
||||||
|
}, [mapId, regions]);
|
||||||
|
|
||||||
|
const browseEntries = useMemo((): TopologyTreeViewItem[] => {
|
||||||
|
if (
|
||||||
|
!activeRegion ||
|
||||||
|
isRegionCanvasFolder(activeRegion, rootFolderId) ||
|
||||||
|
isUmeWorldContainer(activeRegion)
|
||||||
|
) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return activeRegion.views || [];
|
||||||
|
}, [activeRegion, rootFolderId]);
|
||||||
|
|
||||||
|
const hexBrowseRegion = useMemo(() => {
|
||||||
|
if (!activeRegion) return null;
|
||||||
|
if (isUmeWorldContainer(activeRegion)) return activeRegion;
|
||||||
|
if (isRegionCanvasFolder(activeRegion, rootFolderId)) return null;
|
||||||
|
return activeRegion;
|
||||||
|
}, [activeRegion, rootFolderId]);
|
||||||
|
|
||||||
|
const umeWorldHexModules = useMemo(() => {
|
||||||
|
if (!activeRegion || !isUmeWorldContainer(activeRegion)) return null;
|
||||||
|
const drill = (activeRegion.children || []).find((c) => isWorldDrillFolder(c)) || null;
|
||||||
|
const flatView = WORLD_MAP_ENABLED
|
||||||
|
? (activeRegion.views || []).find((v) => isWorldFlatViewName(v.name)) || null
|
||||||
|
: null;
|
||||||
|
return { drill, flatView };
|
||||||
|
}, [activeRegion]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!WORLD_MAP_ENABLED) {
|
||||||
|
setWorldViewId("");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
fetchTopologyWorld()
|
||||||
|
.then((w) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setWorldViewId(w.view_id);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* world not seeded yet */
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(TREE_EXPAND_KEY, JSON.stringify(expandedIds));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, [expandedIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
if (mapId) localStorage.setItem(LAST_LEAF_KEY, mapId);
|
||||||
|
else localStorage.removeItem(LAST_LEAF_KEY);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}, [mapId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (WORLD_MAP_ENABLED || !mapId || !treeFlatMap) return;
|
||||||
|
const container = regions.find(
|
||||||
|
(r) => r.external_ref === "ume:world" || r.name === "UME World",
|
||||||
|
);
|
||||||
|
setWorldFocusFolderId("");
|
||||||
|
if (container) {
|
||||||
|
setSelectedFolderId(container.id);
|
||||||
|
setExpandedIds((p) => ({ ...p, [container.id]: true }));
|
||||||
|
}
|
||||||
|
setMapId("");
|
||||||
|
clearDirty();
|
||||||
|
}, [mapId, treeFlatMap, regions, clearDirty]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const viewId = String(searchParams.get("view") || "").trim();
|
||||||
|
const neId = String(searchParams.get("ne") || "").trim();
|
||||||
|
if (!viewId || !regions.length) return;
|
||||||
|
const hit = findViewInRegion(regions, viewId);
|
||||||
|
if (!hit) return;
|
||||||
|
setSelectedFolderId(hit.region.id);
|
||||||
|
setExpandedIds((p) => ({ ...p, [hit.region.id]: true }));
|
||||||
|
setMapId(viewId);
|
||||||
|
if (neId) setPendingHighlightNe(neId);
|
||||||
|
setSearchParams({}, { replace: true });
|
||||||
|
}, [regions, searchParams, setSearchParams]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!mapId && !selectedFolderId) return;
|
||||||
|
const root = document.querySelector(".topo-region-list");
|
||||||
|
if (!root) return;
|
||||||
|
const hit =
|
||||||
|
root.querySelector("li.is-active") ||
|
||||||
|
root.querySelector(".topo-region-list__block.is-branch-active");
|
||||||
|
if (hit && "scrollIntoView" in hit) {
|
||||||
|
(hit as HTMLElement).scrollIntoView({ block: "nearest", behavior: "smooth" });
|
||||||
|
}
|
||||||
|
}, [mapId, selectedFolderId, expandedIds]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!treeSearchOpen) return;
|
||||||
|
const onDoc = (e: MouseEvent) => {
|
||||||
|
const el = treeSearchRef.current;
|
||||||
|
if (el && e.target instanceof Element && !el.contains(e.target)) {
|
||||||
|
setTreeSearchOpen(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onDoc);
|
||||||
|
return () => document.removeEventListener("mousedown", onDoc);
|
||||||
|
}, [treeSearchOpen]);
|
||||||
|
|
||||||
|
const expandFolderPath = useCallback(
|
||||||
|
(folderId: string) => {
|
||||||
|
const path = folderPathIds(regions, folderId);
|
||||||
|
if (!path.length) return;
|
||||||
|
setExpandedIds((p) => {
|
||||||
|
const next = { ...p };
|
||||||
|
for (const id of path) next[id] = true;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[regions],
|
||||||
|
);
|
||||||
|
|
||||||
|
const primaryViewOfFolder = useCallback((folder: TopologyTreeFolderItem | null | undefined) => {
|
||||||
|
if (!folder) return null;
|
||||||
|
const views = folder.views || [];
|
||||||
|
if (isWorldDrillFolder(folder)) {
|
||||||
|
return views.find((v) => v.name === "World") || views[0] || null;
|
||||||
|
}
|
||||||
|
if (folder.external_ref === "ume:world" || folder.name === "UME World") {
|
||||||
|
const drill = (folder.children || []).find((c) => isWorldDrillFolder(c));
|
||||||
|
if (drill) {
|
||||||
|
const dv = drill.views || [];
|
||||||
|
return dv.find((v) => v.name === "World") || dv[0] || null;
|
||||||
|
}
|
||||||
|
return views.find((v) => !isWorldFlatViewName(v.name)) || null;
|
||||||
|
}
|
||||||
|
const physical = views.find((v) => String(v.kind) === "physical") || null;
|
||||||
|
return physical || views[0] || null;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const goUmeWorldNav = useCallback(() => {
|
||||||
|
if (!confirmDiscardIfDirty()) return;
|
||||||
|
const container = regions.find(
|
||||||
|
(r) => r.external_ref === "ume:world" || r.name === "UME World",
|
||||||
|
);
|
||||||
|
if (!container) return;
|
||||||
|
setWorldFocusFolderId("");
|
||||||
|
setSelectedFolderId(container.id);
|
||||||
|
setExpandedIds((p) => ({ ...p, [container.id]: true }));
|
||||||
|
setMapId("");
|
||||||
|
clearDirty();
|
||||||
|
}, [confirmDiscardIfDirty, clearDirty, regions]);
|
||||||
|
|
||||||
|
const goRegion = useCallback(
|
||||||
|
(folderId: string) => {
|
||||||
|
if (!confirmDiscardIfDirty()) return;
|
||||||
|
setSelectedFolderId(folderId);
|
||||||
|
expandFolderPath(folderId);
|
||||||
|
const folder = findFolderInTree(regions, folderId);
|
||||||
|
if (isRegionCanvasFolder(folder, String(treeRoot?.id || ""))) {
|
||||||
|
setWorldFocusFolderId("");
|
||||||
|
const view = primaryViewOfFolder(folder);
|
||||||
|
if (view) {
|
||||||
|
setMapId(view.id);
|
||||||
|
clearDirty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setMapId("");
|
||||||
|
setWorldFocusFolderId("");
|
||||||
|
clearDirty();
|
||||||
|
setNodes([]);
|
||||||
|
setEdges([]);
|
||||||
|
},
|
||||||
|
[
|
||||||
|
confirmDiscardIfDirty,
|
||||||
|
clearDirty,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
regions,
|
||||||
|
primaryViewOfFolder,
|
||||||
|
expandFolderPath,
|
||||||
|
treeRoot?.id,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goRoot = useCallback(() => {
|
||||||
|
if (!confirmDiscardIfDirty()) return;
|
||||||
|
setSelectedFolderId("");
|
||||||
|
setWorldFocusFolderId("");
|
||||||
|
setMapId("");
|
||||||
|
clearDirty();
|
||||||
|
setNodes([]);
|
||||||
|
setEdges([]);
|
||||||
|
}, [confirmDiscardIfDirty, clearDirty, setNodes, setEdges]);
|
||||||
|
|
||||||
|
const goCanvas = useCallback(
|
||||||
|
(viewId: string, folderId?: string) => {
|
||||||
|
if (!confirmDiscardIfDirty()) return;
|
||||||
|
const hit = findViewInRegion(regions, viewId);
|
||||||
|
if (!WORLD_MAP_ENABLED && isWorldFlatViewName(hit?.view?.name)) {
|
||||||
|
showError(t("topology.worldMapOffline"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const regionId = folderId || hit?.region.id || selectedFolderId;
|
||||||
|
if (regionId) {
|
||||||
|
setSelectedFolderId(regionId);
|
||||||
|
expandFolderPath(regionId);
|
||||||
|
}
|
||||||
|
setWorldFocusFolderId("");
|
||||||
|
setMapId(viewId);
|
||||||
|
},
|
||||||
|
[confirmDiscardIfDirty, regions, selectedFolderId, expandFolderPath, showError, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const goBackBrowse = useCallback(() => {
|
||||||
|
if (!confirmDiscardIfDirty()) return;
|
||||||
|
const folder = findFolderInTree(regions, selectedFolderId);
|
||||||
|
const parentId = String(folder?.parent_id || "").trim();
|
||||||
|
const parent = parentId ? findFolderInTree(regions, parentId) : null;
|
||||||
|
if (parent) {
|
||||||
|
setSelectedFolderId(parent.id);
|
||||||
|
expandFolderPath(parent.id);
|
||||||
|
if (isRegionCanvasFolder(parent, String(treeRoot?.id || ""))) {
|
||||||
|
setWorldFocusFolderId("");
|
||||||
|
const view = primaryViewOfFolder(parent);
|
||||||
|
if (view) {
|
||||||
|
setMapId(view.id);
|
||||||
|
clearDirty();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setWorldFocusFolderId("");
|
||||||
|
setMapId("");
|
||||||
|
clearDirty();
|
||||||
|
setNodes([]);
|
||||||
|
setEdges([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelectedFolderId("");
|
||||||
|
setWorldFocusFolderId("");
|
||||||
|
setMapId("");
|
||||||
|
clearDirty();
|
||||||
|
setNodes([]);
|
||||||
|
setEdges([]);
|
||||||
|
}, [
|
||||||
|
confirmDiscardIfDirty,
|
||||||
|
clearDirty,
|
||||||
|
setNodes,
|
||||||
|
setEdges,
|
||||||
|
regions,
|
||||||
|
selectedFolderId,
|
||||||
|
treeRoot?.id,
|
||||||
|
expandFolderPath,
|
||||||
|
primaryViewOfFolder,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const renameMapMut = useMutation({
|
||||||
|
mutationFn: ({ id, name }: { id: string; name: string }) => updateTopologyMap(id, { name }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
|
showOk(t("topology.renamed"));
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptRenameMap = useCallback(
|
||||||
|
(id: string, currentName: string) => {
|
||||||
|
const next = window.prompt(t("topology.renamePrompt"), currentName);
|
||||||
|
if (next == null) return;
|
||||||
|
const name = next.trim();
|
||||||
|
if (!name || name === currentName) return;
|
||||||
|
renameMapMut.mutate({ id, name });
|
||||||
|
},
|
||||||
|
[renameMapMut, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const createRegionMut = useMutation({
|
||||||
|
mutationFn: (input: { name: string; parent_id?: string }) => {
|
||||||
|
const parentId = String(input.parent_id || "").trim();
|
||||||
|
return createTopologyFolder(
|
||||||
|
parentId
|
||||||
|
? { name: input.name, kind: "region", parent_id: parentId, locale }
|
||||||
|
: { name: input.name, kind: "region", locale },
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async (folder, input) => {
|
||||||
|
setNewRootDialog(null);
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||||
|
const tree = await fetchTopologyTree();
|
||||||
|
const regionsList = tree.root?.children || [];
|
||||||
|
const hit = findFolderInTree(regionsList, folder.id);
|
||||||
|
const parent = String(input.parent_id || folder.parent_id || hit?.parent_id || "").trim();
|
||||||
|
const rootId = String(tree.root?.id || "").trim();
|
||||||
|
const isNested = Boolean(parent && parent !== rootId);
|
||||||
|
setWorldFocusFolderId("");
|
||||||
|
setExpandedIds((prev) => {
|
||||||
|
const next = { ...prev, [folder.id]: true };
|
||||||
|
if (parent) next[parent] = true;
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
if (isNested) {
|
||||||
|
setSelectedFolderId(parent);
|
||||||
|
expandFolderPath(parent);
|
||||||
|
const parentHit = findFolderInTree(regionsList, parent);
|
||||||
|
const parentView =
|
||||||
|
primaryViewOfFolder(parentHit) ||
|
||||||
|
(parentHit?.views || []).find((v) => String(v.kind) === "physical") ||
|
||||||
|
(parentHit?.views || [])[0];
|
||||||
|
const stayViewId = mapId || parentView?.id || "";
|
||||||
|
if (stayViewId) {
|
||||||
|
setMapId(stayViewId);
|
||||||
|
await queryClient.invalidateQueries({
|
||||||
|
queryKey: queryKeys.topologyGraph(stayViewId),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setSelectedFolderId(folder.id);
|
||||||
|
setMapId("");
|
||||||
|
}
|
||||||
|
showOk(t("topology.regionCreated"));
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const renameRegionMut = useMutation({
|
||||||
|
mutationFn: ({ id, name }: { id: string; name: string }) => updateTopologyFolder(id, { name }),
|
||||||
|
onSuccess: async () => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||||
|
showOk(t("topology.regionRenamed"));
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptRenameRegion = useCallback(
|
||||||
|
(id: string, currentName: string) => {
|
||||||
|
const next = window.prompt(t("topology.renameRegionPrompt"), currentName);
|
||||||
|
if (next == null) return;
|
||||||
|
const name = next.trim();
|
||||||
|
if (!name || name === currentName) return;
|
||||||
|
renameRegionMut.mutate({ id, name });
|
||||||
|
},
|
||||||
|
[renameRegionMut, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const deleteMapMut = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteTopologyMap(id),
|
||||||
|
onSuccess: async (_out, id) => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||||
|
if (mapId === id) {
|
||||||
|
clearDirty();
|
||||||
|
setMapId("");
|
||||||
|
setNodes([]);
|
||||||
|
setEdges([]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteFolderMut = useMutation({
|
||||||
|
mutationFn: (id: string) => deleteTopologyFolder(id, false),
|
||||||
|
onSuccess: async (_out, id) => {
|
||||||
|
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
|
||||||
|
if (selectedFolderId === id) {
|
||||||
|
setSelectedFolderId("");
|
||||||
|
setMapId("");
|
||||||
|
}
|
||||||
|
showOk(t("topology.regionDeleted"));
|
||||||
|
},
|
||||||
|
onError: (err) => showError(String(err)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const promptNewRegion = useCallback(() => {
|
||||||
|
setNewRootDialog({ name: t("topology.newRegionName") });
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
const submitNewRoot = useCallback(() => {
|
||||||
|
const name = String(newRootDialog?.name || "").trim();
|
||||||
|
if (!name) {
|
||||||
|
showError(t("topology.newRegionPrompt"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
createRegionMut.mutate({ name });
|
||||||
|
}, [newRootDialog, createRegionMut, showError, t]);
|
||||||
|
|
||||||
|
const promptNewSubRegion = useCallback(() => {
|
||||||
|
const viewName = activeView?.name || activeViewName;
|
||||||
|
if (isWorldFlatViewName(viewName)) {
|
||||||
|
showError(t("topology.subRegionNotOnFlat"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let parentId = selectedFolderId;
|
||||||
|
if (!parentId && worldViewId && mapId === worldViewId) {
|
||||||
|
const container = regions.find(
|
||||||
|
(r) => r.external_ref === "ume:world" || r.name === "UME World",
|
||||||
|
);
|
||||||
|
const drill = (container?.children || []).find((c) => isWorldDrillFolder(c));
|
||||||
|
parentId = drill?.id || container?.id || "";
|
||||||
|
}
|
||||||
|
if (!parentId) {
|
||||||
|
showError(t("topology.subRegionNeedParent"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const name = window.prompt(t("topology.newSubRegionPrompt"), t("topology.newSubRegionName"));
|
||||||
|
if (!name?.trim()) return;
|
||||||
|
createRegionMut.mutate({ name: name.trim(), parent_id: parentId });
|
||||||
|
setCtxMenu(null);
|
||||||
|
}, [
|
||||||
|
activeView?.name,
|
||||||
|
activeViewName,
|
||||||
|
selectedFolderId,
|
||||||
|
worldViewId,
|
||||||
|
mapId,
|
||||||
|
regions,
|
||||||
|
createRegionMut,
|
||||||
|
t,
|
||||||
|
showError,
|
||||||
|
setCtxMenu,
|
||||||
|
]);
|
||||||
|
|
||||||
|
const onTreeToggleExpand = useCallback((folderId: string, nextOpen: boolean) => {
|
||||||
|
setExpandedIds((p) => ({ ...p, [folderId]: nextOpen }));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTreeHotBrowseKey = useCallback((key: string) => {
|
||||||
|
setHotBrowseKey(key);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTreeClearHotBrowseKey = useCallback((key: string) => {
|
||||||
|
setHotBrowseKey((k) => (k === key ? "" : k));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const onTreeDeleteFolder = useCallback(
|
||||||
|
(folderId: string) => {
|
||||||
|
deleteFolderMut.mutate(folderId);
|
||||||
|
},
|
||||||
|
[deleteFolderMut],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onTreeDeleteMap = useCallback(
|
||||||
|
(viewId: string) => {
|
||||||
|
deleteMapMut.mutate(viewId);
|
||||||
|
},
|
||||||
|
[deleteMapMut],
|
||||||
|
);
|
||||||
|
|
||||||
|
const breadcrumbFolders = useMemo(
|
||||||
|
() => folderPathFolders(regions, selectedFolderId),
|
||||||
|
[regions, selectedFolderId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const titleText = useMemo(() => {
|
||||||
|
if (activeRegion) return regionDisplayName(activeRegion, t);
|
||||||
|
return t("topology.rootName");
|
||||||
|
}, [activeRegion, t]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
mapId,
|
||||||
|
setMapId,
|
||||||
|
worldFocusFolderId,
|
||||||
|
setWorldFocusFolderId,
|
||||||
|
worldViewId,
|
||||||
|
selectedFolderId,
|
||||||
|
setSelectedFolderId,
|
||||||
|
hotBrowseKey,
|
||||||
|
setHotBrowseKey,
|
||||||
|
expandedIds,
|
||||||
|
setExpandedIds,
|
||||||
|
treeNeQuery,
|
||||||
|
setTreeNeQuery,
|
||||||
|
debouncedTreeNeQuery,
|
||||||
|
setDebouncedTreeNeQuery,
|
||||||
|
treeSearchOpen,
|
||||||
|
setTreeSearchOpen,
|
||||||
|
treeSearchRef,
|
||||||
|
newRootDialog,
|
||||||
|
setNewRootDialog,
|
||||||
|
treeQuery,
|
||||||
|
treeFlatMap,
|
||||||
|
treeNeSearchQuery,
|
||||||
|
treeRoot,
|
||||||
|
regions,
|
||||||
|
treeLoading,
|
||||||
|
treeFailed,
|
||||||
|
rootFolderId,
|
||||||
|
canvasMode: canvasModeLocal,
|
||||||
|
activeRegion,
|
||||||
|
activeView,
|
||||||
|
browseEntries,
|
||||||
|
hexBrowseRegion,
|
||||||
|
umeWorldHexModules,
|
||||||
|
expandFolderPath,
|
||||||
|
primaryViewOfFolder,
|
||||||
|
goUmeWorldNav,
|
||||||
|
goRegion,
|
||||||
|
goRoot,
|
||||||
|
goCanvas,
|
||||||
|
goBackBrowse,
|
||||||
|
renameMapMut,
|
||||||
|
promptRenameMap,
|
||||||
|
createRegionMut,
|
||||||
|
renameRegionMut,
|
||||||
|
promptRenameRegion,
|
||||||
|
deleteMapMut,
|
||||||
|
deleteFolderMut,
|
||||||
|
promptNewRegion,
|
||||||
|
submitNewRoot,
|
||||||
|
promptNewSubRegion,
|
||||||
|
onTreeToggleExpand,
|
||||||
|
onTreeHotBrowseKey,
|
||||||
|
onTreeClearHotBrowseKey,
|
||||||
|
onTreeDeleteFolder,
|
||||||
|
onTreeDeleteMap,
|
||||||
|
breadcrumbFolders,
|
||||||
|
titleText,
|
||||||
|
pendingHighlightNe,
|
||||||
|
setPendingHighlightNe,
|
||||||
|
};
|
||||||
|
}
|
||||||
23
web/src/pages/topology/idUtils.ts
Normal file
23
web/src/pages/topology/idUtils.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
import type { TopologyDiscoverNeResult } from "../../types";
|
||||||
|
|
||||||
|
export function newId(): string {
|
||||||
|
if (typeof crypto !== "undefined" && crypto.randomUUID) {
|
||||||
|
return crypto.randomUUID().replace(/-/g, "");
|
||||||
|
}
|
||||||
|
return `${Date.now().toString(16)}${Math.random().toString(16).slice(2, 10)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function newLocalEdgeId(): string {
|
||||||
|
return `local:${newId()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLocalPendingEdgeId(id: string): boolean {
|
||||||
|
return String(id || "").startsWith("local:");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function discoverResultKind(r: TopologyDiscoverNeResult): "ok" | "warn" | "fail" {
|
||||||
|
if (!r.ok) return "fail";
|
||||||
|
const unmatchedCount = r.unmatched_count ?? (r.unmatched?.length || 0);
|
||||||
|
if (r.parser_stub || unmatchedCount > 0) return "warn";
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
29
web/src/pages/topology/pageTypes.ts
Normal file
29
web/src/pages/topology/pageTypes.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
import type { Edge, Node } from "@xyflow/react";
|
||||||
|
import type { NeNodeData } from "./TopologyReactFlowView";
|
||||||
|
|
||||||
|
export type HistorySnap = {
|
||||||
|
nodes: Node<NeNodeData>[];
|
||||||
|
edges: Edge[];
|
||||||
|
pendingEdgeDeletes: string[];
|
||||||
|
pendingEdgeCreates: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type PaletteSource = "managed" | "ume";
|
||||||
|
|
||||||
|
export type PaletteItem = {
|
||||||
|
key: string;
|
||||||
|
source: PaletteSource;
|
||||||
|
managed_ne_id: string;
|
||||||
|
ume_ne_id: string;
|
||||||
|
name: string;
|
||||||
|
ip: string;
|
||||||
|
vendor: string;
|
||||||
|
meta: string;
|
||||||
|
connect_status: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export 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: "pane"; x: number; y: number; flowX: number; flowY: number };
|
||||||
40
web/src/pages/topology/searchUtils.ts
Normal file
40
web/src/pages/topology/searchUtils.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
import type { Node } from "@xyflow/react";
|
||||||
|
import type { NeNodeData } from "./TopologyReactFlowView";
|
||||||
|
|
||||||
|
export function normalizeSearchText(s: string): string {
|
||||||
|
return String(s || "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[\s_\-./]+/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Case-insensitive substring + loose subsequence (e.g. r1 → router-1). */
|
||||||
|
export function fuzzyIncludes(haystack: string, needle: string): boolean {
|
||||||
|
const h = normalizeSearchText(haystack);
|
||||||
|
const n = normalizeSearchText(needle);
|
||||||
|
if (!n) return true;
|
||||||
|
if (!h) return false;
|
||||||
|
if (h.includes(n)) return true;
|
||||||
|
let i = 0;
|
||||||
|
for (const ch of h) {
|
||||||
|
if (ch === n[i]) i += 1;
|
||||||
|
if (i >= n.length) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nodeMatchesQuery(n: Node<NeNodeData>, query: string): boolean {
|
||||||
|
const tokens = String(query || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.split(/\s+/)
|
||||||
|
.filter(Boolean);
|
||||||
|
if (!tokens.length) return false;
|
||||||
|
const bits = [n.data.label, n.data.ne_ip, n.data.vendor, n.data.managed_ne_id, n.data.ume_ne_id];
|
||||||
|
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isPlaceholderSource(source: string | undefined, neIp: string): boolean {
|
||||||
|
const src = String(source || "").trim().toLowerCase();
|
||||||
|
if (src === "lldp" || src === "topology") return true;
|
||||||
|
return !String(neIp || "").trim() && Boolean(src);
|
||||||
|
}
|
||||||
35
web/src/pages/topology/sidebarLayout.ts
Normal file
35
web/src/pages/topology/sidebarLayout.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import {
|
||||||
|
SIDEBAR_WIDTH_DEFAULT,
|
||||||
|
SIDEBAR_WIDTH_KEY,
|
||||||
|
SIDEBAR_WIDTH_MAX,
|
||||||
|
SIDEBAR_WIDTH_MIN,
|
||||||
|
} from "./constants";
|
||||||
|
|
||||||
|
export function loadSidebarWidth(): number {
|
||||||
|
try {
|
||||||
|
const n = Number(localStorage.getItem(SIDEBAR_WIDTH_KEY));
|
||||||
|
if (Number.isFinite(n) && n >= SIDEBAR_WIDTH_MIN) {
|
||||||
|
return Math.min(Math.round(n), SIDEBAR_WIDTH_MAX);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
return SIDEBAR_WIDTH_DEFAULT;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveSidebarWidth(width: number): void {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(SIDEBAR_WIDTH_KEY, String(Math.round(width)));
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clampSidebarWidth(width: number, pageWidth?: number): number {
|
||||||
|
const maxByPage =
|
||||||
|
pageWidth && pageWidth > 0
|
||||||
|
? Math.max(SIDEBAR_WIDTH_MIN, Math.floor(pageWidth * 0.55))
|
||||||
|
: SIDEBAR_WIDTH_MAX;
|
||||||
|
const maxW = Math.min(SIDEBAR_WIDTH_MAX, maxByPage);
|
||||||
|
return Math.max(SIDEBAR_WIDTH_MIN, Math.min(maxW, Math.round(width)));
|
||||||
|
}
|
||||||
49
web/src/pages/topology/topoGeometry.ts
Normal file
49
web/src/pages/topology/topoGeometry.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
/** Fixed box for onlyRenderVisibleElements (xyflow skips off-screen mount when sized + handles set). */
|
||||||
|
export const TOPO_NODE_W = 80;
|
||||||
|
export const TOPO_NODE_H = 25;
|
||||||
|
export const TOPO_ICON = 25;
|
||||||
|
/** region-building.png is 195×133 — keep aspect so icon center == handle. */
|
||||||
|
export const TOPO_REGION_ICON_H = Math.round((TOPO_ICON * 133) / 195);
|
||||||
|
export const TOPO_HANDLE_X = TOPO_NODE_W / 2;
|
||||||
|
export const TOPO_HANDLE_Y = TOPO_ICON / 2;
|
||||||
|
export const TOPO_REGION_HANDLE_X = TOPO_NODE_W / 2;
|
||||||
|
export const TOPO_REGION_HANDLE_Y = TOPO_REGION_ICON_H / 2;
|
||||||
|
|
||||||
|
/** API x/y is icon center; RF position is top-left of the node box. */
|
||||||
|
export function iconFlowPosition(
|
||||||
|
apiX: number,
|
||||||
|
apiY: number,
|
||||||
|
handleX: number,
|
||||||
|
handleY: number,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
return { x: apiX - handleX, y: apiY - handleY };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function iconApiPosition(
|
||||||
|
flowX: number,
|
||||||
|
flowY: number,
|
||||||
|
handleX: number,
|
||||||
|
handleY: number,
|
||||||
|
): { x: number; y: number } {
|
||||||
|
return { x: flowX + handleX, y: flowY + handleY };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regionFlowPosition(apiX: number, apiY: number): { x: number; y: number } {
|
||||||
|
return iconFlowPosition(apiX, apiY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regionApiPosition(flowX: number, flowY: number): { x: number; y: number } {
|
||||||
|
return iconApiPosition(flowX, flowY, TOPO_REGION_HANDLE_X, TOPO_REGION_HANDLE_Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function neFlowPosition(apiX: number, apiY: number): { x: number; y: number } {
|
||||||
|
return iconFlowPosition(apiX, apiY, TOPO_HANDLE_X, TOPO_HANDLE_Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function neApiPosition(flowX: number, flowY: number): { x: number; y: number } {
|
||||||
|
return iconApiPosition(flowX, flowY, TOPO_HANDLE_X, TOPO_HANDLE_Y);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRegionFabricId(id: string): boolean {
|
||||||
|
return String(id || "").startsWith("region:");
|
||||||
|
}
|
||||||
120
web/src/pages/topology/topologyIcons.tsx
Normal file
120
web/src/pages/topology/topologyIcons.tsx
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
export function ChevronIcon({ open }: { open: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
width="12"
|
||||||
|
height="12"
|
||||||
|
aria-hidden="true"
|
||||||
|
className="topo-svg-icon"
|
||||||
|
style={{ transform: open ? "rotate(90deg)" : "rotate(0deg)", transition: "transform 0.12s ease" }}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
d="M9 6l6 6-6 6"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SidebarFoldIcon({ expand }: { expand?: boolean }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
|
||||||
|
<path
|
||||||
|
d={expand ? "M9 6l6 6-6 6" : "M15 6l-6 6 6 6"}
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PencilIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
|
||||||
|
<path
|
||||||
|
fill="currentColor"
|
||||||
|
d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04a1 1 0 0 0 0-1.41l-2.34-2.34a1 1 0 0 0-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PlusIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
d="M12 5v14M5 12h14"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CloseIcon() {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width="14" height="14" aria-hidden="true" className="topo-svg-icon">
|
||||||
|
<path
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2.2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
d="M6 6l12 12M18 6L6 18"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RegionGlyph({ size = 16 }: { size?: number }) {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
|
||||||
|
<path
|
||||||
|
d="M4 8.5L12 4l8 4.5v7L12 20l-8-4.5v-7z"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="1.8"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
/>
|
||||||
|
<path d="M12 4v16M4 8.5l8 4.5 8-4.5" fill="none" stroke="currentColor" strokeWidth="1.5" opacity="0.7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LayerGlyph({ role, size = 16 }: { role?: string; size?: number }) {
|
||||||
|
const r = String(role || "core").toLowerCase();
|
||||||
|
if (r === "aggregation") {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
|
||||||
|
<rect x="4" y="5" width="16" height="4" rx="1.5" fill="currentColor" opacity="0.9" />
|
||||||
|
<rect x="6" y="11" width="12" height="3.5" rx="1.2" fill="currentColor" opacity="0.65" />
|
||||||
|
<rect x="8" y="16.5" width="8" height="3" rx="1" fill="currentColor" opacity="0.45" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (r === "access") {
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
|
||||||
|
<circle cx="7" cy="12" r="2.4" fill="currentColor" />
|
||||||
|
<circle cx="17" cy="7" r="2.2" fill="currentColor" opacity="0.8" />
|
||||||
|
<circle cx="17" cy="17" r="2.2" fill="currentColor" opacity="0.8" />
|
||||||
|
<path d="M9.4 12H14.5M14.5 12L16 8.4M14.5 12L16 15.6" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg viewBox="0 0 24 24" width={size} height={size} aria-hidden="true" className="topo-tree__glyph-svg">
|
||||||
|
<rect x="7" y="3.5" width="10" height="17" rx="2" fill="none" stroke="currentColor" strokeWidth="1.8" />
|
||||||
|
<circle cx="12" cy="8" r="2.2" fill="currentColor" />
|
||||||
|
<path d="M9 13.5h6M9 16.5h6" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
web/src/pages/topology/treeUtils.ts
Normal file
227
web/src/pages/topology/treeUtils.ts
Normal file
|
|
@ -0,0 +1,227 @@
|
||||||
|
import type { CSSProperties } from "react";
|
||||||
|
import type { TopologyTreeFolderItem, TopologyTreeViewItem, TopologyWorldTransform } from "../../types";
|
||||||
|
import { WORLD_MAP_ENABLED } from "./constants";
|
||||||
|
|
||||||
|
export { WORLD_MAP_ENABLED };
|
||||||
|
|
||||||
|
export function findViewInRegion(
|
||||||
|
regions: TopologyTreeFolderItem[],
|
||||||
|
viewId: string,
|
||||||
|
): { region: TopologyTreeFolderItem; view: TopologyTreeViewItem } | null {
|
||||||
|
for (const region of regions) {
|
||||||
|
const view = (region.views || []).find((v) => v.id === viewId);
|
||||||
|
if (view) return { region, view };
|
||||||
|
const nested = findViewInRegion(region.children || [], viewId);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findFolderInTree(
|
||||||
|
folders: TopologyTreeFolderItem[],
|
||||||
|
folderId: string,
|
||||||
|
): TopologyTreeFolderItem | null {
|
||||||
|
for (const folder of folders) {
|
||||||
|
if (folder.id === folderId) return folder;
|
||||||
|
const nested = findFolderInTree(folder.children || [], folderId);
|
||||||
|
if (nested) return nested;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function indexFoldersById(
|
||||||
|
folders: TopologyTreeFolderItem[],
|
||||||
|
acc: Map<string, TopologyTreeFolderItem> = new Map(),
|
||||||
|
): Map<string, TopologyTreeFolderItem> {
|
||||||
|
for (const folder of folders) {
|
||||||
|
acc.set(folder.id, folder);
|
||||||
|
indexFoldersById(folder.children || [], acc);
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function folderPathIds(folders: TopologyTreeFolderItem[], folderId: string): string[] {
|
||||||
|
const byId = indexFoldersById(folders);
|
||||||
|
const path: string[] = [];
|
||||||
|
let cur: string | undefined = folderId;
|
||||||
|
const seen = new Set<string>();
|
||||||
|
while (cur && !seen.has(cur)) {
|
||||||
|
seen.add(cur);
|
||||||
|
path.push(cur);
|
||||||
|
const folder = byId.get(cur);
|
||||||
|
const parent = String(folder?.parent_id || "").trim();
|
||||||
|
if (!folder || !parent || !byId.has(parent)) break;
|
||||||
|
cur = parent;
|
||||||
|
}
|
||||||
|
return path;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function folderPathFolders(
|
||||||
|
folders: TopologyTreeFolderItem[],
|
||||||
|
folderId: string,
|
||||||
|
): TopologyTreeFolderItem[] {
|
||||||
|
if (!folderId) return [];
|
||||||
|
const byId = indexFoldersById(folders);
|
||||||
|
return folderPathIds(folders, folderId)
|
||||||
|
.map((id) => byId.get(id))
|
||||||
|
.filter((f): f is TopologyTreeFolderItem => Boolean(f))
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUmeWorldContainer(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||||
|
if (!folder) return false;
|
||||||
|
const ext = String(folder.external_ref || "").trim();
|
||||||
|
return ext === "ume:world" || folder.name === "UME World";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUmeWorldNavFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||||
|
if (!folder) return false;
|
||||||
|
const ext = String(folder.external_ref || "").trim();
|
||||||
|
if (isUmeWorldContainer(folder) || ext === "ume:world:drill") return true;
|
||||||
|
return Boolean(ext);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorldDrillFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||||
|
if (!folder) return false;
|
||||||
|
const ext = String(folder.external_ref || "").trim();
|
||||||
|
return ext === "ume:world:drill" || (folder.name === "World" && Boolean(folder.is_system));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWorldFlatViewName(name: string | undefined | null): boolean {
|
||||||
|
const n = String(name || "").trim();
|
||||||
|
return n === "世界地图" || n === "完整世界地图" || n === "World map";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isManualRootMapName(name: string | undefined | null): boolean {
|
||||||
|
const n = String(name || "").trim();
|
||||||
|
return n === "根图" || n === "Root map";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayViewName(name: string | undefined | null, t: (key: string) => string): string {
|
||||||
|
if (isWorldFlatViewName(name)) return t("topology.worldMapName");
|
||||||
|
if (isManualRootMapName(name)) return t("topology.rootMapName");
|
||||||
|
return String(name || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUmeStructuralFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||||
|
if (!folder) return false;
|
||||||
|
if (isUmeWorldContainer(folder) || isWorldDrillFolder(folder)) return true;
|
||||||
|
const ext = String(folder.external_ref || "").trim();
|
||||||
|
return ext === "ume:world" || ext === "ume:world:drill";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isManualRootMapFolder(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||||
|
if (!folder || isUmeStructuralFolder(folder)) return false;
|
||||||
|
return Boolean(folder.is_system) && !String(folder.external_ref || "").trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isUmeSyncedSubRegion(folder: TopologyTreeFolderItem | null | undefined): boolean {
|
||||||
|
if (!folder || isUmeStructuralFolder(folder)) return false;
|
||||||
|
return Boolean(String(folder.external_ref || "").trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function worldVisualLodFromZoom(zoom: number): "dot" | "pin" | "full" {
|
||||||
|
if (zoom < 0.04) return "dot";
|
||||||
|
if (zoom < 0.12) return "pin";
|
||||||
|
return "full";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function worldDisplayBounds(wt: TopologyWorldTransform | null | undefined): {
|
||||||
|
x: number;
|
||||||
|
y: number;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
} | null {
|
||||||
|
if (!wt) return null;
|
||||||
|
const scale = Number(wt.scale) || 1;
|
||||||
|
const width = Math.max(1, (Number(wt.full_max_x) - Number(wt.full_min_x)) * scale);
|
||||||
|
const height = Math.max(1, (Number(wt.full_max_y) - Number(wt.full_min_y)) * scale);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height)) return null;
|
||||||
|
return { x: 0, y: 0, width, height };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isRegionCanvasFolder(
|
||||||
|
folder: TopologyTreeFolderItem | null | undefined,
|
||||||
|
rootId?: string,
|
||||||
|
): boolean {
|
||||||
|
if (!folder) return false;
|
||||||
|
if (isUmeWorldContainer(folder)) return false;
|
||||||
|
const rid = String(rootId || "").trim();
|
||||||
|
const parent = String(folder.parent_id || "").trim();
|
||||||
|
if (rid && parent === rid) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function regionDisplayName(
|
||||||
|
region: TopologyTreeFolderItem | null | undefined,
|
||||||
|
t?: (key: string) => string,
|
||||||
|
): string {
|
||||||
|
if (!region) return "";
|
||||||
|
const raw = String(region.name || "").trim();
|
||||||
|
if (t && isManualRootMapName(raw)) return t("topology.rootMapName");
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function folderNeCount(folder: TopologyTreeFolderItem | null | undefined): number {
|
||||||
|
if (!folder) return 0;
|
||||||
|
return Math.max(0, Math.floor(Number(folder.ne_count) || 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatNeCount(count: number): string {
|
||||||
|
const n = Math.max(0, Math.floor(Number(count) || 0));
|
||||||
|
return `${n}N`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canvasBgLuminance(bg: string): number {
|
||||||
|
const hex = String(bg || "").replace("#", "").trim();
|
||||||
|
if (hex.length !== 6) return 0.1;
|
||||||
|
const r = parseInt(hex.slice(0, 2), 16);
|
||||||
|
const g = parseInt(hex.slice(2, 4), 16);
|
||||||
|
const b = parseInt(hex.slice(4, 6), 16);
|
||||||
|
if (![r, g, b].every((n) => Number.isFinite(n))) return 0.1;
|
||||||
|
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function canvasFloatChromeVars(bg: string): CSSProperties {
|
||||||
|
const light = canvasBgLuminance(bg) > 0.55;
|
||||||
|
if (light) {
|
||||||
|
return {
|
||||||
|
"--topo-float-bg": "rgba(255, 255, 255, 0.92)",
|
||||||
|
"--topo-float-border": "rgba(15, 23, 42, 0.08)",
|
||||||
|
"--topo-float-shadow": "0 8px 24px rgba(15, 23, 42, 0.12)",
|
||||||
|
"--topo-float-fg": "#0f172a",
|
||||||
|
"--topo-float-muted": "#64748b",
|
||||||
|
"--topo-float-btn-bg": "transparent",
|
||||||
|
"--topo-float-btn-border": "transparent",
|
||||||
|
"--topo-float-btn-hover": "rgba(15, 23, 42, 0.06)",
|
||||||
|
"--topo-float-active-bg": "rgba(37, 99, 235, 0.16)",
|
||||||
|
"--topo-float-active-fg": "#1d4ed8",
|
||||||
|
"--topo-float-active-border": "#3b82f6",
|
||||||
|
"--topo-float-control-bg": "transparent",
|
||||||
|
"--topo-float-control-hover": "rgba(15, 23, 42, 0.06)",
|
||||||
|
"--topo-float-control-fill": "#334155",
|
||||||
|
"--topo-float-input-bg": "#ffffff",
|
||||||
|
"--topo-float-input-fg": "#0f172a",
|
||||||
|
"--topo-float-input-border": "rgba(15, 23, 42, 0.16)",
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"--topo-float-bg": "rgba(15, 23, 42, 0.92)",
|
||||||
|
"--topo-float-border": "rgba(148, 163, 184, 0.18)",
|
||||||
|
"--topo-float-shadow": "0 8px 24px rgba(0, 0, 0, 0.35)",
|
||||||
|
"--topo-float-fg": "#e2e8f0",
|
||||||
|
"--topo-float-muted": "#94a3b8",
|
||||||
|
"--topo-float-btn-bg": "transparent",
|
||||||
|
"--topo-float-btn-border": "transparent",
|
||||||
|
"--topo-float-btn-hover": "rgba(30, 41, 59, 0.9)",
|
||||||
|
"--topo-float-active-bg": "rgba(37, 99, 235, 0.28)",
|
||||||
|
"--topo-float-active-fg": "#93c5fd",
|
||||||
|
"--topo-float-active-border": "#3b82f6",
|
||||||
|
"--topo-float-control-bg": "transparent",
|
||||||
|
"--topo-float-control-hover": "rgba(30, 41, 59, 0.9)",
|
||||||
|
"--topo-float-control-fill": "#cbd5e1",
|
||||||
|
"--topo-float-input-bg": "#0b1220",
|
||||||
|
"--topo-float-input-fg": "#e2e8f0",
|
||||||
|
"--topo-float-input-border": "rgba(148, 163, 184, 0.28)",
|
||||||
|
} as CSSProperties;
|
||||||
|
}
|
||||||
35
web/src/pages/topology/vendorTone.ts
Normal file
35
web/src/pages/topology/vendorTone.ts
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
import type { NeNodeData } from "./TopologyReactFlowView";
|
||||||
|
import { VENDOR_TONE_KEYS, type VendorColors, type VendorToneKey } from "./displayPrefs";
|
||||||
|
|
||||||
|
export type { VendorToneKey, VendorColors };
|
||||||
|
|
||||||
|
export function nodeIconTone(
|
||||||
|
vendor: string,
|
||||||
|
managedNeId: string,
|
||||||
|
umeNeId = "",
|
||||||
|
): VendorToneKey {
|
||||||
|
const hasManaged = Boolean(String(managedNeId || "").trim());
|
||||||
|
const hasUme = Boolean(String(umeNeId || "").trim());
|
||||||
|
if (!hasManaged && !hasUme) return "gray";
|
||||||
|
const v = String(vendor || "").trim().toLowerCase();
|
||||||
|
if (!v || v === "other" || v === "unknown" || v === "generic") {
|
||||||
|
return hasUme ? "zte" : "gray";
|
||||||
|
}
|
||||||
|
if (v.includes("cisco")) return "cisco";
|
||||||
|
if (v.includes("huawei")) return "huawei";
|
||||||
|
if (v.includes("zte")) return "zte";
|
||||||
|
if (v.includes("juniper")) return "juniper";
|
||||||
|
if (v.includes("nokia") || v.includes("alcatel")) return "nokia";
|
||||||
|
if (v.includes("ericsson")) return "ericsson";
|
||||||
|
if (v.includes("h3c") || v.includes("comware")) return "h3c";
|
||||||
|
if (v.includes("ruijie") || v.includes("锐捷")) return "ruijie";
|
||||||
|
if (v.includes("mikrotik")) return "mikrotik";
|
||||||
|
return hasUme ? "zte" : "gray";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function vendorColorForNode(data: NeNodeData, colors: VendorColors): string {
|
||||||
|
if (data.kind === "region") return "#38bdf8";
|
||||||
|
return colors[nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id)] || colors.gray;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { VENDOR_TONE_KEYS };
|
||||||
73
web/src/pages/topology/worldGraph.ts
Normal file
73
web/src/pages/topology/worldGraph.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
import type {
|
||||||
|
TopologyViewEdgeItem,
|
||||||
|
TopologyViewGraph,
|
||||||
|
TopologyViewNodeItem,
|
||||||
|
} from "../../types";
|
||||||
|
import { WORLD_FLAT_ACCUM_CAP } from "./constants";
|
||||||
|
|
||||||
|
/** Merge newly fetched flat LOD into the existing cache so visited areas stay. */
|
||||||
|
export function mergeFlatWorldGraph(
|
||||||
|
prev: TopologyViewGraph | undefined,
|
||||||
|
next: TopologyViewGraph,
|
||||||
|
opts?: { centerX?: number; centerY?: number },
|
||||||
|
): TopologyViewGraph {
|
||||||
|
const scatter = next.scatter?.length ? next.scatter : prev?.scatter;
|
||||||
|
if (!prev?.nodes?.length) {
|
||||||
|
return scatter?.length && !next.scatter?.length ? { ...next, scatter } : next;
|
||||||
|
}
|
||||||
|
if (!next?.nodes?.length) {
|
||||||
|
return {
|
||||||
|
...prev,
|
||||||
|
view: next.view || prev.view,
|
||||||
|
nodes: [],
|
||||||
|
edges: [],
|
||||||
|
scatter: scatter || [],
|
||||||
|
world_transform: next.world_transform ?? prev.world_transform,
|
||||||
|
truncated: Boolean(next.truncated || prev.truncated),
|
||||||
|
truncate_reason: next.truncate_reason || prev.truncate_reason || "",
|
||||||
|
outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const nodeMap = new Map<string, TopologyViewNodeItem>();
|
||||||
|
for (const n of prev.nodes) nodeMap.set(String(n.fabric_node_id), n);
|
||||||
|
for (const n of next.nodes) nodeMap.set(String(n.fabric_node_id), n);
|
||||||
|
|
||||||
|
const edgeMap = new Map<string, TopologyViewEdgeItem>();
|
||||||
|
for (const e of prev.edges || []) edgeMap.set(String(e.id), e);
|
||||||
|
for (const e of next.edges || []) edgeMap.set(String(e.id), e);
|
||||||
|
|
||||||
|
let nodes = [...nodeMap.values()];
|
||||||
|
const cx = opts?.centerX;
|
||||||
|
const cy = opts?.centerY;
|
||||||
|
if (nodes.length > WORLD_FLAT_ACCUM_CAP && cx != null && cy != null) {
|
||||||
|
const prefer = new Set(next.nodes.map((n) => String(n.fabric_node_id)));
|
||||||
|
const kept = next.nodes.slice();
|
||||||
|
const extras = nodes
|
||||||
|
.filter((n) => !prefer.has(String(n.fabric_node_id)))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const da = (Number(a.x) - cx) ** 2 + (Number(a.y) - cy) ** 2;
|
||||||
|
const db = (Number(b.x) - cx) ** 2 + (Number(b.y) - cy) ** 2;
|
||||||
|
return da - db;
|
||||||
|
});
|
||||||
|
const room = Math.max(0, WORLD_FLAT_ACCUM_CAP - kept.length);
|
||||||
|
nodes = kept.concat(extras.slice(0, room));
|
||||||
|
}
|
||||||
|
|
||||||
|
const idSet = new Set(nodes.map((n) => String(n.fabric_node_id)));
|
||||||
|
const edges = [...edgeMap.values()].filter(
|
||||||
|
(e) => idSet.has(String(e.a_node_id)) && idSet.has(String(e.b_node_id)),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...next,
|
||||||
|
view: next.view || prev.view,
|
||||||
|
nodes,
|
||||||
|
edges,
|
||||||
|
world_transform: next.world_transform ?? prev.world_transform,
|
||||||
|
scatter: scatter || [],
|
||||||
|
truncated: Boolean(prev.truncated || next.truncated),
|
||||||
|
truncate_reason: next.truncate_reason || prev.truncate_reason || "",
|
||||||
|
outside_peers: next.outside_peers?.length ? next.outside_peers : prev.outside_peers,
|
||||||
|
};
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue