mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 03:40:51 +08:00
Optimize topology canvas per the 2026-10-12 plan.
Save path: one atomic POST /views/{id}/commit (edges, ports, styles, node
add/remove, positions) replaces the multi-request save. Edge styles and
manual-edge port edits now go through dirty/undo/save instead of writing
immediately; non-manual edge ports are read-only.
Data safety: UI saves no longer clear MCP node locks or freeze the device
name into the placement label (label_override + omit-means-keep semantics,
also applied to the MCP positions tool). Adding NEs/neighbors with unsaved
edits now saves first instead of silently dropping them. Positions no longer
bypass membership caps, except persisting synthesized child-region icons.
Fixes found in acceptance: nodes added in the same commit kept (40,40)
under autoflush=False; edits on edges deleted in the same save 404'd the
whole save; box-selection drags and arrow-key moves were not marked dirty;
large canvases far from the origin opened blank and locate did nothing
because fitView waits for measurement that onlyRenderVisibleElements never
does (now fitBounds from known geometry).
Also: batch N+1 lookups in graph/positions, deterministic truncation,
outside-peer scan excludes internal edges, split TopologyPage into hooks,
fix flaky UME/LLDP port test, add scripts/topology_clear_frozen_labels.py.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
60a0ea6021
commit
f301544b99
40 changed files with 5587 additions and 1916 deletions
File diff suppressed because it is too large
Load diff
|
|
@ -399,8 +399,10 @@ export function TopologyCtxMenu({
|
|||
className="topo-ctx__style-input"
|
||||
value={selectedEdgeData.source_port || ""}
|
||||
placeholder="?"
|
||||
readOnly={String(selectedEdgeData.source || "").toLowerCase() !== "manual"}
|
||||
disabled={
|
||||
Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks
|
||||
String(selectedEdgeData.source || "").toLowerCase() !== "manual" ||
|
||||
(Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks)
|
||||
}
|
||||
onFocus={() => onPushHistory()}
|
||||
onChange={(e) =>
|
||||
|
|
@ -421,8 +423,10 @@ export function TopologyCtxMenu({
|
|||
className="topo-ctx__style-input"
|
||||
value={selectedEdgeData.target_port || ""}
|
||||
placeholder="?"
|
||||
readOnly={String(selectedEdgeData.source || "").toLowerCase() !== "manual"}
|
||||
disabled={
|
||||
Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks
|
||||
String(selectedEdgeData.source || "").toLowerCase() !== "manual" ||
|
||||
(Number(selectedEdgeData.member_count || 1) > 1 && !expandPhysicalLinks)
|
||||
}
|
||||
onFocus={() => onPushHistory()}
|
||||
onChange={(e) =>
|
||||
|
|
|
|||
867
web/src/features/topology/components/TopologyPageView.tsx
Normal file
867
web/src/features/topology/components/TopologyPageView.tsx
Normal file
|
|
@ -0,0 +1,867 @@
|
|||
import type { CSSProperties } from "react";
|
||||
import { Suspense } from "react";
|
||||
import { Button } from "@heroui/react";
|
||||
import { TopologyDiscoverPanel } from "./TopologyDiscoverPanel";
|
||||
import { TopologyHexBrowser } from "./TopologyHexBrowser";
|
||||
import { TopologyToolbar } from "./TopologyToolbar";
|
||||
import { TopologyViewTools } from "./TopologyViewTools";
|
||||
import { TopologySidebar } from "./TopologySidebar";
|
||||
import { TopologyModals } from "./TopologyModals";
|
||||
import { TopologyCtxMenu } from "./TopologyCtxMenu";
|
||||
import type { TopologyPageViewModel } from "../lib/pageViewModel";
|
||||
import type { PaletteItem } from "../lib/pageTypes";
|
||||
import { canvasBgLuminance, canvasFloatChromeVars } from "../lib/treeUtils";
|
||||
import type { PlaceholderCreateDialogState } from "./modals/PlaceholderCreateDialog";
|
||||
|
||||
|
||||
type Props = {
|
||||
v: TopologyPageViewModel;
|
||||
};
|
||||
|
||||
export function TopologyPageView({ v }: Props) {
|
||||
const {
|
||||
TopologyReactFlowView,
|
||||
activeRegion,
|
||||
activeView,
|
||||
addNeOpen,
|
||||
addOutsidePeers,
|
||||
addPaletteItems,
|
||||
alarmByNodeId,
|
||||
applyAlign,
|
||||
applyWorldMut,
|
||||
autoLayoutAfterDiscover,
|
||||
breadcrumbFolders,
|
||||
browseEntries,
|
||||
canRedo,
|
||||
canUndo,
|
||||
canWriteTopology,
|
||||
cancelDiscover,
|
||||
canvasBg,
|
||||
canvasGraphEmpty,
|
||||
canvasGraphError,
|
||||
canvasGraphErrorMsg,
|
||||
canvasGraphLoading,
|
||||
canvasGraphRefreshing,
|
||||
canvasHits,
|
||||
canvasMode,
|
||||
canvasQuery,
|
||||
canvasRef,
|
||||
canvasZoom,
|
||||
clearSelection,
|
||||
closeConnectDetail,
|
||||
closeCreateNeMode,
|
||||
closeCtxMenu,
|
||||
closeManagedForm,
|
||||
closePlaceholder,
|
||||
connectClickRef,
|
||||
connectDetailRow,
|
||||
connectTestSelectedFor,
|
||||
connectTestSubmitting,
|
||||
createNeModeOpen,
|
||||
createRegionMut,
|
||||
ctxMenu,
|
||||
ctxMenuRef,
|
||||
debouncedTreeNeQuery,
|
||||
deleteFolderMut,
|
||||
dirty,
|
||||
discoverAutoAddUnmatched,
|
||||
discoverError,
|
||||
discoverOneFor,
|
||||
discoverOpen,
|
||||
discoverPct,
|
||||
discoverProgress,
|
||||
discoverProjectNeighbors,
|
||||
discoverReport,
|
||||
discoverResults,
|
||||
discoverSelectedFor,
|
||||
discoverSummary,
|
||||
discovering,
|
||||
displayEdges,
|
||||
displayMenuRef,
|
||||
displayOpts,
|
||||
edgeDefaults,
|
||||
edgeFlow,
|
||||
edges,
|
||||
expandPhysicalLinks,
|
||||
expandedIds,
|
||||
exporting,
|
||||
findActiveIdx,
|
||||
findBoxRef,
|
||||
findOnCanvas,
|
||||
findOpen,
|
||||
fitCanvas,
|
||||
focusEdge,
|
||||
focusNode,
|
||||
fullscreen,
|
||||
goBackBrowse,
|
||||
goCanvas,
|
||||
goRegion,
|
||||
goRoot,
|
||||
goUmeWorldNav,
|
||||
graphQuery,
|
||||
graphTruncated,
|
||||
hexBrowseRegion,
|
||||
hideIp,
|
||||
hidePorts,
|
||||
hideVendor,
|
||||
hotBrowseKey,
|
||||
isValidConnection,
|
||||
isWorldFlatCanvas,
|
||||
jumpToTreeSearchHit,
|
||||
keyword,
|
||||
labelColors,
|
||||
liveSync,
|
||||
managedFormInitial,
|
||||
managedFormOpen,
|
||||
mapId,
|
||||
markDirty,
|
||||
newRootDialog,
|
||||
nodes,
|
||||
nodesRef,
|
||||
onCanvasDragOver,
|
||||
onCanvasDrop,
|
||||
onConnect,
|
||||
onConnectRetestSubmitted,
|
||||
onManagedFormSaved,
|
||||
onNodeClick,
|
||||
onNodeDoubleClick,
|
||||
onPaletteDragStart,
|
||||
onSidebarResizeDown,
|
||||
onTreeClearHotBrowseKey,
|
||||
onTreeDeleteFolder,
|
||||
onTreeDeleteMap,
|
||||
onTreeHotBrowseKey,
|
||||
onTreeToggleExpand,
|
||||
openAlarmsFor,
|
||||
openCreateNeAt,
|
||||
openNeFor,
|
||||
openPortTrafficForEdge,
|
||||
openWebcrtFor,
|
||||
outsidePeerNameById,
|
||||
outsidePeerQuery,
|
||||
outsidePeerSelectedIds,
|
||||
outsidePeers,
|
||||
outsidePeersAdding,
|
||||
outsidePeersOpen,
|
||||
outsidePeersVisible,
|
||||
paletteAdding,
|
||||
paletteLoading,
|
||||
paletteSelectedKeys,
|
||||
paletteSource,
|
||||
paletteVisible,
|
||||
patchSelectedEdgeStyle,
|
||||
pendingFitRef,
|
||||
pickCreateManaged,
|
||||
pickCreatePlaceholder,
|
||||
placeCtxMenu,
|
||||
placeholderBusy,
|
||||
placeholderDialog,
|
||||
popHistory,
|
||||
primaryViewOfFolder,
|
||||
promptNewRegion,
|
||||
promptNewSubRegion,
|
||||
promptRenameMap,
|
||||
promptRenameRegion,
|
||||
purgePlaceholderById,
|
||||
pushHistory,
|
||||
redo,
|
||||
regions,
|
||||
removeEdgeById,
|
||||
removeNodeById,
|
||||
removeSelected,
|
||||
removeStaleEdges,
|
||||
renameMapMut,
|
||||
renameRegionMut,
|
||||
renameSelectedNode,
|
||||
resetEdgeDefaults,
|
||||
rfRef,
|
||||
rootFolderId,
|
||||
runConnectTestForNode,
|
||||
runTopologyExport,
|
||||
saveMut,
|
||||
scaleBundleWidth,
|
||||
scheduleFlatViewportRefresh,
|
||||
searchHitIds,
|
||||
selectedConnectableCount,
|
||||
selectedDiscoverableCount,
|
||||
selectedEdge,
|
||||
selectedEdgeData,
|
||||
selectedEdgeId,
|
||||
selectedEdgeResolved,
|
||||
selectedEdgeSourceNode,
|
||||
selectedEdgeTargetNode,
|
||||
selectedFolderId,
|
||||
selectedNode,
|
||||
selectedNodeIds,
|
||||
selectedNodes,
|
||||
setAddNeOpen,
|
||||
setAutoLayoutAfterDiscover,
|
||||
setCanvasBg,
|
||||
setCanvasQuery,
|
||||
setCanvasZoom,
|
||||
setCtxMenu,
|
||||
setDiscoverAutoAddUnmatched,
|
||||
setDiscoverOpen,
|
||||
setDiscoverProjectNeighbors,
|
||||
setEdgeFlow,
|
||||
setEdges,
|
||||
setExpandPhysicalLinks,
|
||||
setFindActiveIdx,
|
||||
setFindOpen,
|
||||
setHideIp,
|
||||
setHidePorts,
|
||||
setHideVendor,
|
||||
setHotBrowseKey,
|
||||
setKeyword,
|
||||
setLabelColors,
|
||||
setLiveSync,
|
||||
setNewRootDialog,
|
||||
setNodes,
|
||||
setOutsidePeerQuery,
|
||||
setOutsidePeerSelectedIds,
|
||||
setOutsidePeersOpen,
|
||||
setPaletteSelectedKeys,
|
||||
setPaletteSource,
|
||||
setPlaceholderDialog,
|
||||
setScaleBundleWidth,
|
||||
setSelectedFolderId,
|
||||
setShowAlarmOverlay,
|
||||
setShowConnectStatus,
|
||||
setShowLevelColors,
|
||||
setShowPlaceholderBadge,
|
||||
setSidebarCollapsed,
|
||||
setSnapToGrid,
|
||||
setToolMode,
|
||||
setTreeNeQuery,
|
||||
setTreeSearchOpen,
|
||||
setVendorColors,
|
||||
setViewToolsToolbarSlot,
|
||||
setWorldFocusFolderId,
|
||||
showAlarmOverlay,
|
||||
showConnectStatus,
|
||||
showError,
|
||||
showLevelColors,
|
||||
showPlaceholderBadge,
|
||||
showWorldScatter,
|
||||
sidebarCollapsed,
|
||||
sidebarResizing,
|
||||
sidebarWidth,
|
||||
snapToGrid,
|
||||
staleEdgeIds,
|
||||
submitNewRoot,
|
||||
submitPlaceholder,
|
||||
t,
|
||||
titleText,
|
||||
toggleFullscreen,
|
||||
toolBehavior,
|
||||
toolMode,
|
||||
topoPageRef,
|
||||
treeFailed,
|
||||
treeLoading,
|
||||
treeNeQuery,
|
||||
treeNeSearchQuery,
|
||||
treeQuery,
|
||||
treeRoot,
|
||||
treeSearchOpen,
|
||||
treeSearchRef,
|
||||
truncateBannerText,
|
||||
truncateReason,
|
||||
umeWorldHexModules,
|
||||
undo,
|
||||
updateEdgeDefault,
|
||||
vendorColors,
|
||||
viewToolsToolbarSlot,
|
||||
worldDockMe,
|
||||
worldNeedsApply,
|
||||
worldScatter,
|
||||
worldTotal,
|
||||
worldTransformRef,
|
||||
worldViewId,
|
||||
worldVisualLod,
|
||||
} = v;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={topoPageRef}
|
||||
className={`topo-page${sidebarCollapsed ? " is-sidebar-collapsed" : ""}${
|
||||
sidebarResizing ? " is-sidebar-resizing" : ""
|
||||
}`}
|
||||
style={
|
||||
sidebarCollapsed
|
||||
? undefined
|
||||
: ({ "--topo-sidebar-w": `${sidebarWidth}px` } as CSSProperties)
|
||||
}
|
||||
>
|
||||
<TopologySidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
resizing={sidebarResizing}
|
||||
treeSearchRef={treeSearchRef}
|
||||
treeNeQuery={treeNeQuery}
|
||||
onTreeNeQueryChange={setTreeNeQuery}
|
||||
treeSearchOpen={treeSearchOpen}
|
||||
onTreeSearchOpen={setTreeSearchOpen}
|
||||
debouncedTreeNeQuery={debouncedTreeNeQuery}
|
||||
treeSearchFetching={treeNeSearchQuery.isFetching}
|
||||
treeSearchItems={treeNeSearchQuery.data?.items || []}
|
||||
treeSearchTotal={treeNeSearchQuery.data?.total || 0}
|
||||
onJumpToSearchHit={jumpToTreeSearchHit}
|
||||
promptNewRegion={promptNewRegion}
|
||||
createRegionPending={createRegionMut.isPending}
|
||||
onCollapse={() => setSidebarCollapsed(true)}
|
||||
onExpand={() => setSidebarCollapsed(false)}
|
||||
treeRoot={treeRoot}
|
||||
treeLoading={treeLoading}
|
||||
treeFailed={treeFailed}
|
||||
treeError={treeQuery.error}
|
||||
onTreeRetry={() => void treeQuery.refetch()}
|
||||
regions={regions}
|
||||
treeNav={{
|
||||
treeRootId: String(treeRoot?.id || ""),
|
||||
expandedIds,
|
||||
mapId,
|
||||
worldViewId,
|
||||
selectedFolderId,
|
||||
hotBrowseKey,
|
||||
dirty,
|
||||
onToggleExpand: onTreeToggleExpand,
|
||||
onHotBrowseKey: onTreeHotBrowseKey,
|
||||
onClearHotBrowseKey: onTreeClearHotBrowseKey,
|
||||
onWorldFocusClear: () => setWorldFocusFolderId(""),
|
||||
goUmeWorldNav,
|
||||
goRegion,
|
||||
goCanvas,
|
||||
promptRenameRegion,
|
||||
promptRenameMap,
|
||||
renameRegionPending: renameRegionMut.isPending,
|
||||
renameMapPending: renameMapMut.isPending,
|
||||
onDeleteFolder: onTreeDeleteFolder,
|
||||
deleteFolderPending: deleteFolderMut.isPending,
|
||||
onDeleteMap: onTreeDeleteMap,
|
||||
}}
|
||||
onResizeDown={onSidebarResizeDown}
|
||||
/>
|
||||
|
||||
<main className="topo-main">
|
||||
{canvasMode ? (
|
||||
<TopologyToolbar
|
||||
readOnly={!canWriteTopology}
|
||||
breadcrumbFolders={breadcrumbFolders}
|
||||
activeView={activeView}
|
||||
activeRegion={activeRegion}
|
||||
rootFolderId={rootFolderId}
|
||||
dirty={dirty}
|
||||
selectedNodes={selectedNodes}
|
||||
selectedEdgeId={selectedEdgeId}
|
||||
liveSync={liveSync}
|
||||
onLiveSyncToggle={() => setLiveSync((v: boolean) => !v)}
|
||||
isWorldFlatCanvas={isWorldFlatCanvas}
|
||||
onAddNe={() => {
|
||||
if (isWorldFlatCanvas) {
|
||||
showError(t("topology.worldMapNoDirectNes"));
|
||||
return;
|
||||
}
|
||||
setKeyword("");
|
||||
setPaletteSelectedKeys([]);
|
||||
setAddNeOpen(true);
|
||||
}}
|
||||
onCreateNe={(flowX, flowY) => {
|
||||
if (isWorldFlatCanvas) {
|
||||
showError(t("topology.worldMapNoDirectNes"));
|
||||
return;
|
||||
}
|
||||
openCreateNeAt(flowX, flowY);
|
||||
}}
|
||||
nodes={nodes}
|
||||
rfRef={rfRef}
|
||||
onBackUp={goBackBrowse}
|
||||
canUndo={canUndo}
|
||||
onUndo={undo}
|
||||
canRedo={canRedo}
|
||||
onRedo={redo}
|
||||
savePending={saveMut.isPending}
|
||||
onSave={() => saveMut.mutate()}
|
||||
onFit={fitCanvas}
|
||||
exporting={exporting}
|
||||
onExport={runTopologyExport}
|
||||
mapId={mapId}
|
||||
staleEdgeCount={staleEdgeIds.length}
|
||||
onRemoveStale={removeStaleEdges}
|
||||
toolMode={toolMode}
|
||||
onToolModeChange={setToolMode}
|
||||
onConnectClickReset={() => {
|
||||
connectClickRef.current = null;
|
||||
}}
|
||||
fullscreen={fullscreen}
|
||||
viewToolsToolbarSlotRef={setViewToolsToolbarSlot}
|
||||
outsidePeerCount={outsidePeers.length}
|
||||
onOpenOutsidePeers={() => {
|
||||
setOutsidePeerQuery("");
|
||||
setOutsidePeerSelectedIds([]);
|
||||
setOutsidePeersOpen(true);
|
||||
}}
|
||||
goRoot={goRoot}
|
||||
goRegion={goRegion}
|
||||
primaryViewOfFolder={primaryViewOfFolder}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{discoverOpen ? (
|
||||
<TopologyDiscoverPanel
|
||||
discovering={discovering}
|
||||
discoverProgress={discoverProgress}
|
||||
discoverPct={discoverPct}
|
||||
discoverReport={discoverReport}
|
||||
discoverError={discoverError}
|
||||
hasResults={discoverResults.length > 0 || Boolean(discoverReport)}
|
||||
discoverSummary={discoverSummary}
|
||||
onCancelDiscover={cancelDiscover}
|
||||
onClose={() => setDiscoverOpen(false)}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!canvasMode ? (
|
||||
<TopologyHexBrowser
|
||||
titleText={titleText}
|
||||
breadcrumbFolders={breadcrumbFolders}
|
||||
hexBrowseRegion={hexBrowseRegion}
|
||||
umeWorldHexModules={umeWorldHexModules}
|
||||
browseEntries={browseEntries}
|
||||
regions={regions}
|
||||
rootFolderId={rootFolderId}
|
||||
selectedFolderId={selectedFolderId}
|
||||
mapId={mapId}
|
||||
hotBrowseKey={hotBrowseKey}
|
||||
treeLoading={treeLoading}
|
||||
treeFailed={treeFailed}
|
||||
treeError={treeQuery.error}
|
||||
createRegionPending={createRegionMut.isPending}
|
||||
onTreeRetry={() => void treeQuery.refetch()}
|
||||
onHotBrowseKey={setHotBrowseKey}
|
||||
onClearHotBrowseKey={(key: string) => setHotBrowseKey((k: string) => (k === key ? "" : k))}
|
||||
goRoot={goRoot}
|
||||
goRegion={goRegion}
|
||||
goUmeWorldNav={goUmeWorldNav}
|
||||
goCanvas={goCanvas}
|
||||
promptNewRegion={promptNewRegion}
|
||||
promptNewSubRegion={promptNewSubRegion}
|
||||
onSelectFolder={setSelectedFolderId}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
className={`topo-canvas${fullscreen ? " is-fullscreen" : ""}${
|
||||
toolMode === "pan" ? " is-pan-mode" : ""
|
||||
}${canvasBgLuminance(canvasBg) > 0.55 ? " is-canvas-light" : ""}`}
|
||||
ref={canvasRef}
|
||||
style={
|
||||
{
|
||||
"--topo-canvas-bg": canvasBg,
|
||||
"--topo-label-color": labelColors.name,
|
||||
"--topo-edge-label-color": labelColors.edgeLabel,
|
||||
"--topo-vendor-cisco": vendorColors.cisco,
|
||||
"--topo-vendor-huawei": vendorColors.huawei,
|
||||
"--topo-vendor-zte": vendorColors.zte,
|
||||
"--topo-vendor-juniper": vendorColors.juniper,
|
||||
"--topo-vendor-nokia": vendorColors.nokia,
|
||||
"--topo-vendor-ericsson": vendorColors.ericsson,
|
||||
"--topo-vendor-h3c": vendorColors.h3c,
|
||||
"--topo-vendor-ruijie": vendorColors.ruijie,
|
||||
"--topo-vendor-mikrotik": vendorColors.mikrotik,
|
||||
"--topo-vendor-gray": vendorColors.gray,
|
||||
...canvasFloatChromeVars(canvasBg),
|
||||
} as CSSProperties
|
||||
}
|
||||
onDragOver={onCanvasDragOver}
|
||||
onDrop={onCanvasDrop}
|
||||
>
|
||||
{truncateBannerText ? (
|
||||
<div className="topo-truncate-banner" role="status">
|
||||
{truncateBannerText}
|
||||
</div>
|
||||
) : null}
|
||||
{!canWriteTopology && mapId ? (
|
||||
<div className="topo-readonly-banner" role="status">
|
||||
{t("topology.readOnlyBanner")}
|
||||
</div>
|
||||
) : null}
|
||||
<TopologyViewTools
|
||||
fullscreen={fullscreen}
|
||||
toolbarSlot={viewToolsToolbarSlot}
|
||||
displayMenuRef={displayMenuRef}
|
||||
findBoxRef={findBoxRef}
|
||||
hideIp={hideIp}
|
||||
onHideIpChange={setHideIp}
|
||||
hideVendor={hideVendor}
|
||||
onHideVendorChange={setHideVendor}
|
||||
showPlaceholderBadge={showPlaceholderBadge}
|
||||
onShowPlaceholderBadgeChange={setShowPlaceholderBadge}
|
||||
showConnectStatus={showConnectStatus}
|
||||
onShowConnectStatusChange={setShowConnectStatus}
|
||||
showAlarmOverlay={showAlarmOverlay}
|
||||
onShowAlarmOverlayChange={setShowAlarmOverlay}
|
||||
showLevelColors={showLevelColors}
|
||||
onShowLevelColorsChange={setShowLevelColors}
|
||||
hidePorts={hidePorts}
|
||||
onHidePortsChange={setHidePorts}
|
||||
expandPhysicalLinks={expandPhysicalLinks}
|
||||
onExpandPhysicalLinksChange={setExpandPhysicalLinks}
|
||||
scaleBundleWidth={scaleBundleWidth}
|
||||
onScaleBundleWidthChange={setScaleBundleWidth}
|
||||
edgeFlow={edgeFlow}
|
||||
onEdgeFlowChange={setEdgeFlow}
|
||||
snapToGrid={snapToGrid}
|
||||
onSnapToGridChange={setSnapToGrid}
|
||||
autoLayoutAfterDiscover={autoLayoutAfterDiscover}
|
||||
onAutoLayoutAfterDiscoverChange={setAutoLayoutAfterDiscover}
|
||||
discoverAutoAddUnmatched={discoverAutoAddUnmatched}
|
||||
onDiscoverAutoAddUnmatchedChange={setDiscoverAutoAddUnmatched}
|
||||
discoverProjectNeighbors={discoverProjectNeighbors}
|
||||
onDiscoverProjectNeighborsChange={setDiscoverProjectNeighbors}
|
||||
canvasBg={canvasBg}
|
||||
onCanvasBgChange={setCanvasBg}
|
||||
labelColors={labelColors}
|
||||
onLabelColorsChange={setLabelColors}
|
||||
vendorColors={vendorColors}
|
||||
onVendorColorsChange={setVendorColors}
|
||||
edgeDefaults={edgeDefaults}
|
||||
onUpdateEdgeDefault={updateEdgeDefault}
|
||||
onResetEdgeDefaults={resetEdgeDefaults}
|
||||
selectedNodeCount={selectedNodes.length}
|
||||
onAlign={applyAlign}
|
||||
mapId={mapId}
|
||||
nodeCount={nodes.length}
|
||||
canvasQuery={canvasQuery}
|
||||
onCanvasQueryChange={setCanvasQuery}
|
||||
findOpen={findOpen}
|
||||
onFindOpenChange={setFindOpen}
|
||||
findActiveIdx={findActiveIdx}
|
||||
onFindActiveIdxChange={setFindActiveIdx}
|
||||
canvasHits={canvasHits}
|
||||
onFindOnCanvas={findOnCanvas}
|
||||
/>
|
||||
{fullscreen ? (
|
||||
<div className="topo-fs-toolbar" 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-fs-toolbar__btn${toolMode === mode ? " is-active" : ""}`}
|
||||
title={`${label} (${key})`}
|
||||
aria-pressed={toolMode === mode}
|
||||
onClick={() => {
|
||||
setToolMode(mode);
|
||||
connectClickRef.current = null;
|
||||
}}
|
||||
>
|
||||
{label}
|
||||
<kbd>{key}</kbd>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="topo-fs-toolbar__btn"
|
||||
title={t("topology.fit")}
|
||||
onClick={() => fitCanvas()}
|
||||
>
|
||||
{t("topology.fit")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="topo-fs-toolbar__btn"
|
||||
title={t("topology.exitFullscreen")}
|
||||
onClick={() => void toggleFullscreen()}
|
||||
>
|
||||
{t("topology.exitFullscreen")}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{treeRoot ? (
|
||||
<>
|
||||
{isWorldFlatCanvas ? (
|
||||
<div className="topo-world-hud" role="status">
|
||||
<span>
|
||||
{t("topology.worldHud")
|
||||
.replace("{{total}}", String(worldTotal || worldScatter.length || 0))
|
||||
.replace("{{cached}}", String(nodes.length))
|
||||
.replace("{{zoom}}", canvasZoom.toFixed(3))
|
||||
.replace("{{lod}}", worldVisualLod)}
|
||||
</span>
|
||||
{graphTruncated ? (
|
||||
<span className="muted"> · {truncateReason || "truncated"}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className="topo-canvas__empty topo-canvas__status" role="status">
|
||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||
<p>{t("topology.treeLoading")}</p>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TopologyReactFlowView
|
||||
displayOpts={displayOpts}
|
||||
alarmByNodeId={alarmByNodeId}
|
||||
nodes={nodes}
|
||||
setNodes={setNodes}
|
||||
edges={edges}
|
||||
setEdges={setEdges}
|
||||
displayEdges={displayEdges}
|
||||
searchHitIds={searchHitIds}
|
||||
isWorldFlatCanvas={isWorldFlatCanvas}
|
||||
worldVisualLod={worldVisualLod}
|
||||
toolBehavior={toolBehavior}
|
||||
toolMode={toolMode}
|
||||
snapToGrid={snapToGrid}
|
||||
canvasBg={canvasBg}
|
||||
worldScatter={worldScatter}
|
||||
showWorldScatter={showWorldScatter}
|
||||
fullscreen={fullscreen}
|
||||
connectHint={t("topology.connectHint")}
|
||||
fullscreenLabel={t("topology.fullscreen")}
|
||||
exitFullscreenLabel={t("topology.exitFullscreen")}
|
||||
worldTransform={worldTransformRef.current || graphQuery.data?.world_transform}
|
||||
nodesRef={nodesRef}
|
||||
rfRef={rfRef}
|
||||
pendingFitRef={pendingFitRef}
|
||||
markDirty={markDirty}
|
||||
pushHistory={pushHistory}
|
||||
popHistory={popHistory}
|
||||
onConnect={onConnect}
|
||||
isValidConnection={isValidConnection}
|
||||
onNodeClick={onNodeClick}
|
||||
onNodeDoubleClick={onNodeDoubleClick}
|
||||
focusEdge={focusEdge}
|
||||
focusNode={focusNode}
|
||||
clearSelection={clearSelection}
|
||||
setCtxMenu={setCtxMenu}
|
||||
placeCtxMenu={placeCtxMenu}
|
||||
selectedNodeIds={selectedNodeIds}
|
||||
closeCtxMenu={closeCtxMenu}
|
||||
setCanvasZoom={setCanvasZoom}
|
||||
scheduleFlatViewportRefresh={scheduleFlatViewportRefresh}
|
||||
toggleFullscreen={toggleFullscreen}
|
||||
/>
|
||||
</Suspense>
|
||||
</>
|
||||
) : treeLoading ? (
|
||||
<div className="topo-canvas__empty topo-canvas__status" role="status">
|
||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||
<p>{t("topology.treeLoading")}</p>
|
||||
</div>
|
||||
) : treeFailed ? (
|
||||
<div className="topo-canvas__empty">
|
||||
<p className="muted">{t("topology.treeLoadFailed")}</p>
|
||||
<div className="topo-browser__empty-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => void treeQuery.refetch()}
|
||||
>
|
||||
{t("topology.treeRetry")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={promptNewRegion}
|
||||
isDisabled={createRegionMut.isPending}
|
||||
>
|
||||
{t("topology.newRegion")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="topo-canvas__empty">
|
||||
<p className="muted">{t("topology.emptyMaps")}</p>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
onPress={promptNewRegion}
|
||||
isDisabled={createRegionMut.isPending}
|
||||
>
|
||||
{t("topology.newRegion")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{treeRoot && canvasGraphError ? (
|
||||
<div className="topo-canvas__overlay topo-canvas__overlay--error" role="alert">
|
||||
<p className="topo-canvas__overlay-title">{t("topology.graphLoadFailed")}</p>
|
||||
{canvasGraphErrorMsg ? (
|
||||
<p className="topo-canvas__overlay-hint muted">{canvasGraphErrorMsg}</p>
|
||||
) : null}
|
||||
<div className="topo-browser__empty-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => void graphQuery.refetch()}
|
||||
>
|
||||
{t("topology.treeRetry")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{treeRoot && canvasGraphLoading ? (
|
||||
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
|
||||
<span className="topo-loading-spinner topo-loading-spinner--lg" aria-hidden="true" />
|
||||
<p className="topo-canvas__overlay-title">{t("topology.graphLoading")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
{treeRoot && canvasGraphEmpty ? (
|
||||
<div
|
||||
className={`topo-canvas__overlay topo-canvas__overlay--empty${
|
||||
worldNeedsApply ? " is-interactive" : ""
|
||||
}`}
|
||||
role="status"
|
||||
>
|
||||
<p className="topo-canvas__overlay-title">
|
||||
{worldNeedsApply ? t("topology.worldEmptyCoords") : t("topology.canvasEmpty")}
|
||||
</p>
|
||||
<p className="topo-canvas__overlay-hint">
|
||||
{worldNeedsApply
|
||||
? t("topology.worldEmptyCoordsHint").replace("{{dock}}", String(worldDockMe))
|
||||
: t("topology.canvasEmptyHint")}
|
||||
</p>
|
||||
{worldNeedsApply ? (
|
||||
<div className="btn-row" style={{ marginTop: 10, justifyContent: "center" }}>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={applyWorldMut.isPending}
|
||||
onPress={() => applyWorldMut.mutate()}
|
||||
>
|
||||
{applyWorldMut.isPending
|
||||
? t("topology.worldApplying")
|
||||
: t("topology.worldApply")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{treeRoot && canvasGraphRefreshing ? (
|
||||
<div className="topo-canvas__refresh" role="status" aria-live="polite">
|
||||
<span className="topo-loading-spinner" aria-hidden="true" />
|
||||
{t("topology.graphRefreshing")}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
<TopologyModals
|
||||
canvasMode={canvasMode}
|
||||
newRootDialog={newRootDialog}
|
||||
onNewRootNameChange={(name) => setNewRootDialog({ name })}
|
||||
onCloseNewRoot={() => setNewRootDialog(null)}
|
||||
onSubmitNewRoot={submitNewRoot}
|
||||
createRegionPending={createRegionMut.isPending}
|
||||
createNeModeOpen={createNeModeOpen}
|
||||
onCloseCreateNeMode={closeCreateNeMode}
|
||||
onPickCreateManaged={pickCreateManaged}
|
||||
onPickCreatePlaceholder={pickCreatePlaceholder}
|
||||
placeholderDialog={placeholderDialog}
|
||||
placeholderBusy={placeholderBusy}
|
||||
onPlaceholderChange={(patch: Partial<PlaceholderCreateDialogState>) =>
|
||||
setPlaceholderDialog((prev: PlaceholderCreateDialogState | null) =>
|
||||
prev ? { ...prev, ...patch } : prev,
|
||||
)
|
||||
}
|
||||
onClosePlaceholder={closePlaceholder}
|
||||
onSubmitPlaceholder={submitPlaceholder}
|
||||
managedFormOpen={managedFormOpen}
|
||||
managedFormInitial={managedFormInitial}
|
||||
onCloseManagedForm={closeManagedForm}
|
||||
onManagedFormSaved={(item) => void onManagedFormSaved(item)}
|
||||
connectDetailRow={connectDetailRow}
|
||||
onCloseConnectDetail={closeConnectDetail}
|
||||
onConnectRetestSubmitted={onConnectRetestSubmitted}
|
||||
outsidePeersOpen={outsidePeersOpen}
|
||||
outsidePeers={outsidePeers}
|
||||
outsidePeersVisible={outsidePeersVisible}
|
||||
outsidePeerQuery={outsidePeerQuery}
|
||||
onOutsidePeerQueryChange={setOutsidePeerQuery}
|
||||
outsidePeerSelectedIds={outsidePeerSelectedIds}
|
||||
onOutsidePeerSelectedIdsChange={setOutsidePeerSelectedIds}
|
||||
outsidePeerNameById={outsidePeerNameById}
|
||||
outsidePeersAdding={outsidePeersAdding}
|
||||
onCloseOutsidePeers={() => setOutsidePeersOpen(false)}
|
||||
onAddOutsidePeers={addOutsidePeers}
|
||||
addNeOpen={addNeOpen}
|
||||
paletteSource={paletteSource}
|
||||
onPaletteSourceChange={(source) => {
|
||||
setPaletteSource(source);
|
||||
setPaletteSelectedKeys([]);
|
||||
}}
|
||||
keyword={keyword}
|
||||
onKeywordChange={(value) => {
|
||||
setKeyword(value);
|
||||
setPaletteSelectedKeys([]);
|
||||
}}
|
||||
paletteVisible={paletteVisible}
|
||||
paletteSelectedKeys={paletteSelectedKeys}
|
||||
onPaletteSelectedKeysChange={setPaletteSelectedKeys}
|
||||
paletteLoading={paletteLoading}
|
||||
paletteAdding={paletteAdding}
|
||||
onCloseAddNe={() => setAddNeOpen(false)}
|
||||
onPaletteDragStart={onPaletteDragStart}
|
||||
onAddSelectedPalette={async () => {
|
||||
const selected = (paletteVisible as PaletteItem[]).filter((item: PaletteItem) =>
|
||||
paletteSelectedKeys.includes(item.key),
|
||||
);
|
||||
if (selected.length === 0) return;
|
||||
const ok = await addPaletteItems(selected);
|
||||
if (!ok) return;
|
||||
setPaletteSelectedKeys([]);
|
||||
setAddNeOpen(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
{ctxMenu ? (
|
||||
<TopologyCtxMenu
|
||||
ctxMenu={ctxMenu}
|
||||
menuRef={ctxMenuRef}
|
||||
portalTarget={canvasRef.current}
|
||||
fullscreen={fullscreen}
|
||||
selectedNodeCount={selectedNodes.length}
|
||||
selectedNode={selectedNode}
|
||||
selectedEdge={selectedEdge}
|
||||
selectedEdgeData={selectedEdgeData}
|
||||
selectedEdgeResolved={selectedEdgeResolved}
|
||||
selectedEdgeSourceNode={selectedEdgeSourceNode || null}
|
||||
selectedEdgeTargetNode={selectedEdgeTargetNode || null}
|
||||
expandPhysicalLinks={expandPhysicalLinks}
|
||||
discovering={discovering}
|
||||
isWorldFlatCanvas={isWorldFlatCanvas}
|
||||
createRegionPending={createRegionMut.isPending}
|
||||
onClose={closeCtxMenu}
|
||||
onToggleFullscreen={toggleFullscreen}
|
||||
onRemoveSelected={removeSelected}
|
||||
onDiscoverSelected={discoverSelectedFor}
|
||||
onConnectTestSelected={connectTestSelectedFor}
|
||||
selectedDiscoverableCount={selectedDiscoverableCount}
|
||||
selectedConnectableCount={selectedConnectableCount}
|
||||
onOpenCreateNe={openCreateNeAt}
|
||||
onPromptNewSubRegion={promptNewSubRegion}
|
||||
onRenameSelectedNode={renameSelectedNode}
|
||||
onDiscoverOne={discoverOneFor}
|
||||
onOpenWebcrt={openWebcrtFor}
|
||||
onOpenNe={openNeFor}
|
||||
onOpenAlarms={openAlarmsFor}
|
||||
onConnectTest={runConnectTestForNode}
|
||||
connectTestBusy={connectTestSubmitting}
|
||||
onPurgePlaceholder={purgePlaceholderById}
|
||||
onRemoveNode={removeNodeById}
|
||||
onExpandPhysicalLinks={() => setExpandPhysicalLinks(true)}
|
||||
onPushHistory={pushHistory}
|
||||
onPatchSelectedEdgeStyle={patchSelectedEdgeStyle}
|
||||
onOpenPortTraffic={openPortTrafficForEdge}
|
||||
onRemoveEdge={removeEdgeById}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,4 +1,11 @@
|
|||
import { createContext, memo, useContext, type Dispatch, type SetStateAction } from "react";
|
||||
import {
|
||||
createContext,
|
||||
memo,
|
||||
useContext,
|
||||
useRef,
|
||||
type Dispatch,
|
||||
type SetStateAction,
|
||||
} from "react";
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
|
|
@ -24,6 +31,7 @@ import {
|
|||
import "@xyflow/react/dist/style.css";
|
||||
import { WorldScatterLayer, type WorldScatterPoint } from "./WorldScatterLayer";
|
||||
import { ParallelEdge } from "./ParallelEdge";
|
||||
import { fitToNodes } from "../lib/fitNodes";
|
||||
import { isAggregateEdgeId } from "../lib/linkDisplay";
|
||||
import type { ToolMode, ToolModeBehavior } from "../lib/toolMode";
|
||||
import type { TopologyViewRole, TopologyWorldTransform } from "../../../types";
|
||||
|
|
@ -36,6 +44,13 @@ export { isPlaceholderSource };
|
|||
|
||||
export type NeNodeData = {
|
||||
label: string;
|
||||
/** Current label override to persist; "" clears override. */
|
||||
label_override?: string;
|
||||
/** Last server label_override; used to decide whether to send label on save. */
|
||||
label_override_saved?: string;
|
||||
/** Inventory / fabric display name (for restoring after clearing override). */
|
||||
name?: string;
|
||||
locked?: boolean;
|
||||
managed_ne_id: string;
|
||||
ume_ne_id: string;
|
||||
ne_ip: string;
|
||||
|
|
@ -215,6 +230,16 @@ const NeNode = memo(function NeNode({ id, data, selected }: NodeProps<Node<NeNod
|
|||
isConnectable={connectMode && !isRegion}
|
||||
/>
|
||||
{!isRegion ? <RouterIcon /> : <RegionCanvasIcon />}
|
||||
{data.locked ? (
|
||||
<span className="topo-node__lock" title="Locked" aria-label="Locked">
|
||||
<svg viewBox="0 0 24 24" width="10" height="10" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M12 17a2 2 0 1 0 0-4 2 2 0 0 0 0 4zm6-6h-1V9a5 5 0 0 0-10 0v2H6a2 2 0 0 0-2 2v7a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-7a2 2 0 0 0-2-2zm-3 0H9V9a3 3 0 1 1 6 0v2z"
|
||||
/>
|
||||
</svg>
|
||||
</span>
|
||||
) : null}
|
||||
{showConnectStatus && !isRegion ? (
|
||||
<span className={`topo-node__conn topo-node__conn--${connect}`} aria-hidden />
|
||||
) : null}
|
||||
|
|
@ -328,6 +353,8 @@ export type TopologyReactFlowViewProps = {
|
|||
pendingFitRef: { current: boolean };
|
||||
markDirty: () => void;
|
||||
pushHistory: () => void;
|
||||
/** Drop the latest undo frame when a drag was cancelled / no real move. */
|
||||
popHistory: () => void;
|
||||
onConnect: (connection: Connection) => void;
|
||||
isValidConnection: (connection: Connection | Edge) => boolean;
|
||||
onNodeClick: (e: React.MouseEvent, node: Node<NeNodeData>) => void;
|
||||
|
|
@ -376,6 +403,7 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
|||
pendingFitRef,
|
||||
markDirty,
|
||||
pushHistory,
|
||||
popHistory,
|
||||
onConnect,
|
||||
isValidConnection,
|
||||
onNodeClick,
|
||||
|
|
@ -392,6 +420,24 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
|||
toggleFullscreen,
|
||||
} = props;
|
||||
|
||||
/** Pre-drag positions of every node being dragged (single node or a box selection). */
|
||||
const dragStartRef = useRef<Map<string, { x: number; y: number }> | null>(null);
|
||||
const beginDrag = (dragged: Node<NeNodeData>[]) => {
|
||||
dragStartRef.current = new Map(dragged.map((n) => [n.id, { x: n.position.x, y: n.position.y }]));
|
||||
pushHistory();
|
||||
};
|
||||
const endDrag = (dragged: Node<NeNodeData>[]) => {
|
||||
const start = dragStartRef.current;
|
||||
dragStartRef.current = null;
|
||||
if (!start) return;
|
||||
const moved = dragged.some((n) => {
|
||||
const s0 = start.get(n.id);
|
||||
return Boolean(s0) && (Math.abs(n.position.x - s0!.x) > 1 || Math.abs(n.position.y - s0!.y) > 1);
|
||||
});
|
||||
if (moved) markDirty();
|
||||
else popHistory();
|
||||
};
|
||||
|
||||
return (
|
||||
<TopoDisplayContext.Provider value={displayOpts}>
|
||||
<TopoAlarmContext.Provider value={alarmByNodeId || emptyAlarmMap}>
|
||||
|
|
@ -439,13 +485,26 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
|||
multiSelectionKeyCode="Shift"
|
||||
snapToGrid={snapToGrid}
|
||||
snapGrid={SNAP_GRID}
|
||||
onNodeDragStart={() => {
|
||||
pushHistory();
|
||||
}}
|
||||
onNodeDragStart={(_e, _node, dragged) => beginDrag(dragged)}
|
||||
onNodeDragStop={(_e, _node, dragged) => endDrag(dragged)}
|
||||
// Dragging an existing box selection fires selection events, not node drag events.
|
||||
onSelectionDragStart={(_e, dragged) => beginDrag(dragged)}
|
||||
onSelectionDragStop={(_e, dragged) => endDrag(dragged)}
|
||||
onNodesChange={(changes: NodeChange<Node<NeNodeData>>[]) => {
|
||||
if (changes.some((c) => c.type === "position" || c.type === "remove" || c.type === "add")) {
|
||||
markDirty();
|
||||
// Position changes alone (click / select jitter) must not mark dirty; pointer
|
||||
// drags are settled in endDrag. Outside a drag, a real position change comes from
|
||||
// keyboard arrows (xyflow a11y move) and is an edit.
|
||||
let edited = changes.some((c) => c.type === "remove" || c.type === "add");
|
||||
if (!edited && !dragStartRef.current) {
|
||||
const before = new Map(nodes.map((n) => [n.id, n.position]));
|
||||
edited = changes.some((c) => {
|
||||
if (c.type !== "position" || c.dragging || !c.position) return false;
|
||||
const p0 = before.get(c.id);
|
||||
return Boolean(p0) && (p0!.x !== c.position.x || p0!.y !== c.position.y);
|
||||
});
|
||||
if (edited) pushHistory();
|
||||
}
|
||||
if (edited) markDirty();
|
||||
setNodes((nds) => applyNodeChanges(changes, nds));
|
||||
}}
|
||||
onEdgesChange={(changes: EdgeChange<Edge>[]) => {
|
||||
|
|
@ -522,8 +581,8 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
|||
const bounds = isWorldFlatCanvas ? worldDisplayBounds(worldTransform) : null;
|
||||
if (bounds) {
|
||||
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
||||
} else if (nodesRef.current.length > 0) {
|
||||
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
|
||||
} else {
|
||||
fitToNodes(inst, nodesRef.current, { padding: FIT_VIEW_OPTS.padding });
|
||||
}
|
||||
setCanvasZoom(inst.getZoom());
|
||||
});
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ import {
|
|||
worldVisualLodFromZoom,
|
||||
} from "../lib/treeUtils";
|
||||
import { mergeFlatWorldGraph } from "../lib/worldGraph";
|
||||
import { fitToNodes } from "../lib/fitNodes";
|
||||
import type { HistorySnap } from "../lib/pageTypes";
|
||||
|
||||
export type UseTopologyCanvasOptions = {
|
||||
|
|
@ -62,6 +63,10 @@ export type UseTopologyCanvasOptions = {
|
|||
redoRef: React.MutableRefObject<HistorySnap[]>;
|
||||
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
|
||||
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
|
||||
pendingEdgePortEditsRef?: React.MutableRefObject<Map<string, { a_port: string; b_port: string }>>;
|
||||
pendingEdgeStyleRef?: React.MutableRefObject<
|
||||
Map<string, { stroke_color: string; stroke_width: number; line_style: string }>
|
||||
>;
|
||||
clearDirty: () => void;
|
||||
bumpHistory: () => void;
|
||||
showError: (msg: string) => void;
|
||||
|
|
@ -102,6 +107,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
redoRef,
|
||||
pendingEdgeDeletesRef,
|
||||
pendingEdgeCreatesRef,
|
||||
pendingEdgePortEditsRef,
|
||||
pendingEdgeStyleRef,
|
||||
clearDirty,
|
||||
bumpHistory,
|
||||
showError,
|
||||
|
|
@ -288,7 +295,7 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
? worldDisplayBounds(worldTransformRef.current || graphQuery.data?.world_transform)
|
||||
: null;
|
||||
if (bounds) inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
||||
else inst.fitView({ ...FIT_VIEW_OPTS });
|
||||
else fitToNodes(inst, nodesRef.current, { padding: FIT_VIEW_OPTS.padding });
|
||||
setCanvasZoom(inst.getZoom());
|
||||
}, [isWorldFlatCanvas, graphQuery.data?.world_transform]);
|
||||
|
||||
|
|
@ -345,14 +352,17 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
setEdges(rfEdges);
|
||||
appliedGraphFpRef.current = fp;
|
||||
appliedOverlayFpRef.current = overlayFingerprint(graphQuery.data);
|
||||
// Full server overwrite (enter map or clean live sync): drop local edit queues/undo.
|
||||
historyRef.current = [];
|
||||
redoRef.current = [];
|
||||
pendingEdgeDeletesRef.current = new Set();
|
||||
pendingEdgeCreatesRef.current = new Set();
|
||||
pendingEdgePortEditsRef?.current.clear();
|
||||
pendingEdgeStyleRef?.current.clear();
|
||||
clearDirty();
|
||||
bumpHistory();
|
||||
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;
|
||||
|
|
@ -373,6 +383,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
redoRef,
|
||||
pendingEdgeDeletesRef,
|
||||
pendingEdgeCreatesRef,
|
||||
pendingEdgePortEditsRef,
|
||||
pendingEdgeStyleRef,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -394,7 +406,7 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
if (bounds) {
|
||||
inst.fitBounds(bounds, { padding: 0.12, duration: 0 });
|
||||
} else {
|
||||
inst.fitView({ ...FIT_VIEW_OPTS, duration: 0 });
|
||||
fitToNodes(inst, nodesRef.current, { padding: FIT_VIEW_OPTS.padding });
|
||||
}
|
||||
setCanvasZoom(inst.getZoom());
|
||||
};
|
||||
|
|
@ -509,12 +521,9 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
}
|
||||
|
||||
window.setTimeout(() => {
|
||||
rfRef.current?.fitView({
|
||||
nodes: [{ id: nodeId }],
|
||||
padding: 0.45,
|
||||
duration: 280,
|
||||
includeHiddenNodes: true,
|
||||
});
|
||||
const inst = rfRef.current;
|
||||
const target = nodesRef.current.filter((n) => n.id === nodeId);
|
||||
if (inst) fitToNodes(inst, target, { padding: 0.45, duration: 280, minWidth: 600, minHeight: 360 });
|
||||
}, 30);
|
||||
},
|
||||
[focusNode, focusWorldAround, showError, t, setSearchHitIds, searchHitTimerRef],
|
||||
|
|
|
|||
|
|
@ -190,7 +190,6 @@ export function useTopologyCreateNe({
|
|||
fabric_node_id: added.fabric_node_id,
|
||||
x: at.flowX,
|
||||
y: at.flowY,
|
||||
label: added.label || added.name || item.name || "",
|
||||
},
|
||||
]);
|
||||
graph = await fetchTopologyGraph(mapId);
|
||||
|
|
|
|||
|
|
@ -47,6 +47,8 @@ export type UseTopologyDiscoverOptions = {
|
|||
appliedMapIdRef: React.MutableRefObject<string>;
|
||||
markDirty: () => void;
|
||||
clearDirty: () => void;
|
||||
/** Prefer atomic /commit when provided (T6). */
|
||||
commitCanvas?: () => Promise<unknown>;
|
||||
showOk: (msg: string) => void;
|
||||
showError: (msg: string) => void;
|
||||
t: (key: string) => string;
|
||||
|
|
@ -72,6 +74,7 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
|||
appliedMapIdRef,
|
||||
markDirty,
|
||||
clearDirty,
|
||||
commitCanvas,
|
||||
showOk,
|
||||
showError,
|
||||
t,
|
||||
|
|
@ -95,6 +98,10 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
|||
|
||||
const flushDirtyCanvas = useCallback(async () => {
|
||||
if (!mapId || !dirtyRef.current) return;
|
||||
if (commitCanvas) {
|
||||
await commitCanvas();
|
||||
return;
|
||||
}
|
||||
const createIds = [...pendingEdgeCreatesRef.current];
|
||||
for (const id of createIds) {
|
||||
const e = edges.find((x) => x.id === id);
|
||||
|
|
@ -136,6 +143,7 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
|
|||
pendingEdgeCreatesRef,
|
||||
pendingEdgeDeletesRef,
|
||||
clearDirty,
|
||||
commitCanvas,
|
||||
]);
|
||||
|
||||
const runDiscover = useCallback(
|
||||
|
|
|
|||
362
web/src/features/topology/hooks/useTopologyEdgeEditing.ts
Normal file
362
web/src/features/topology/hooks/useTopologyEdgeEditing.ts
Normal file
|
|
@ -0,0 +1,362 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import type { Connection, Edge, Node } from "@xyflow/react";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
import {
|
||||
type EdgeDefaults,
|
||||
type EdgeStyleData,
|
||||
withEdgeVisual,
|
||||
} from "../lib/edgeStyle";
|
||||
import { isLocalPendingEdgeId, newLocalEdgeId } from "../lib/idUtils";
|
||||
import {
|
||||
formatPortPairLabel,
|
||||
isAggregateEdgeId,
|
||||
physicalIdsForDisplayEdge,
|
||||
} from "../lib/linkDisplay";
|
||||
|
||||
type PortEdit = { a_port: string; b_port: string };
|
||||
type StyleEdit = { stroke_color: string; stroke_width: number; line_style: string };
|
||||
|
||||
type Args = {
|
||||
canWriteTopology: boolean;
|
||||
mapId: string;
|
||||
nodes: Node<NeNodeData>[];
|
||||
edges: Edge[];
|
||||
displayEdges: Edge[];
|
||||
selectedEdgeId: string | null;
|
||||
selectedEdge: Edge | null;
|
||||
edgeDefaults: EdgeDefaults;
|
||||
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
|
||||
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
|
||||
setSelectedEdgeId: React.Dispatch<React.SetStateAction<string | null>>;
|
||||
connectClickRef: React.MutableRefObject<string | null>;
|
||||
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
|
||||
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
|
||||
pendingEdgePortEditsRef: React.MutableRefObject<Map<string, PortEdit>>;
|
||||
pendingEdgeStyleRef: React.MutableRefObject<Map<string, StyleEdit>>;
|
||||
pushHistory: () => void;
|
||||
markDirty: () => void;
|
||||
closeCtxMenu: () => void;
|
||||
showOk: (msg: string) => void;
|
||||
t: (key: string) => string;
|
||||
};
|
||||
|
||||
export function useTopologyEdgeEditing({
|
||||
canWriteTopology,
|
||||
mapId,
|
||||
nodes,
|
||||
edges,
|
||||
displayEdges,
|
||||
selectedEdgeId,
|
||||
selectedEdge,
|
||||
edgeDefaults,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setSelectedEdgeId,
|
||||
connectClickRef,
|
||||
pendingEdgeCreatesRef,
|
||||
pendingEdgeDeletesRef,
|
||||
pendingEdgePortEditsRef,
|
||||
pendingEdgeStyleRef,
|
||||
pushHistory,
|
||||
markDirty,
|
||||
closeCtxMenu,
|
||||
showOk,
|
||||
t,
|
||||
}: Args) {
|
||||
const isValidConnection = useCallback(
|
||||
(connection: Connection | Edge) => {
|
||||
const source = String(connection.source || "");
|
||||
const target = String(connection.target || "");
|
||||
if (!source || !target || source === target) return false;
|
||||
return !edges.some(
|
||||
(e) =>
|
||||
(e.source === source && e.target === target) ||
|
||||
(e.source === target && e.target === source),
|
||||
);
|
||||
},
|
||||
[edges],
|
||||
);
|
||||
|
||||
const onConnect = useCallback(
|
||||
(connection: Connection) => {
|
||||
if (!canWriteTopology || !mapId || !isValidConnection(connection)) return;
|
||||
const source = String(connection.source || "");
|
||||
const target = String(connection.target || "");
|
||||
if (!source || !target) return;
|
||||
pushHistory();
|
||||
connectClickRef.current = null;
|
||||
const id = newLocalEdgeId();
|
||||
const data: EdgeStyleData = {
|
||||
source: "manual",
|
||||
source_port: "",
|
||||
target_port: "",
|
||||
stroke_color: "",
|
||||
stroke_width: 0,
|
||||
line_style: "",
|
||||
discovered_at: null,
|
||||
};
|
||||
const edge = withEdgeVisual(
|
||||
{
|
||||
id,
|
||||
source,
|
||||
target,
|
||||
// Center anchors only — omit handle ids so attach matches discovered edges after Save.
|
||||
sourceHandle: null,
|
||||
targetHandle: null,
|
||||
type: "straight",
|
||||
animated: false,
|
||||
data,
|
||||
},
|
||||
edgeDefaults,
|
||||
);
|
||||
pendingEdgeCreatesRef.current.add(id);
|
||||
setEdges((es) => [...es, edge]);
|
||||
markDirty();
|
||||
},
|
||||
[
|
||||
mapId,
|
||||
isValidConnection,
|
||||
pushHistory,
|
||||
edgeDefaults,
|
||||
setEdges,
|
||||
markDirty,
|
||||
canWriteTopology,
|
||||
connectClickRef,
|
||||
pendingEdgeCreatesRef,
|
||||
],
|
||||
);
|
||||
|
||||
const patchSelectedEdgeStyle = useCallback(
|
||||
(
|
||||
patch: Partial<
|
||||
Pick<EdgeStyleData, "stroke_color" | "stroke_width" | "line_style" | "source_port" | "target_port">
|
||||
>,
|
||||
opts?: { skipHistory?: boolean },
|
||||
) => {
|
||||
if (!selectedEdgeId || !mapId) return;
|
||||
const display = displayEdges.find((e) => e.id === selectedEdgeId) || selectedEdge;
|
||||
const targetIds = physicalIdsForDisplayEdge(display, edges);
|
||||
if (!targetIds.length) return;
|
||||
// Port edits only make sense on a single physical manual link.
|
||||
if (
|
||||
(isAggregateEdgeId(selectedEdgeId) || targetIds.length > 1) &&
|
||||
(patch.source_port !== undefined || patch.target_port !== undefined)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (!opts?.skipHistory) pushHistory();
|
||||
setEdges((eds) =>
|
||||
eds.map((e) => {
|
||||
if (!targetIds.includes(e.id)) return e;
|
||||
const prev = (e.data || {}) as EdgeStyleData;
|
||||
const data: EdgeStyleData = {
|
||||
...prev,
|
||||
stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "",
|
||||
stroke_width:
|
||||
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
|
||||
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
|
||||
source_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "",
|
||||
target_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "",
|
||||
};
|
||||
const portLabel = formatPortPairLabel(
|
||||
data.source_port || "",
|
||||
data.target_port || "",
|
||||
data.display_label,
|
||||
);
|
||||
return withEdgeVisual({ ...e, data, label: portLabel || undefined }, edgeDefaults);
|
||||
}),
|
||||
);
|
||||
markDirty();
|
||||
for (const fabricEdgeId of targetIds) {
|
||||
const edge = edges.find((e) => e.id === fabricEdgeId);
|
||||
const prev = (edge?.data || {}) as EdgeStyleData;
|
||||
const nextStyle = {
|
||||
stroke_color: patch.stroke_color !== undefined ? patch.stroke_color : prev.stroke_color || "",
|
||||
stroke_width:
|
||||
patch.stroke_width !== undefined ? Number(patch.stroke_width || 0) : Number(prev.stroke_width || 0),
|
||||
line_style: patch.line_style !== undefined ? patch.line_style : prev.line_style || "",
|
||||
};
|
||||
if (
|
||||
patch.stroke_color !== undefined ||
|
||||
patch.stroke_width !== undefined ||
|
||||
patch.line_style !== undefined
|
||||
) {
|
||||
pendingEdgeStyleRef.current.set(fabricEdgeId, nextStyle);
|
||||
}
|
||||
if (patch.source_port !== undefined || patch.target_port !== undefined) {
|
||||
const src = String(prev.source || "").toLowerCase();
|
||||
// New local manual edges are saved via edge_creates; only queue port edits for saved edges.
|
||||
if (src === "manual" && !pendingEdgeCreatesRef.current.has(fabricEdgeId) && !isLocalPendingEdgeId(fabricEdgeId)) {
|
||||
pendingEdgePortEditsRef.current.set(fabricEdgeId, {
|
||||
a_port: patch.source_port !== undefined ? patch.source_port : prev.source_port || "",
|
||||
b_port: patch.target_port !== undefined ? patch.target_port : prev.target_port || "",
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
[
|
||||
selectedEdgeId,
|
||||
selectedEdge,
|
||||
mapId,
|
||||
edges,
|
||||
displayEdges,
|
||||
setEdges,
|
||||
pushHistory,
|
||||
edgeDefaults,
|
||||
markDirty,
|
||||
pendingEdgeStyleRef,
|
||||
pendingEdgePortEditsRef,
|
||||
pendingEdgeCreatesRef,
|
||||
],
|
||||
);
|
||||
|
||||
const queueDeleteEdges = useCallback(
|
||||
(
|
||||
edgeIds: string[],
|
||||
opts?: { confirmKey?: string; okKey?: string; skipConfirm?: boolean },
|
||||
) => {
|
||||
if (!edgeIds.length) return false;
|
||||
if (!opts?.skipConfirm) {
|
||||
const confirmMsg = t(opts?.confirmKey || "topology.deleteEdgeConfirm");
|
||||
if (!window.confirm(confirmMsg)) return false;
|
||||
}
|
||||
pushHistory();
|
||||
for (const id of edgeIds) {
|
||||
if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) {
|
||||
pendingEdgeCreatesRef.current.delete(id);
|
||||
} else {
|
||||
pendingEdgeDeletesRef.current.add(id);
|
||||
}
|
||||
}
|
||||
const idSet = new Set(edgeIds);
|
||||
setEdges((es) => es.filter((e) => !idSet.has(e.id)));
|
||||
setSelectedEdgeId((cur) => (cur && idSet.has(cur) ? null : cur));
|
||||
markDirty();
|
||||
showOk(
|
||||
t(opts?.okKey || "topology.edgeDeleted").replace("{{count}}", String(edgeIds.length)),
|
||||
);
|
||||
return true;
|
||||
},
|
||||
[pushHistory, setEdges, setSelectedEdgeId, markDirty, showOk, t, pendingEdgeCreatesRef, pendingEdgeDeletesRef],
|
||||
);
|
||||
|
||||
const removeSelected = useCallback(() => {
|
||||
if (!mapId) return;
|
||||
const nodeIds = nodes.filter((n) => n.selected).map((n) => n.id);
|
||||
const selectedDisplay = displayEdges.filter((e) => e.selected);
|
||||
const edgeIds = new Set<string>();
|
||||
for (const de of selectedDisplay) {
|
||||
for (const id of physicalIdsForDisplayEdge(de, edges)) edgeIds.add(id);
|
||||
}
|
||||
for (const e of edges) {
|
||||
if (e.selected) edgeIds.add(e.id);
|
||||
}
|
||||
if (!nodeIds.length && !edgeIds.size) return;
|
||||
|
||||
if (nodeIds.length && edgeIds.size) {
|
||||
const msg = t("topology.deleteSelectionConfirm")
|
||||
.replace("{{nodes}}", String(nodeIds.length))
|
||||
.replace("{{edges}}", String(edgeIds.size));
|
||||
if (!window.confirm(msg)) return;
|
||||
pushHistory();
|
||||
const nodeSet = new Set(nodeIds);
|
||||
for (const id of edgeIds) {
|
||||
if (pendingEdgeCreatesRef.current.has(id) || isLocalPendingEdgeId(id)) {
|
||||
pendingEdgeCreatesRef.current.delete(id);
|
||||
} else {
|
||||
pendingEdgeDeletesRef.current.add(id);
|
||||
}
|
||||
}
|
||||
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
|
||||
setEdges((es) =>
|
||||
es.filter(
|
||||
(e) =>
|
||||
!edgeIds.has(e.id) && !nodeSet.has(e.source) && !nodeSet.has(e.target),
|
||||
),
|
||||
);
|
||||
setSelectedEdgeId(null);
|
||||
markDirty();
|
||||
showOk(
|
||||
t("topology.selectionDeleted")
|
||||
.replace("{{nodes}}", String(nodeIds.length))
|
||||
.replace("{{edges}}", String(edgeIds.size)),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!nodeIds.length && edgeIds.size) {
|
||||
queueDeleteEdges([...edgeIds]);
|
||||
return;
|
||||
}
|
||||
|
||||
pushHistory();
|
||||
const nodeSet = new Set(nodeIds);
|
||||
setNodes((ns) => ns.filter((n) => !nodeSet.has(n.id)));
|
||||
// Drop incident edges from the canvas only — do not queue Fabric deletes.
|
||||
setEdges((es) => {
|
||||
const next = es.filter((e) => !nodeSet.has(e.source) && !nodeSet.has(e.target));
|
||||
for (const e of es) {
|
||||
if (next.some((x) => x.id === e.id)) continue;
|
||||
pendingEdgeCreatesRef.current.delete(e.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
setSelectedEdgeId(null);
|
||||
markDirty();
|
||||
}, [
|
||||
mapId,
|
||||
nodes,
|
||||
edges,
|
||||
displayEdges,
|
||||
setNodes,
|
||||
setEdges,
|
||||
setSelectedEdgeId,
|
||||
pushHistory,
|
||||
markDirty,
|
||||
showOk,
|
||||
queueDeleteEdges,
|
||||
t,
|
||||
pendingEdgeCreatesRef,
|
||||
pendingEdgeDeletesRef,
|
||||
]);
|
||||
|
||||
const removeEdgeById = useCallback(
|
||||
(edgeId: string) => {
|
||||
const display = displayEdges.find((e) => e.id === edgeId);
|
||||
const ids = physicalIdsForDisplayEdge(display, edges);
|
||||
const list = ids.length ? ids : [edgeId];
|
||||
queueDeleteEdges(list);
|
||||
closeCtxMenu();
|
||||
},
|
||||
[displayEdges, edges, queueDeleteEdges, closeCtxMenu],
|
||||
);
|
||||
|
||||
const staleEdgeIds = useMemo(() => {
|
||||
return edges
|
||||
.filter((e) => {
|
||||
const src = String((e.data as EdgeStyleData | undefined)?.source || "");
|
||||
return src === "stale";
|
||||
})
|
||||
.map((e) => e.id);
|
||||
}, [edges]);
|
||||
|
||||
const removeStaleEdges = useCallback(() => {
|
||||
if (!staleEdgeIds.length) return;
|
||||
queueDeleteEdges(staleEdgeIds, {
|
||||
confirmKey: "topology.removeStaleHint",
|
||||
okKey: "topology.staleRemoved",
|
||||
});
|
||||
}, [staleEdgeIds, queueDeleteEdges]);
|
||||
|
||||
return {
|
||||
isValidConnection,
|
||||
onConnect,
|
||||
patchSelectedEdgeStyle,
|
||||
queueDeleteEdges,
|
||||
removeSelected,
|
||||
removeEdgeById,
|
||||
staleEdgeIds,
|
||||
removeStaleEdges,
|
||||
};
|
||||
}
|
||||
124
web/src/features/topology/hooks/useTopologyExport.ts
Normal file
124
web/src/features/topology/hooks/useTopologyExport.ts
Normal file
|
|
@ -0,0 +1,124 @@
|
|||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { TopologyTreeFolderItem, TopologyTreeViewItem, TopologyViewGraph, TopologyViewItem } from "../../../types";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
import type { LabelColors, VendorColors } from "../lib/displayPrefs";
|
||||
import { buildExportGraph, exportTopologySvg, exportTopologyXml } from "../lib/exportTopology";
|
||||
import { regionDisplayName } from "../lib/treeUtils";
|
||||
|
||||
type Args = {
|
||||
canvasMode: boolean;
|
||||
isWorldFlatCanvas: boolean;
|
||||
activeLeafName: string;
|
||||
activeRegion: TopologyTreeFolderItem | null;
|
||||
activeView: TopologyTreeViewItem | null;
|
||||
graphQueryData?: TopologyViewGraph;
|
||||
nodes: Node<NeNodeData>[];
|
||||
edges: Edge[];
|
||||
displayEdges: Edge[];
|
||||
labelColors: LabelColors;
|
||||
vendorColors: VendorColors;
|
||||
hideIp: boolean;
|
||||
hideVendor: boolean;
|
||||
showOk: (msg: string) => void;
|
||||
showError: (msg: string) => void;
|
||||
t: (key: string) => string;
|
||||
};
|
||||
|
||||
export function useTopologyExport({
|
||||
canvasMode,
|
||||
isWorldFlatCanvas,
|
||||
activeLeafName,
|
||||
activeRegion,
|
||||
activeView,
|
||||
graphQueryData,
|
||||
nodes,
|
||||
edges,
|
||||
displayEdges,
|
||||
labelColors,
|
||||
vendorColors,
|
||||
hideIp,
|
||||
hideVendor,
|
||||
showOk,
|
||||
showError,
|
||||
t,
|
||||
}: Args) {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const runTopologyExport = useCallback(
|
||||
async (format: "svg" | "xml") => {
|
||||
const view: TopologyViewItem | undefined =
|
||||
graphQueryData?.view ??
|
||||
(activeView
|
||||
? {
|
||||
id: activeView.id,
|
||||
name: activeView.name,
|
||||
remark: "",
|
||||
node_count: activeView.node_count,
|
||||
kind: activeView.kind,
|
||||
role: activeView.role,
|
||||
sort_order: activeView.sort_order,
|
||||
}
|
||||
: undefined);
|
||||
if (!view) {
|
||||
showError(t("topology.selectMap"));
|
||||
return;
|
||||
}
|
||||
if (format !== "xml" && nodes.length === 0 && !isWorldFlatCanvas) {
|
||||
showError(t("topology.exportEmpty"));
|
||||
return;
|
||||
}
|
||||
setExporting(true);
|
||||
const filenameBase = activeLeafName || view.name || "topology";
|
||||
try {
|
||||
if (format === "svg") {
|
||||
await exportTopologySvg(
|
||||
nodes,
|
||||
displayEdges,
|
||||
filenameBase,
|
||||
{
|
||||
labelColor: labelColors.name,
|
||||
edgeLabelColor: labelColors.edgeLabel,
|
||||
vendorColors,
|
||||
hideIp,
|
||||
hideVendor,
|
||||
},
|
||||
filenameBase,
|
||||
);
|
||||
showOk(t("topology.exportedSvg"));
|
||||
} else {
|
||||
exportTopologyXml(buildExportGraph(view, nodes, edges, graphQueryData), filenameBase);
|
||||
showOk(t("topology.exportedXml"));
|
||||
}
|
||||
} catch (err) {
|
||||
showError(t("topology.exportFailed").replace("{{detail}}", String(err)));
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
activeView,
|
||||
graphQueryData,
|
||||
nodes,
|
||||
edges,
|
||||
displayEdges,
|
||||
isWorldFlatCanvas,
|
||||
activeLeafName,
|
||||
labelColors,
|
||||
vendorColors,
|
||||
hideIp,
|
||||
hideVendor,
|
||||
showError,
|
||||
showOk,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const titleText = useMemo(() => {
|
||||
if (canvasMode) return activeLeafName || t("topology.selectMap");
|
||||
if (activeRegion) return regionDisplayName(activeRegion, t);
|
||||
return t("topology.rootName");
|
||||
}, [canvasMode, activeLeafName, activeRegion, t]);
|
||||
|
||||
return { exporting, runTopologyExport, titleText };
|
||||
}
|
||||
269
web/src/features/topology/hooks/useTopologyFind.ts
Normal file
269
web/src/features/topology/hooks/useTopologyFind.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { useCallback, useEffect, useMemo, useRef } from "react";
|
||||
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
|
||||
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../../types";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
import { WORLD_MAP_ENABLED } from "../lib/constants";
|
||||
import { nodeMatchesQuery } from "../lib/searchUtils";
|
||||
import { findViewInRegion, isWorldFlatViewName } from "../lib/treeUtils";
|
||||
|
||||
type Args = {
|
||||
mapId: string;
|
||||
canvasMode: boolean;
|
||||
nodes: Node<NeNodeData>[];
|
||||
regions: TopologyTreeFolderItem[];
|
||||
pendingHighlightNe: string;
|
||||
graphQueryData: unknown;
|
||||
canvasQuery: string;
|
||||
setCanvasQuery: React.Dispatch<React.SetStateAction<string>>;
|
||||
setSearchHitIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
findOpen: boolean;
|
||||
setFindOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
findActiveIdx: number;
|
||||
setFindActiveIdx: React.Dispatch<React.SetStateAction<number>>;
|
||||
findBoxRef: React.MutableRefObject<HTMLDivElement | null>;
|
||||
displayMenuRef: React.MutableRefObject<HTMLDetailsElement | null>;
|
||||
isWorldFlatCanvasRef: React.MutableRefObject<boolean>;
|
||||
rfRef: React.MutableRefObject<ReactFlowInstance<Node<NeNodeData>, Edge> | null>;
|
||||
locateNode: (id: string, world?: { worldX?: number; worldY?: number }) => void;
|
||||
confirmDiscardIfDirty: () => boolean;
|
||||
setMapId: (id: string) => void;
|
||||
setSelectedFolderId: (id: string) => void;
|
||||
setExpandedIds: React.Dispatch<React.SetStateAction<Record<string, boolean>>>;
|
||||
setPendingHighlightNe: (id: string) => void;
|
||||
setTreeNeQuery: (q: string) => void;
|
||||
setDebouncedTreeNeQuery: (q: string) => void;
|
||||
setTreeSearchOpen: (open: boolean) => void;
|
||||
showError: (msg: string) => void;
|
||||
t: (key: string) => string;
|
||||
};
|
||||
|
||||
export function useTopologyFind({
|
||||
mapId,
|
||||
canvasMode,
|
||||
nodes,
|
||||
regions,
|
||||
pendingHighlightNe,
|
||||
graphQueryData,
|
||||
canvasQuery,
|
||||
setCanvasQuery,
|
||||
setSearchHitIds,
|
||||
setFindOpen,
|
||||
findActiveIdx,
|
||||
setFindActiveIdx,
|
||||
findBoxRef,
|
||||
displayMenuRef,
|
||||
isWorldFlatCanvasRef,
|
||||
rfRef,
|
||||
locateNode,
|
||||
confirmDiscardIfDirty,
|
||||
setMapId,
|
||||
setSelectedFolderId,
|
||||
setExpandedIds,
|
||||
setPendingHighlightNe,
|
||||
setTreeNeQuery,
|
||||
setDebouncedTreeNeQuery,
|
||||
setTreeSearchOpen,
|
||||
showError,
|
||||
t,
|
||||
}: Args) {
|
||||
const findJustLocatedRef = useRef(false);
|
||||
const pendingLocateWorldRef = useRef<{
|
||||
id: string;
|
||||
worldX?: number;
|
||||
worldY?: number;
|
||||
} | null>(null);
|
||||
|
||||
const jumpToTreeSearchHit = useCallback(
|
||||
(
|
||||
hit: FabricNodeSearchHit,
|
||||
view?: NonNullable<FabricNodeSearchHit["views"]>[number],
|
||||
) => {
|
||||
const views = hit.views || [];
|
||||
const worldCoords =
|
||||
hit.world_x != null && hit.world_y != null
|
||||
? { worldX: Number(hit.world_x), worldY: Number(hit.world_y) }
|
||||
: undefined;
|
||||
const onCurrent =
|
||||
Boolean(mapId) &&
|
||||
(views.some((v) => v.view_id === mapId) ||
|
||||
nodes.some((n) => n.id === hit.id) ||
|
||||
(isWorldFlatCanvasRef.current && worldCoords != null));
|
||||
if (onCurrent && mapId && (!view || view.view_id === mapId)) {
|
||||
if (isWorldFlatCanvasRef.current) {
|
||||
locateNode(hit.id, worldCoords);
|
||||
} else if (nodes.some((n) => n.id === hit.id)) {
|
||||
locateNode(hit.id);
|
||||
} else {
|
||||
setPendingHighlightNe(hit.id);
|
||||
}
|
||||
setTreeNeQuery("");
|
||||
setDebouncedTreeNeQuery("");
|
||||
setTreeSearchOpen(false);
|
||||
return;
|
||||
}
|
||||
const target =
|
||||
view ||
|
||||
(WORLD_MAP_ENABLED
|
||||
? views.find((v) => isWorldFlatViewName(v.view_name))
|
||||
: undefined) ||
|
||||
views.find((v) => !isWorldFlatViewName(v.view_name) && String(v.kind) === "physical") ||
|
||||
views.find((v) => !isWorldFlatViewName(v.view_name)) ||
|
||||
views[0] ||
|
||||
null;
|
||||
if (!target) {
|
||||
showError(t("topology.treeSearchNotOnMap"));
|
||||
return;
|
||||
}
|
||||
if (!WORLD_MAP_ENABLED && isWorldFlatViewName(target.view_name)) {
|
||||
showError(t("topology.worldMapOffline"));
|
||||
return;
|
||||
}
|
||||
if (!confirmDiscardIfDirty()) return;
|
||||
const regionId = target.folder_id || findViewInRegion(regions, target.view_id)?.region.id;
|
||||
if (regionId) {
|
||||
setSelectedFolderId(regionId);
|
||||
setExpandedIds((p) => ({ ...p, [regionId]: true }));
|
||||
}
|
||||
if (WORLD_MAP_ENABLED && isWorldFlatViewName(target.view_name) && worldCoords) {
|
||||
pendingLocateWorldRef.current = { id: hit.id, ...worldCoords };
|
||||
} else {
|
||||
pendingLocateWorldRef.current = null;
|
||||
}
|
||||
setMapId(target.view_id);
|
||||
setPendingHighlightNe(hit.id);
|
||||
setTreeNeQuery("");
|
||||
setDebouncedTreeNeQuery("");
|
||||
setTreeSearchOpen(false);
|
||||
},
|
||||
[
|
||||
mapId,
|
||||
nodes,
|
||||
locateNode,
|
||||
showError,
|
||||
t,
|
||||
confirmDiscardIfDirty,
|
||||
regions,
|
||||
setSelectedFolderId,
|
||||
setMapId,
|
||||
setPendingHighlightNe,
|
||||
setTreeNeQuery,
|
||||
setDebouncedTreeNeQuery,
|
||||
setTreeSearchOpen,
|
||||
setExpandedIds,
|
||||
isWorldFlatCanvasRef,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingHighlightNe || !canvasMode) return;
|
||||
const nodeId = pendingHighlightNe;
|
||||
const pendingWorld = pendingLocateWorldRef.current;
|
||||
if (
|
||||
pendingWorld &&
|
||||
pendingWorld.id === nodeId &&
|
||||
isWorldFlatCanvasRef.current &&
|
||||
rfRef.current
|
||||
) {
|
||||
pendingLocateWorldRef.current = null;
|
||||
setPendingHighlightNe("");
|
||||
window.setTimeout(
|
||||
() =>
|
||||
locateNode(nodeId, {
|
||||
worldX: pendingWorld.worldX,
|
||||
worldY: pendingWorld.worldY,
|
||||
}),
|
||||
80,
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (!nodes.length || !nodes.some((n) => n.id === nodeId)) return;
|
||||
pendingLocateWorldRef.current = null;
|
||||
setPendingHighlightNe("");
|
||||
window.setTimeout(() => locateNode(nodeId), 80);
|
||||
}, [
|
||||
pendingHighlightNe,
|
||||
canvasMode,
|
||||
nodes,
|
||||
locateNode,
|
||||
mapId,
|
||||
graphQueryData,
|
||||
isWorldFlatCanvasRef,
|
||||
rfRef,
|
||||
setPendingHighlightNe,
|
||||
]);
|
||||
|
||||
const canvasHits = useMemo(() => {
|
||||
const q = canvasQuery.trim();
|
||||
if (!q) return [];
|
||||
return nodes.filter((n) => nodeMatchesQuery(n, q));
|
||||
}, [canvasQuery, nodes]);
|
||||
|
||||
useEffect(() => {
|
||||
const q = canvasQuery.trim();
|
||||
if (!q) {
|
||||
setFindActiveIdx(0);
|
||||
setFindOpen(false);
|
||||
if (findJustLocatedRef.current) {
|
||||
findJustLocatedRef.current = false;
|
||||
return;
|
||||
}
|
||||
setSearchHitIds([]);
|
||||
return;
|
||||
}
|
||||
setFindOpen(true);
|
||||
setSearchHitIds(canvasHits.map((n) => n.id));
|
||||
setFindActiveIdx((idx) => (canvasHits.length ? Math.min(idx, canvasHits.length - 1) : 0));
|
||||
}, [canvasQuery, canvasHits, setFindActiveIdx, setFindOpen, setSearchHitIds]);
|
||||
|
||||
const findOnCanvas = useCallback(
|
||||
(nodeId?: string) => {
|
||||
const q = canvasQuery.trim();
|
||||
if (!q) return;
|
||||
if (!canvasHits.length) {
|
||||
showError(t("topology.findNoMatch"));
|
||||
return;
|
||||
}
|
||||
const id =
|
||||
nodeId ||
|
||||
canvasHits[Math.max(0, Math.min(findActiveIdx, canvasHits.length - 1))]?.id ||
|
||||
canvasHits[0].id;
|
||||
findJustLocatedRef.current = true;
|
||||
setFindOpen(false);
|
||||
setCanvasQuery("");
|
||||
setFindActiveIdx(0);
|
||||
locateNode(id);
|
||||
},
|
||||
[
|
||||
canvasQuery,
|
||||
canvasHits,
|
||||
findActiveIdx,
|
||||
locateNode,
|
||||
showError,
|
||||
t,
|
||||
setFindOpen,
|
||||
setCanvasQuery,
|
||||
setFindActiveIdx,
|
||||
],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const onDoc = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (findBoxRef.current && target && !findBoxRef.current.contains(target)) {
|
||||
setFindOpen(false);
|
||||
}
|
||||
const details = displayMenuRef.current;
|
||||
if (details?.open && target && !details.contains(target)) {
|
||||
details.open = false;
|
||||
}
|
||||
};
|
||||
document.addEventListener("mousedown", onDoc);
|
||||
return () => document.removeEventListener("mousedown", onDoc);
|
||||
}, [displayMenuRef, findBoxRef, setFindOpen]);
|
||||
|
||||
return {
|
||||
canvasHits,
|
||||
jumpToTreeSearchHit,
|
||||
findOnCanvas,
|
||||
};
|
||||
}
|
||||
|
|
@ -19,6 +19,12 @@ export function useTopologyHistory(
|
|||
const historyLockRef = useRef(false);
|
||||
const pendingEdgeDeletesRef = useRef<Set<string>>(new Set());
|
||||
const pendingEdgeCreatesRef = useRef<Set<string>>(new Set());
|
||||
const pendingEdgePortEditsRef = useRef<Map<string, { a_port: string; b_port: string }>>(
|
||||
new Map(),
|
||||
);
|
||||
const pendingEdgeStyleRef = useRef<
|
||||
Map<string, { stroke_color: string; stroke_width: number; line_style: string }>
|
||||
>(new Map());
|
||||
|
||||
const canUndo = historyTick >= 0 && historyRef.current.length > 0;
|
||||
const canRedo = historyTick >= 0 && redoRef.current.length > 0;
|
||||
|
|
@ -48,6 +54,8 @@ export function useTopologyHistory(
|
|||
edges: edges.map((e) => ({ ...e })),
|
||||
pendingEdgeDeletes: [...pendingEdgeDeletesRef.current],
|
||||
pendingEdgeCreates: [...pendingEdgeCreatesRef.current],
|
||||
pendingEdgePortEdits: [...pendingEdgePortEditsRef.current.entries()],
|
||||
pendingEdgeStyles: [...pendingEdgeStyleRef.current.entries()],
|
||||
}),
|
||||
[nodes, edges],
|
||||
);
|
||||
|
|
@ -59,6 +67,14 @@ export function useTopologyHistory(
|
|||
bumpHistory();
|
||||
}, [snapshot, bumpHistory]);
|
||||
|
||||
/** Drop the latest undo frame without applying it (e.g. click that never moved). */
|
||||
const popHistory = useCallback(() => {
|
||||
if (historyLockRef.current) return;
|
||||
if (!historyRef.current.length) return;
|
||||
historyRef.current.pop();
|
||||
bumpHistory();
|
||||
}, [bumpHistory]);
|
||||
|
||||
const undo = useCallback(() => {
|
||||
const prev = historyRef.current.pop();
|
||||
if (!prev) return;
|
||||
|
|
@ -68,6 +84,8 @@ export function useTopologyHistory(
|
|||
setEdges(prev.edges);
|
||||
pendingEdgeDeletesRef.current = new Set(prev.pendingEdgeDeletes);
|
||||
pendingEdgeCreatesRef.current = new Set(prev.pendingEdgeCreates || []);
|
||||
pendingEdgePortEditsRef.current = new Map(prev.pendingEdgePortEdits || []);
|
||||
pendingEdgeStyleRef.current = new Map(prev.pendingEdgeStyles || []);
|
||||
markDirty();
|
||||
bumpHistory();
|
||||
historyLockRef.current = false;
|
||||
|
|
@ -82,6 +100,8 @@ export function useTopologyHistory(
|
|||
setEdges(next.edges);
|
||||
pendingEdgeDeletesRef.current = new Set(next.pendingEdgeDeletes);
|
||||
pendingEdgeCreatesRef.current = new Set(next.pendingEdgeCreates || []);
|
||||
pendingEdgePortEditsRef.current = new Map(next.pendingEdgePortEdits || []);
|
||||
pendingEdgeStyleRef.current = new Map(next.pendingEdgeStyles || []);
|
||||
markDirty();
|
||||
bumpHistory();
|
||||
historyLockRef.current = false;
|
||||
|
|
@ -97,11 +117,14 @@ export function useTopologyHistory(
|
|||
historyLockRef,
|
||||
pendingEdgeDeletesRef,
|
||||
pendingEdgeCreatesRef,
|
||||
pendingEdgePortEditsRef,
|
||||
pendingEdgeStyleRef,
|
||||
markDirty,
|
||||
clearDirty,
|
||||
bumpHistory,
|
||||
confirmDiscardIfDirty,
|
||||
pushHistory,
|
||||
popHistory,
|
||||
undo,
|
||||
redo,
|
||||
};
|
||||
|
|
|
|||
133
web/src/features/topology/hooks/useTopologyKeyboard.ts
Normal file
133
web/src/features/topology/hooks/useTopologyKeyboard.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { useEffect, useRef } from "react";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import type { UseMutationResult } from "@tanstack/react-query";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
import { toolModeFromKey, type ToolMode } from "../lib/toolMode";
|
||||
|
||||
type Args = {
|
||||
canWriteTopology: boolean;
|
||||
mapId: string;
|
||||
nodes: Node<NeNodeData>[];
|
||||
edges: Edge[];
|
||||
dirtyRef: React.MutableRefObject<boolean>;
|
||||
connectClickRef: React.MutableRefObject<string | null>;
|
||||
displayMenuRef: React.MutableRefObject<HTMLDetailsElement | null>;
|
||||
saveMut: Pick<UseMutationResult<unknown, unknown, void, unknown>, "isPending" | "mutate">;
|
||||
closeCtxMenu: () => void;
|
||||
clearSelection: () => void;
|
||||
setToolMode: React.Dispatch<React.SetStateAction<ToolMode>>;
|
||||
setFindOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
undo: () => void;
|
||||
redo: () => void;
|
||||
selectAllNodes: () => void;
|
||||
removeSelected: () => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* Global topology shortcuts. Handlers are read via refs so the window
|
||||
* listener is not rebound on every nodes/edges change.
|
||||
*/
|
||||
export function useTopologyKeyboard({
|
||||
canWriteTopology,
|
||||
mapId,
|
||||
nodes,
|
||||
edges,
|
||||
dirtyRef,
|
||||
connectClickRef,
|
||||
displayMenuRef,
|
||||
saveMut,
|
||||
closeCtxMenu,
|
||||
clearSelection,
|
||||
setToolMode,
|
||||
setFindOpen,
|
||||
undo,
|
||||
redo,
|
||||
selectAllNodes,
|
||||
removeSelected,
|
||||
}: Args) {
|
||||
const latestRef = useRef({
|
||||
canWriteTopology,
|
||||
mapId,
|
||||
nodes,
|
||||
edges,
|
||||
saveMut,
|
||||
closeCtxMenu,
|
||||
clearSelection,
|
||||
setToolMode,
|
||||
setFindOpen,
|
||||
undo,
|
||||
redo,
|
||||
selectAllNodes,
|
||||
removeSelected,
|
||||
});
|
||||
latestRef.current = {
|
||||
canWriteTopology,
|
||||
mapId,
|
||||
nodes,
|
||||
edges,
|
||||
saveMut,
|
||||
closeCtxMenu,
|
||||
clearSelection,
|
||||
setToolMode,
|
||||
setFindOpen,
|
||||
undo,
|
||||
redo,
|
||||
selectAllNodes,
|
||||
removeSelected,
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement | null)?.tagName?.toLowerCase();
|
||||
if (tag === "input" || tag === "textarea" || tag === "select" || (e.target as HTMLElement)?.isContentEditable) {
|
||||
return;
|
||||
}
|
||||
const s = latestRef.current;
|
||||
if (e.key === "Escape") {
|
||||
s.closeCtxMenu();
|
||||
s.clearSelection();
|
||||
connectClickRef.current = null;
|
||||
s.setToolMode((m) => (m === "connect" ? "select" : m));
|
||||
if (displayMenuRef.current?.open) displayMenuRef.current.open = false;
|
||||
s.setFindOpen(false);
|
||||
return;
|
||||
}
|
||||
const mode = toolModeFromKey(e.key);
|
||||
if (mode && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
s.setToolMode(mode);
|
||||
connectClickRef.current = null;
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
|
||||
e.preventDefault();
|
||||
if (s.canWriteTopology && s.mapId && dirtyRef.current && !s.saveMut.isPending) s.saveMut.mutate();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
s.undo();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && (e.key.toLowerCase() === "y" || (e.key.toLowerCase() === "z" && e.shiftKey))) {
|
||||
e.preventDefault();
|
||||
s.redo();
|
||||
return;
|
||||
}
|
||||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "a") {
|
||||
e.preventDefault();
|
||||
s.selectAllNodes();
|
||||
return;
|
||||
}
|
||||
if (e.key === "Delete" || e.key === "Backspace") {
|
||||
if (!s.canWriteTopology) return;
|
||||
if (s.nodes.some((n) => n.selected) || s.edges.some((ed) => ed.selected)) {
|
||||
e.preventDefault();
|
||||
void s.removeSelected();
|
||||
}
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [connectClickRef, displayMenuRef, dirtyRef]);
|
||||
}
|
||||
149
web/src/features/topology/hooks/useTopologyNodeOps.ts
Normal file
149
web/src/features/topology/hooks/useTopologyNodeOps.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { useCallback } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { queryKeys } from "../../../constants/queryKeys";
|
||||
import { fetchTopologyGraph, purgePlaceholderFabricNodes } from "../../../services/api";
|
||||
import type { TopologyViewGraph } from "../../../types";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
import { isPlaceholderSource } from "../lib/searchUtils";
|
||||
|
||||
type Args = {
|
||||
mapId: string;
|
||||
nodes: Node<NeNodeData>[];
|
||||
selectedNode: Node<NeNodeData> | null;
|
||||
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
|
||||
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
|
||||
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
|
||||
dirtyRef: React.MutableRefObject<boolean>;
|
||||
appliedMapIdRef: React.MutableRefObject<string>;
|
||||
pushHistory: () => void;
|
||||
markDirty: () => void;
|
||||
bumpHistory: () => void;
|
||||
closeCtxMenu: () => void;
|
||||
commitCanvas: () => Promise<unknown>;
|
||||
resetLocalFromServer: (graph: TopologyViewGraph) => void;
|
||||
showOk: (msg: string) => void;
|
||||
showError: (msg: string) => void;
|
||||
t: (key: string) => string;
|
||||
};
|
||||
|
||||
export function useTopologyNodeOps({
|
||||
mapId,
|
||||
nodes,
|
||||
selectedNode,
|
||||
setNodes,
|
||||
setEdges,
|
||||
pendingEdgeCreatesRef,
|
||||
dirtyRef,
|
||||
appliedMapIdRef,
|
||||
pushHistory,
|
||||
markDirty,
|
||||
bumpHistory,
|
||||
closeCtxMenu,
|
||||
commitCanvas,
|
||||
resetLocalFromServer,
|
||||
showOk,
|
||||
showError,
|
||||
t,
|
||||
}: Args) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const renameSelectedNode = useCallback(() => {
|
||||
if (!selectedNode) return;
|
||||
const currentOverride = String(selectedNode.data.label_override ?? "");
|
||||
const next = window.prompt(
|
||||
`${t("topology.renameNodePrompt")}\n${t("topology.renameNodeClearHint")}`,
|
||||
currentOverride || selectedNode.data.label || "",
|
||||
);
|
||||
if (next == null) return;
|
||||
const override = next.trim();
|
||||
const prevOverride = currentOverride;
|
||||
const display =
|
||||
override ||
|
||||
selectedNode.data.name ||
|
||||
selectedNode.data.ne_ip ||
|
||||
selectedNode.id;
|
||||
if (override === prevOverride && display === (selectedNode.data.label || "")) {
|
||||
closeCtxMenu();
|
||||
return;
|
||||
}
|
||||
pushHistory();
|
||||
markDirty();
|
||||
setNodes((ns) =>
|
||||
ns.map((n) =>
|
||||
n.id === selectedNode.id
|
||||
? {
|
||||
...n,
|
||||
data: {
|
||||
...n.data,
|
||||
label: display,
|
||||
label_override: override,
|
||||
},
|
||||
}
|
||||
: n,
|
||||
),
|
||||
);
|
||||
closeCtxMenu();
|
||||
}, [selectedNode, t, pushHistory, markDirty, setNodes, closeCtxMenu]);
|
||||
|
||||
const removeNodeById = useCallback(
|
||||
(nodeId: string) => {
|
||||
if (!mapId) return;
|
||||
pushHistory();
|
||||
closeCtxMenu();
|
||||
setNodes((ns) => ns.filter((n) => n.id !== nodeId));
|
||||
setEdges((es) => {
|
||||
const next = es.filter((e) => e.source !== nodeId && e.target !== nodeId);
|
||||
for (const e of es) {
|
||||
if (next.some((x) => x.id === e.id)) continue;
|
||||
pendingEdgeCreatesRef.current.delete(e.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
markDirty();
|
||||
},
|
||||
[mapId, pushHistory, closeCtxMenu, setNodes, setEdges, pendingEdgeCreatesRef, markDirty],
|
||||
);
|
||||
|
||||
const purgePlaceholderById = useCallback(
|
||||
async (nodeId: string) => {
|
||||
if (!mapId) return;
|
||||
const node = nodes.find((n) => n.id === nodeId);
|
||||
if (!node || !isPlaceholderSource(node.data.managed_source, node.data.ne_ip)) {
|
||||
return;
|
||||
}
|
||||
if (!window.confirm(t("topology.deletePlaceholderConfirm"))) return;
|
||||
closeCtxMenu();
|
||||
try {
|
||||
if (dirtyRef.current) {
|
||||
await commitCanvas();
|
||||
}
|
||||
await purgePlaceholderFabricNodes([nodeId]);
|
||||
const graph = await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), graph);
|
||||
appliedMapIdRef.current = mapId;
|
||||
resetLocalFromServer(graph);
|
||||
bumpHistory();
|
||||
showOk(t("topology.deletePlaceholderDone"));
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
},
|
||||
[
|
||||
mapId,
|
||||
nodes,
|
||||
closeCtxMenu,
|
||||
commitCanvas,
|
||||
resetLocalFromServer,
|
||||
queryClient,
|
||||
bumpHistory,
|
||||
showOk,
|
||||
showError,
|
||||
t,
|
||||
dirtyRef,
|
||||
appliedMapIdRef,
|
||||
],
|
||||
);
|
||||
|
||||
return { renameSelectedNode, removeNodeById, purgePlaceholderById };
|
||||
}
|
||||
409
web/src/features/topology/hooks/useTopologyPalette.ts
Normal file
409
web/src/features/topology/hooks/useTopologyPalette.ts
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
|
||||
import { queryKeys } from "../../../constants/queryKeys";
|
||||
import {
|
||||
addTopologyViewNodes,
|
||||
fetchManagedNe,
|
||||
fetchTopologyGraph,
|
||||
fetchUmeNe,
|
||||
patchTopologyPositions,
|
||||
} from "../../../services/api";
|
||||
import type { TopologyOutsidePeer } from "../../../types";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
import { PALETTE_DND, SEP } from "../lib/constants";
|
||||
import type { PaletteItem, PaletteSource } from "../lib/pageTypes";
|
||||
import { fuzzyIncludes } from "../lib/searchUtils";
|
||||
import { isWorldFlatViewName } from "../lib/treeUtils";
|
||||
|
||||
type Args = {
|
||||
canWriteTopology: boolean;
|
||||
mapId: string;
|
||||
activeViewName?: string | null;
|
||||
nodes: Node<NeNodeData>[];
|
||||
keyword: string;
|
||||
debouncedKeyword: string;
|
||||
addNeOpen: boolean;
|
||||
paletteSource: PaletteSource;
|
||||
outsidePeers: TopologyOutsidePeer[];
|
||||
outsidePeerQuery: string;
|
||||
dirtyRef: React.MutableRefObject<boolean>;
|
||||
rfRef: React.MutableRefObject<ReactFlowInstance<Node<NeNodeData>, Edge> | null>;
|
||||
commitCanvas: () => Promise<unknown>;
|
||||
resetLocalFromServer: (graph: Awaited<ReturnType<typeof fetchTopologyGraph>>) => void;
|
||||
showOk: (msg: string) => void;
|
||||
showError: (msg: string) => void;
|
||||
t: (key: string) => string;
|
||||
setOutsidePeersOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
setOutsidePeerSelectedIds: React.Dispatch<React.SetStateAction<string[]>>;
|
||||
};
|
||||
|
||||
export function useTopologyPalette({
|
||||
canWriteTopology,
|
||||
mapId,
|
||||
activeViewName,
|
||||
nodes,
|
||||
keyword,
|
||||
debouncedKeyword,
|
||||
addNeOpen,
|
||||
paletteSource,
|
||||
outsidePeers,
|
||||
outsidePeerQuery,
|
||||
dirtyRef,
|
||||
rfRef,
|
||||
commitCanvas,
|
||||
resetLocalFromServer,
|
||||
showOk,
|
||||
showError,
|
||||
t,
|
||||
setOutsidePeersOpen,
|
||||
setOutsidePeerSelectedIds,
|
||||
}: Args) {
|
||||
const queryClient = useQueryClient();
|
||||
const [paletteAdding, setPaletteAdding] = useState(false);
|
||||
const [outsidePeersAdding, setOutsidePeersAdding] = useState(false);
|
||||
|
||||
const neQuery = useQuery({
|
||||
queryKey: [...queryKeys.managedNeAll, "topology-add-ne", debouncedKeyword],
|
||||
queryFn: () =>
|
||||
fetchManagedNe({
|
||||
keyword: debouncedKeyword,
|
||||
vendor: "",
|
||||
connectStatus: "",
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
}),
|
||||
enabled: addNeOpen && paletteSource === "managed",
|
||||
});
|
||||
|
||||
const umeQuery = useQuery({
|
||||
queryKey: ["umeInventoryNe", "topology-add-ne", debouncedKeyword],
|
||||
queryFn: () =>
|
||||
fetchUmeNe({
|
||||
keyword: debouncedKeyword,
|
||||
page: 1,
|
||||
pageSize: 100,
|
||||
}),
|
||||
enabled: addNeOpen && paletteSource === "ume",
|
||||
});
|
||||
|
||||
const addPaletteItems = useCallback(
|
||||
async (items: PaletteItem[], origin?: { x: number; y: number }): Promise<boolean> => {
|
||||
if (!canWriteTopology) return false;
|
||||
if (!mapId) {
|
||||
showError(t("topology.selectMap"));
|
||||
return false;
|
||||
}
|
||||
if (isWorldFlatViewName(activeViewName)) {
|
||||
showError(t("topology.worldMapNoDirectNes"));
|
||||
return false;
|
||||
}
|
||||
if (items.length === 0 || paletteAdding) return false;
|
||||
|
||||
const onCanvasManaged = new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean));
|
||||
const onCanvasUme = new Set(nodes.map((n) => n.data.ume_ne_id).filter(Boolean));
|
||||
const managedIds = [
|
||||
...new Set(
|
||||
items
|
||||
.filter((item) => item.source === "managed")
|
||||
.map((item) => item.managed_ne_id)
|
||||
.filter((id) => id && !onCanvasManaged.has(id)),
|
||||
),
|
||||
];
|
||||
const umeIds = [
|
||||
...new Set(
|
||||
items
|
||||
.filter((item) => item.source === "ume")
|
||||
.map((item) => item.ume_ne_id)
|
||||
.filter((id) => id && !onCanvasUme.has(id)),
|
||||
),
|
||||
];
|
||||
if (managedIds.length === 0 && umeIds.length === 0) return false;
|
||||
|
||||
if (dirtyRef.current) {
|
||||
if (!window.confirm(t("topology.unsavedSaveBeforeAdd"))) return false;
|
||||
try {
|
||||
await commitCanvas();
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setPaletteAdding(true);
|
||||
try {
|
||||
const graph = await addTopologyViewNodes(mapId, {
|
||||
managed_ne_ids: managedIds,
|
||||
ume_ne_ids: umeIds,
|
||||
layout: "grid",
|
||||
});
|
||||
const managedSet = new Set(managedIds);
|
||||
const umeSet = new Set(umeIds);
|
||||
const added = graph.nodes.filter(
|
||||
(n) =>
|
||||
(n.managed_ne_id && managedSet.has(n.managed_ne_id)) ||
|
||||
(n.ume_ne_id && umeSet.has(n.ume_ne_id)),
|
||||
);
|
||||
if (added.length > 0) {
|
||||
const start = origin || { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 };
|
||||
const cols = Math.max(1, Math.ceil(Math.sqrt(added.length)));
|
||||
await patchTopologyPositions(
|
||||
mapId,
|
||||
added.map((n, i) => ({
|
||||
fabric_node_id: n.fabric_node_id,
|
||||
x: start.x + (i % cols) * 180,
|
||||
y: start.y + Math.floor(i / cols) * 120,
|
||||
})),
|
||||
);
|
||||
}
|
||||
const refreshed = await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
|
||||
resetLocalFromServer(refreshed);
|
||||
const count = added.length || managedIds.length + umeIds.length;
|
||||
showOk(t("topology.addSelectedDone").replace("{{count}}", String(count)));
|
||||
return count > 0;
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setPaletteAdding(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
mapId,
|
||||
nodes,
|
||||
paletteAdding,
|
||||
showError,
|
||||
showOk,
|
||||
t,
|
||||
queryClient,
|
||||
resetLocalFromServer,
|
||||
commitCanvas,
|
||||
dirtyRef,
|
||||
activeViewName,
|
||||
canWriteTopology,
|
||||
],
|
||||
);
|
||||
|
||||
const addNodeAt = useCallback(
|
||||
async (item: PaletteItem, position: { x: number; y: number }) => {
|
||||
await addPaletteItems([item], position);
|
||||
},
|
||||
[addPaletteItems],
|
||||
);
|
||||
|
||||
const onPaletteDragStart = (e: React.DragEvent, item: PaletteItem) => {
|
||||
e.dataTransfer.setData(PALETTE_DND, JSON.stringify(item));
|
||||
e.dataTransfer.effectAllowed = "copy";
|
||||
};
|
||||
|
||||
const onCanvasDragOver = (e: React.DragEvent) => {
|
||||
if (!e.dataTransfer.types.includes(PALETTE_DND)) return;
|
||||
e.preventDefault();
|
||||
e.dataTransfer.dropEffect = "copy";
|
||||
};
|
||||
|
||||
const onCanvasDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
if (!canWriteTopology) return;
|
||||
const raw = e.dataTransfer.getData(PALETTE_DND);
|
||||
if (!raw || !rfRef.current) return;
|
||||
try {
|
||||
const item = JSON.parse(raw) as PaletteItem;
|
||||
const pos = rfRef.current.screenToFlowPosition({ x: e.clientX, y: e.clientY });
|
||||
void addNodeAt(item, pos);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
};
|
||||
|
||||
const addOutsidePeers = useCallback(
|
||||
async (fabricNodeIds: string[]): Promise<boolean> => {
|
||||
if (!mapId) return false;
|
||||
const ids = [...new Set(fabricNodeIds.map((x) => String(x || "").trim()).filter(Boolean))];
|
||||
if (!ids.length || outsidePeersAdding) return false;
|
||||
|
||||
if (dirtyRef.current) {
|
||||
if (!window.confirm(t("topology.unsavedSaveBeforeAdd"))) return false;
|
||||
try {
|
||||
await commitCanvas();
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
setOutsidePeersAdding(true);
|
||||
try {
|
||||
const onCanvas = new Set(nodes.map((n) => n.id));
|
||||
const toAdd = ids.filter((id) => !onCanvas.has(id));
|
||||
if (!toAdd.length) {
|
||||
setOutsidePeersOpen(false);
|
||||
setOutsidePeerSelectedIds([]);
|
||||
return false;
|
||||
}
|
||||
const graph = await addTopologyViewNodes(mapId, {
|
||||
fabric_node_ids: toAdd,
|
||||
layout: "grid",
|
||||
});
|
||||
if (graph.truncate_reason === "membership_frozen") {
|
||||
showError(t("topology.truncatedFrozen"));
|
||||
return false;
|
||||
}
|
||||
const addedSet = new Set(toAdd);
|
||||
const added = (graph.nodes || []).filter((n) => addedSet.has(n.fabric_node_id));
|
||||
if (added.length > 0) {
|
||||
const viaAnchors = new Map(
|
||||
outsidePeers
|
||||
.filter((p) => addedSet.has(p.fabric_node_id))
|
||||
.map((p) => [p.fabric_node_id, p.via_node_id] as const),
|
||||
);
|
||||
const cols = Math.max(1, Math.ceil(Math.sqrt(added.length)));
|
||||
await patchTopologyPositions(
|
||||
mapId,
|
||||
added.map((n, i) => {
|
||||
const viaId = viaAnchors.get(n.fabric_node_id);
|
||||
const viaNode = viaId ? nodes.find((x) => x.id === viaId) : undefined;
|
||||
const base = viaNode
|
||||
? { x: viaNode.position.x + 200, y: viaNode.position.y }
|
||||
: { x: 80 + nodes.length * 24, y: 80 + nodes.length * 24 };
|
||||
return {
|
||||
fabric_node_id: n.fabric_node_id,
|
||||
x: base.x + (i % cols) * 180,
|
||||
y: base.y + Math.floor(i / cols) * 120,
|
||||
};
|
||||
}),
|
||||
);
|
||||
}
|
||||
const refreshed = await fetchTopologyGraph(mapId);
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), refreshed);
|
||||
resetLocalFromServer(refreshed);
|
||||
const count = added.length || toAdd.length;
|
||||
showOk(t("topology.projectedNeighbors").replace("{{count}}", String(count)));
|
||||
setOutsidePeerSelectedIds([]);
|
||||
setOutsidePeersOpen(false);
|
||||
return count > 0;
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
return false;
|
||||
} finally {
|
||||
setOutsidePeersAdding(false);
|
||||
}
|
||||
},
|
||||
[
|
||||
mapId,
|
||||
nodes,
|
||||
outsidePeers,
|
||||
outsidePeersAdding,
|
||||
resetLocalFromServer,
|
||||
commitCanvas,
|
||||
dirtyRef,
|
||||
queryClient,
|
||||
showOk,
|
||||
showError,
|
||||
t,
|
||||
setOutsidePeersOpen,
|
||||
setOutsidePeerSelectedIds,
|
||||
],
|
||||
);
|
||||
|
||||
const outsidePeerNameById = useMemo(() => {
|
||||
const m = new Map<string, string>();
|
||||
for (const n of nodes) {
|
||||
const label = String(n.data?.label || n.data?.subtitle || "").trim();
|
||||
m.set(n.id, label || n.id.slice(0, 8));
|
||||
}
|
||||
return m;
|
||||
}, [nodes]);
|
||||
|
||||
const outsidePeersVisible = useMemo(() => {
|
||||
const q = outsidePeerQuery.trim().toLowerCase();
|
||||
if (!q) return outsidePeers;
|
||||
return outsidePeers.filter((p) => {
|
||||
const viaName = outsidePeerNameById.get(p.via_node_id) || p.via_node_id;
|
||||
const bits = [p.name, p.ip, viaName, p.fabric_node_id];
|
||||
return bits.some((b) => String(b || "").toLowerCase().includes(q));
|
||||
});
|
||||
}, [outsidePeers, outsidePeerQuery, outsidePeerNameById]);
|
||||
|
||||
const onCanvasManagedIds = useMemo(
|
||||
() => new Set(nodes.map((n) => n.data.managed_ne_id).filter(Boolean)),
|
||||
[nodes],
|
||||
);
|
||||
const onCanvasUmeIds = useMemo(
|
||||
() => new Set(nodes.map((n) => n.data.ume_ne_id).filter(Boolean)),
|
||||
[nodes],
|
||||
);
|
||||
|
||||
const palette = useMemo((): PaletteItem[] => {
|
||||
if (paletteSource === "ume") {
|
||||
return (umeQuery.data?.items || []).map((ne) => {
|
||||
const name = (ne.host_name || ne.ne_name || ne.user_label || ne.ip_address || ne.ne_id).trim();
|
||||
return {
|
||||
key: `ume:${ne.ne_id}`,
|
||||
source: "ume" as const,
|
||||
managed_ne_id: "",
|
||||
ume_ne_id: ne.ne_id,
|
||||
name,
|
||||
ip: ne.ip_address || "",
|
||||
vendor: "ZTE",
|
||||
meta: `${ne.ip_address || "-"}${SEP}${ne.ne_type || "UME"}`,
|
||||
connect_status: ne.connection_status || "",
|
||||
};
|
||||
});
|
||||
}
|
||||
return (neQuery.data?.items || []).map((ne) => ({
|
||||
key: `managed:${ne.id}`,
|
||||
source: "managed" as const,
|
||||
managed_ne_id: ne.id,
|
||||
ume_ne_id: "",
|
||||
name: ne.name || ne.ip_address,
|
||||
ip: ne.ip_address,
|
||||
vendor: ne.vendor,
|
||||
meta: `${ne.ip_address}${SEP}${ne.vendor}`,
|
||||
connect_status: ne.connect_status,
|
||||
}));
|
||||
}, [paletteSource, neQuery.data, umeQuery.data]);
|
||||
|
||||
const paletteVisible = useMemo(() => {
|
||||
const tokens = keyword
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
let list = palette;
|
||||
if (tokens.length) {
|
||||
list = list.filter((item) => {
|
||||
const bits = [item.name, item.ip, item.vendor, item.managed_ne_id, item.ume_ne_id, item.meta];
|
||||
return tokens.every((tok) => bits.some((b) => fuzzyIncludes(String(b || ""), tok)));
|
||||
});
|
||||
}
|
||||
return list.filter((item) =>
|
||||
item.source === "managed"
|
||||
? !onCanvasManagedIds.has(item.managed_ne_id)
|
||||
: !onCanvasUmeIds.has(item.ume_ne_id),
|
||||
);
|
||||
}, [palette, onCanvasManagedIds, onCanvasUmeIds, keyword]);
|
||||
|
||||
const paletteLoading =
|
||||
(paletteSource === "managed" && neQuery.isLoading) ||
|
||||
(paletteSource === "ume" && umeQuery.isLoading);
|
||||
|
||||
return {
|
||||
paletteAdding,
|
||||
outsidePeersAdding,
|
||||
neQuery,
|
||||
umeQuery,
|
||||
addPaletteItems,
|
||||
addNodeAt,
|
||||
onPaletteDragStart,
|
||||
onCanvasDragOver,
|
||||
onCanvasDrop,
|
||||
addOutsidePeers,
|
||||
outsidePeerNameById,
|
||||
outsidePeersVisible,
|
||||
palette,
|
||||
paletteVisible,
|
||||
paletteLoading,
|
||||
};
|
||||
}
|
||||
152
web/src/features/topology/hooks/useTopologySave.ts
Normal file
152
web/src/features/topology/hooks/useTopologySave.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { useCallback } from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { queryKeys } from "../../../constants/queryKeys";
|
||||
import { formatErr } from "../../../services/api";
|
||||
import { commitTopologyView } from "../../../services/api/topology";
|
||||
import type { TopologyViewGraph } from "../../../types";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
import { applyViewGraph, flowToPositions } from "../lib/graphFlow";
|
||||
import type { EdgeDefaults } from "../lib/edgeStyle";
|
||||
import type { HistorySnap } from "../lib/pageTypes";
|
||||
import { buildViewCommitBody } from "../lib/saveDiff";
|
||||
|
||||
type PortEdit = { a_port: string; b_port: string };
|
||||
type StyleEdit = { stroke_color: string; stroke_width: number; line_style: string };
|
||||
|
||||
type Args = {
|
||||
mapId: string;
|
||||
nodes: Node<NeNodeData>[];
|
||||
edges: Edge[];
|
||||
graphQueryData?: TopologyViewGraph;
|
||||
edgeDefaults: EdgeDefaults;
|
||||
setNodes: React.Dispatch<React.SetStateAction<Node<NeNodeData>[]>>;
|
||||
setEdges: React.Dispatch<React.SetStateAction<Edge[]>>;
|
||||
historyRef: React.MutableRefObject<HistorySnap[]>;
|
||||
redoRef: React.MutableRefObject<HistorySnap[]>;
|
||||
historyLockRef: React.MutableRefObject<boolean>;
|
||||
pendingEdgeCreatesRef: React.MutableRefObject<Set<string>>;
|
||||
pendingEdgeDeletesRef: React.MutableRefObject<Set<string>>;
|
||||
pendingEdgePortEditsRef: React.MutableRefObject<Map<string, PortEdit>>;
|
||||
pendingEdgeStyleRef: React.MutableRefObject<Map<string, StyleEdit>>;
|
||||
clearDirty: () => void;
|
||||
showOk: (msg: string) => void;
|
||||
showError: (msg: string) => void;
|
||||
t: (key: string) => string;
|
||||
};
|
||||
|
||||
export function useTopologySave({
|
||||
mapId,
|
||||
nodes,
|
||||
edges,
|
||||
graphQueryData,
|
||||
edgeDefaults,
|
||||
setNodes,
|
||||
setEdges,
|
||||
historyRef,
|
||||
redoRef,
|
||||
historyLockRef,
|
||||
pendingEdgeCreatesRef,
|
||||
pendingEdgeDeletesRef,
|
||||
pendingEdgePortEditsRef,
|
||||
pendingEdgeStyleRef,
|
||||
clearDirty,
|
||||
showOk,
|
||||
showError,
|
||||
t,
|
||||
}: Args) {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const resetLocalFromServer = useCallback(
|
||||
(graph: TopologyViewGraph) => {
|
||||
historyLockRef.current = true;
|
||||
applyViewGraph(graph, edgeDefaults, setNodes, setEdges);
|
||||
historyLockRef.current = false;
|
||||
pendingEdgeCreatesRef.current = new Set();
|
||||
pendingEdgeDeletesRef.current = new Set();
|
||||
pendingEdgePortEditsRef.current = new Map();
|
||||
pendingEdgeStyleRef.current = new Map();
|
||||
historyRef.current = [];
|
||||
redoRef.current = [];
|
||||
clearDirty();
|
||||
},
|
||||
[
|
||||
edgeDefaults,
|
||||
setNodes,
|
||||
setEdges,
|
||||
historyLockRef,
|
||||
pendingEdgeCreatesRef,
|
||||
pendingEdgeDeletesRef,
|
||||
pendingEdgePortEditsRef,
|
||||
pendingEdgeStyleRef,
|
||||
historyRef,
|
||||
redoRef,
|
||||
clearDirty,
|
||||
],
|
||||
);
|
||||
|
||||
const commitCanvas = useCallback(async () => {
|
||||
if (!mapId) throw new Error(t("topology.selectMap"));
|
||||
const body = buildViewCommitBody({
|
||||
nodes,
|
||||
edges,
|
||||
serverNodeIds: (graphQueryData?.nodes || []).map((n) => n.fabric_node_id),
|
||||
positions: flowToPositions(nodes),
|
||||
pendingCreates: pendingEdgeCreatesRef.current,
|
||||
pendingDeletes: pendingEdgeDeletesRef.current,
|
||||
pendingPortEdits: pendingEdgePortEditsRef.current,
|
||||
pendingStyles: pendingEdgeStyleRef.current,
|
||||
});
|
||||
const result = await commitTopologyView(mapId, body);
|
||||
if (result.graph) {
|
||||
queryClient.setQueryData(queryKeys.topologyGraph(mapId), result.graph);
|
||||
resetLocalFromServer(result.graph);
|
||||
} else {
|
||||
pendingEdgeCreatesRef.current = new Set();
|
||||
pendingEdgeDeletesRef.current = new Set();
|
||||
pendingEdgePortEditsRef.current = new Map();
|
||||
pendingEdgeStyleRef.current = new Map();
|
||||
historyRef.current = [];
|
||||
redoRef.current = [];
|
||||
clearDirty();
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
|
||||
}
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
|
||||
return result;
|
||||
}, [
|
||||
mapId,
|
||||
nodes,
|
||||
edges,
|
||||
graphQueryData,
|
||||
pendingEdgeCreatesRef,
|
||||
pendingEdgeDeletesRef,
|
||||
historyRef,
|
||||
redoRef,
|
||||
clearDirty,
|
||||
queryClient,
|
||||
resetLocalFromServer,
|
||||
t,
|
||||
]);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: commitCanvas,
|
||||
onSuccess: (result) => {
|
||||
const skipped = Number(result.skipped_locked || 0);
|
||||
if (skipped > 0) {
|
||||
showOk(t("topology.savedLockedSkipped").replace("{{count}}", String(skipped)));
|
||||
} else {
|
||||
showOk(t("topology.saved"));
|
||||
}
|
||||
},
|
||||
onError: async (err) => {
|
||||
showError(t("topology.saveFailed").replace("{{detail}}", formatErr(err)));
|
||||
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
saveMut,
|
||||
commitCanvas,
|
||||
resetLocalFromServer,
|
||||
};
|
||||
}
|
||||
49
web/src/features/topology/lib/fitNodes.ts
Normal file
49
web/src/features/topology/lib/fitNodes.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import type { Node, ReactFlowInstance } from "@xyflow/react";
|
||||
import { TOPO_NODE_H, TOPO_NODE_W } from "./topoGeometry";
|
||||
|
||||
type FitOpts = {
|
||||
padding: number;
|
||||
duration?: number;
|
||||
/** Smallest box to fit (flow units); keeps a single-node locate from zooming to max. */
|
||||
minWidth?: number;
|
||||
minHeight?: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* Fit the viewport to nodes using their known geometry.
|
||||
*
|
||||
* xyflow's fitView waits until the targeted nodes are measured. With
|
||||
* onlyRenderVisibleElements, nodes outside the initial viewport are never rendered,
|
||||
* so on a large canvas far from the origin fitView stays queued forever and the
|
||||
* user sees an empty canvas. Our nodes carry explicit width/height, so we compute
|
||||
* the bounds ourselves and use fitBounds, which needs no measurement.
|
||||
*/
|
||||
export function fitToNodes(
|
||||
inst: Pick<ReactFlowInstance, "fitBounds">,
|
||||
nodes: Array<Pick<Node, "position" | "width" | "height">>,
|
||||
opts: FitOpts,
|
||||
): boolean {
|
||||
if (!nodes.length) return false;
|
||||
let minX = Infinity;
|
||||
let minY = Infinity;
|
||||
let maxX = -Infinity;
|
||||
let maxY = -Infinity;
|
||||
for (const n of nodes) {
|
||||
const w = Number(n.width) || TOPO_NODE_W;
|
||||
const h = Number(n.height) || TOPO_NODE_H;
|
||||
minX = Math.min(minX, n.position.x);
|
||||
minY = Math.min(minY, n.position.y);
|
||||
maxX = Math.max(maxX, n.position.x + w);
|
||||
maxY = Math.max(maxY, n.position.y + h);
|
||||
}
|
||||
if (!Number.isFinite(minX)) return false;
|
||||
const width = Math.max(1, opts.minWidth ?? 0, maxX - minX);
|
||||
const height = Math.max(1, opts.minHeight ?? 0, maxY - minY);
|
||||
const cx = (minX + maxX) / 2;
|
||||
const cy = (minY + maxY) / 2;
|
||||
void inst.fitBounds(
|
||||
{ x: cx - width / 2, y: cy - height / 2, width, height },
|
||||
{ padding: opts.padding, duration: opts.duration ?? 0 },
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
|
@ -31,18 +31,25 @@ export function graphToFlow(
|
|||
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;
|
||||
const locked = Boolean(n.locked);
|
||||
const labelOverride = String(n.label_override ?? "");
|
||||
return {
|
||||
id: n.fabric_node_id,
|
||||
type: "neNode",
|
||||
position,
|
||||
width: TOPO_NODE_W,
|
||||
height: isRegion ? TOPO_REGION_ICON_H : TOPO_NODE_H,
|
||||
draggable: locked ? false : undefined,
|
||||
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,
|
||||
label_override: labelOverride,
|
||||
label_override_saved: labelOverride,
|
||||
name: n.name || "",
|
||||
locked,
|
||||
managed_ne_id: n.managed_ne_id || "",
|
||||
ume_ne_id: n.ume_ne_id || "",
|
||||
ne_ip: n.ip || "",
|
||||
|
|
@ -93,12 +100,23 @@ export function flowToPositions(nodes: Node<NeNodeData>[]) {
|
|||
const pos = isRegion
|
||||
? regionApiPosition(n.position.x, n.position.y)
|
||||
: neApiPosition(n.position.x, n.position.y);
|
||||
return {
|
||||
const out: {
|
||||
fabric_node_id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
label?: string;
|
||||
} = {
|
||||
fabric_node_id: n.id,
|
||||
x: pos.x,
|
||||
y: pos.y,
|
||||
label: n.data.label || "",
|
||||
};
|
||||
// Omit locked so the server keeps the existing lock; omit label unless overridden locally.
|
||||
const currentOverride = String(n.data.label_override ?? "");
|
||||
const savedOverride = String(n.data.label_override_saved ?? "");
|
||||
if (currentOverride !== savedOverride) {
|
||||
out.label = currentOverride;
|
||||
}
|
||||
return out;
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
163
web/src/features/topology/lib/moduleLinks.ts
Normal file
163
web/src/features/topology/lib/moduleLinks.ts
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
import type { Edge, Node } from "@xyflow/react";
|
||||
import { fetchManagedNeById } from "../../../services/api";
|
||||
import { openOrFocusModule } from "../../../utils/moduleWindows";
|
||||
import type { NeNodeData } from "../components/TopologyReactFlowView";
|
||||
import type { EdgeStyleData } from "./edgeStyle";
|
||||
|
||||
export function openNeInventory(opts: {
|
||||
neId?: string;
|
||||
create?: boolean;
|
||||
name?: string;
|
||||
ip_address?: string;
|
||||
vendor?: string;
|
||||
}) {
|
||||
const q = new URLSearchParams();
|
||||
if (opts.neId) {
|
||||
q.set("ne_id", opts.neId);
|
||||
} else if (opts.create) {
|
||||
q.set("create", "1");
|
||||
if (opts.name) q.set("name", opts.name);
|
||||
if (opts.ip_address) q.set("ip_address", opts.ip_address);
|
||||
if (opts.vendor) q.set("vendor", opts.vendor);
|
||||
}
|
||||
const qs = q.toString();
|
||||
openOrFocusModule({
|
||||
moduleId: "ne",
|
||||
path: qs ? `/ne?${qs}` : "/ne",
|
||||
});
|
||||
}
|
||||
|
||||
type LinkToast = {
|
||||
showOk: (msg: string) => void;
|
||||
showError: (msg: string) => void;
|
||||
t: (key: string) => string;
|
||||
closeCtxMenu: () => void;
|
||||
};
|
||||
|
||||
export function openWebcrtFor(
|
||||
node: Node<NeNodeData> | null,
|
||||
{ showOk, showError, t, closeCtxMenu }: LinkToast,
|
||||
) {
|
||||
closeCtxMenu();
|
||||
if (!node) return;
|
||||
const managedId = String(node.data.managed_ne_id || "").trim();
|
||||
const umeId = String(node.data.ume_ne_id || "").trim();
|
||||
|
||||
if (managedId) {
|
||||
void (async () => {
|
||||
try {
|
||||
const ne = await fetchManagedNeById(managedId);
|
||||
const src = String(ne.source || "").trim().toLowerCase();
|
||||
const hasIp = Boolean(String(ne.ip_address || "").trim());
|
||||
// Ready inventory hosts: reuse stored credentials (incl. hop/proxy).
|
||||
// LLDP / topology placeholders / incomplete rows → full NE edit form.
|
||||
const needsSetup = !hasIp || src === "lldp" || src === "topology";
|
||||
if (!needsSetup) {
|
||||
openOrFocusModule({
|
||||
moduleId: "webcrt",
|
||||
path: `/webcrt?ne_id=${encodeURIComponent(managedId)}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
showOk(t("topology.completeNeFirst"));
|
||||
openNeInventory({ neId: managedId });
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
if (umeId) {
|
||||
openOrFocusModule({
|
||||
moduleId: "webcrt",
|
||||
path: `/webcrt?ne_id=${encodeURIComponent(umeId)}&source=ume`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// No inventory link — open NE create with topology hint fields.
|
||||
showOk(t("topology.completeNeFirst"));
|
||||
openNeInventory({
|
||||
create: true,
|
||||
name: String(node.data.label || "").trim(),
|
||||
ip_address: String(node.data.ne_ip || "").trim(),
|
||||
vendor: String(node.data.vendor || "").trim(),
|
||||
});
|
||||
}
|
||||
|
||||
export function openNeFor(
|
||||
node: Node<NeNodeData> | null,
|
||||
{ showError, t, closeCtxMenu }: Pick<LinkToast, "showError" | "t" | "closeCtxMenu">,
|
||||
) {
|
||||
closeCtxMenu();
|
||||
const managedId = String(node?.data.managed_ne_id || "").trim();
|
||||
const umeId = String(node?.data.ume_ne_id || "").trim();
|
||||
if (managedId) {
|
||||
openNeInventory({ neId: managedId });
|
||||
return;
|
||||
}
|
||||
if (umeId) {
|
||||
openOrFocusModule({
|
||||
moduleId: "ume",
|
||||
path: "/ume",
|
||||
});
|
||||
return;
|
||||
}
|
||||
showError(t("topology.noNeLink"));
|
||||
}
|
||||
|
||||
export function openAlarmsFor(
|
||||
node: Node<NeNodeData> | null,
|
||||
{ showError, t, closeCtxMenu }: Pick<LinkToast, "showError" | "t" | "closeCtxMenu">,
|
||||
) {
|
||||
closeCtxMenu();
|
||||
if (!node || node.data.kind === "region") {
|
||||
showError(t("topology.noAlarmLink"));
|
||||
return;
|
||||
}
|
||||
const host = String(node.data.label || node.data.ne_ip || "").trim();
|
||||
const q = new URLSearchParams();
|
||||
if (host) q.set("host", host);
|
||||
q.set("cleared", "false");
|
||||
openOrFocusModule({
|
||||
moduleId: "network",
|
||||
path: `/network/alarms?${q.toString()}`,
|
||||
});
|
||||
}
|
||||
|
||||
export function openPortTrafficForEdge(
|
||||
edge: Edge | null,
|
||||
nodes: Node<NeNodeData>[],
|
||||
{ showError, t, closeCtxMenu }: Pick<LinkToast, "showError" | "t" | "closeCtxMenu">,
|
||||
) {
|
||||
closeCtxMenu();
|
||||
if (!edge) {
|
||||
showError(t("topology.noNeLink"));
|
||||
return;
|
||||
}
|
||||
const srcNode = nodes.find((n) => n.id === edge.source);
|
||||
const data = (edge.data || {}) as EdgeStyleData;
|
||||
const ifname = String(data.source_port || "").trim();
|
||||
const managedId = String(srcNode?.data.managed_ne_id || "").trim();
|
||||
const umeId = String(srcNode?.data.ume_ne_id || "").trim();
|
||||
if (managedId) {
|
||||
const q = new URLSearchParams({ ne_id: managedId, source: "managed" });
|
||||
if (ifname) q.set("ifname", ifname);
|
||||
openOrFocusModule({
|
||||
moduleId: "network",
|
||||
path: `/network/tasks/port-traffic?${q.toString()}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (umeId) {
|
||||
const q = new URLSearchParams({ ne_id: umeId, source: "ume" });
|
||||
if (ifname) q.set("ifname", ifname);
|
||||
openOrFocusModule({
|
||||
moduleId: "network",
|
||||
path: `/network/tasks/port-traffic?${q.toString()}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
showError(t("topology.noNeLink"));
|
||||
}
|
||||
|
|
@ -6,6 +6,10 @@ export type HistorySnap = {
|
|||
edges: Edge[];
|
||||
pendingEdgeDeletes: string[];
|
||||
pendingEdgeCreates: string[];
|
||||
pendingEdgePortEdits: Array<[string, { a_port: string; b_port: string }]>;
|
||||
pendingEdgeStyles: Array<
|
||||
[string, { stroke_color: string; stroke_width: number; line_style: string }]
|
||||
>;
|
||||
};
|
||||
|
||||
export type PaletteSource = "managed" | "ume";
|
||||
|
|
|
|||
2
web/src/features/topology/lib/pageViewModel.ts
Normal file
2
web/src/features/topology/lib/pageViewModel.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/** Loose bag for TopologyPage → TopologyPageView wiring (pure layout split). */
|
||||
export type TopologyPageViewModel = Record<string, any>;
|
||||
136
web/src/features/topology/lib/saveDiff.ts
Normal file
136
web/src/features/topology/lib/saveDiff.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Pure helpers that turn local canvas state into a view /commit body.
|
||||
* Runtime-import-free so Node --experimental-strip-types tests can load this file.
|
||||
*/
|
||||
|
||||
export type SaveDiffNode = {
|
||||
id: string;
|
||||
data?: { kind?: string };
|
||||
};
|
||||
|
||||
export type SaveDiffEdge = {
|
||||
id: string;
|
||||
source: string;
|
||||
target: string;
|
||||
data?: {
|
||||
source_port?: string;
|
||||
target_port?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type SaveDiffPosition = {
|
||||
fabric_node_id: string;
|
||||
x: number;
|
||||
y: number;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export type ViewCommitBody = {
|
||||
edge_creates: Array<{
|
||||
client_id: string;
|
||||
a_node_id: string;
|
||||
b_node_id: string;
|
||||
a_port: string;
|
||||
b_port: string;
|
||||
}>;
|
||||
edge_deletes: string[];
|
||||
edge_port_edits: Array<{ id: string; a_port: string; b_port: string }>;
|
||||
edge_styles: Array<{
|
||||
fabric_edge_id: string;
|
||||
stroke_color: string;
|
||||
stroke_width: number;
|
||||
line_style: string;
|
||||
}>;
|
||||
node_adds: string[];
|
||||
node_removes: string[];
|
||||
positions: SaveDiffPosition[];
|
||||
};
|
||||
|
||||
export function isRegionNodeId(id: string): boolean {
|
||||
return String(id || "").startsWith("region:");
|
||||
}
|
||||
|
||||
export function isLocalPendingEdgeId(id: string): boolean {
|
||||
return String(id || "").startsWith("local:");
|
||||
}
|
||||
|
||||
export type BuildCommitInput = {
|
||||
nodes: SaveDiffNode[];
|
||||
edges: SaveDiffEdge[];
|
||||
serverNodeIds: string[];
|
||||
positions: SaveDiffPosition[];
|
||||
pendingCreates: Iterable<string>;
|
||||
pendingDeletes: Iterable<string>;
|
||||
pendingPortEdits?: Iterable<[string, { a_port: string; b_port: string }]>;
|
||||
pendingStyles?: Iterable<
|
||||
[string, { stroke_color: string; stroke_width: number; line_style: string }]
|
||||
>;
|
||||
};
|
||||
|
||||
export function buildViewCommitBody(input: BuildCommitInput): ViewCommitBody {
|
||||
const createSet = new Set(
|
||||
[...input.pendingCreates].map((x) => String(x || "").trim()).filter(Boolean),
|
||||
);
|
||||
// Deletes that were also re-created (or are still local) must not be sent.
|
||||
const deleteSet = new Set(
|
||||
[...input.pendingDeletes]
|
||||
.map((x) => String(x || "").trim())
|
||||
.filter((id) => id && !isLocalPendingEdgeId(id) && !createSet.has(id)),
|
||||
);
|
||||
|
||||
const edgeById = new Map(input.edges.map((e) => [e.id, e]));
|
||||
const edge_creates = [...createSet]
|
||||
.map((id) => edgeById.get(id))
|
||||
.filter((e): e is SaveDiffEdge => Boolean(e))
|
||||
.map((e) => ({
|
||||
client_id: e.id,
|
||||
a_node_id: e.source,
|
||||
b_node_id: e.target,
|
||||
a_port: String(e.data?.source_port || ""),
|
||||
b_port: String(e.data?.target_port || ""),
|
||||
}));
|
||||
|
||||
const localIds = input.nodes.map((n) => n.id);
|
||||
const localSet = new Set(localIds);
|
||||
const serverSet = new Set(
|
||||
input.serverNodeIds.map((x) => String(x || "").trim()).filter(Boolean),
|
||||
);
|
||||
const node_adds = localIds.filter((id) => !serverSet.has(id) && !isRegionNodeId(id));
|
||||
const node_removes = [...serverSet].filter((id) => !localSet.has(id) && !isRegionNodeId(id));
|
||||
|
||||
const edge_port_edits = [...(input.pendingPortEdits || [])]
|
||||
.map(([id, ports]) => ({
|
||||
id: String(id || "").trim(),
|
||||
a_port: String(ports.a_port || ""),
|
||||
b_port: String(ports.b_port || ""),
|
||||
}))
|
||||
.filter(
|
||||
(x) => x.id && !isLocalPendingEdgeId(x.id) && !createSet.has(x.id) && !deleteSet.has(x.id),
|
||||
);
|
||||
|
||||
const edge_styles = [...(input.pendingStyles || [])]
|
||||
.map(([id, style]) => ({
|
||||
fabric_edge_id: String(id || "").trim(),
|
||||
stroke_color: String(style.stroke_color || ""),
|
||||
stroke_width: Number(style.stroke_width || 0),
|
||||
line_style: String(style.line_style || ""),
|
||||
}))
|
||||
// Deleted edges drop their style; a new local edge's style is keyed by its client id
|
||||
// (the server maps it through edge_id_map) only while that edge is still being created.
|
||||
.filter(
|
||||
(x) =>
|
||||
x.fabric_edge_id &&
|
||||
!deleteSet.has(x.fabric_edge_id) &&
|
||||
(!isLocalPendingEdgeId(x.fabric_edge_id) || createSet.has(x.fabric_edge_id)),
|
||||
);
|
||||
|
||||
return {
|
||||
edge_creates,
|
||||
edge_deletes: [...deleteSet],
|
||||
edge_port_edits,
|
||||
edge_styles,
|
||||
node_adds,
|
||||
node_removes,
|
||||
positions: input.positions,
|
||||
};
|
||||
}
|
||||
|
|
@ -115,9 +115,12 @@ const topology = {
|
|||
save: "Save",
|
||||
saveDirty: "Save *",
|
||||
unsavedConfirm: "You have unsaved changes. Discard and switch maps?",
|
||||
unsavedSaveBeforeAdd: "You have unsaved changes. Save and continue adding?",
|
||||
saving: "Saving…",
|
||||
saved: "Topology saved",
|
||||
savedLockedSkipped: "Topology saved ({{count}} locked node(s) skipped)",
|
||||
saveFailed: "Save failed: {{detail}}",
|
||||
renameNodeClearHint: "Leave empty to follow the device name",
|
||||
readOnlyBanner: "Read-only (ne:write scope required to edit)",
|
||||
readOnlyHint: "Read-only — no write permission",
|
||||
discoverTimeout: "Discovery timed out; check LLDP tasks for progress",
|
||||
|
|
|
|||
|
|
@ -112,9 +112,12 @@ const topology = {
|
|||
save: "保存",
|
||||
saveDirty: "保存 *",
|
||||
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
|
||||
unsavedSaveBeforeAdd: "当前有未保存修改,保存后继续添加?",
|
||||
saving: "保存中…",
|
||||
saved: "拓扑已保存",
|
||||
savedLockedSkipped: "拓扑已保存({{count}} 个锁定节点未移动)",
|
||||
saveFailed: "保存失败:{{detail}}",
|
||||
renameNodeClearHint: "留空则恢复跟随设备名",
|
||||
readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)",
|
||||
readOnlyHint: "只读模式,无编辑权限",
|
||||
discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度",
|
||||
|
|
|
|||
|
|
@ -132,6 +132,14 @@ export const deleteTopologyView = (viewId: string, force = false) =>
|
|||
`/v1/topology/views/${encodeURIComponent(viewId)}?force=${force ? "true" : "false"}`,
|
||||
);
|
||||
|
||||
export type TopologyPositionsMutation = {
|
||||
ok?: boolean;
|
||||
skipped_locked?: number;
|
||||
updated?: number;
|
||||
matched?: number;
|
||||
graph?: TopologyViewGraph | null;
|
||||
};
|
||||
|
||||
export const patchTopologyPositions = (
|
||||
viewId: string,
|
||||
positions: Array<{
|
||||
|
|
@ -141,10 +149,17 @@ export const patchTopologyPositions = (
|
|||
label?: string;
|
||||
locked?: boolean;
|
||||
}>,
|
||||
) =>
|
||||
apiPatch<TopologyViewGraph>(`/v1/topology/views/${encodeURIComponent(viewId)}/positions`, {
|
||||
positions,
|
||||
});
|
||||
opts?: { return_graph?: boolean },
|
||||
) => {
|
||||
const returnGraph = opts?.return_graph !== false;
|
||||
return apiPatch<TopologyViewGraph | TopologyPositionsMutation>(
|
||||
`/v1/topology/views/${encodeURIComponent(viewId)}/positions`,
|
||||
{
|
||||
positions,
|
||||
return_graph: returnGraph,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const addTopologyViewNodes = (
|
||||
viewId: string,
|
||||
|
|
@ -401,6 +416,62 @@ export const deleteFabricEdge = (edgeId: string) =>
|
|||
`/v1/topology/fabric/edges/${encodeURIComponent(edgeId)}`,
|
||||
);
|
||||
|
||||
export const patchFabricEdgePorts = (
|
||||
edgeId: string,
|
||||
body: { a_port?: string; b_port?: string },
|
||||
) =>
|
||||
apiPatch<{
|
||||
id: string;
|
||||
a_node_id: string;
|
||||
b_node_id: string;
|
||||
a_port: string;
|
||||
b_port: string;
|
||||
source: string;
|
||||
status: string;
|
||||
layer: string;
|
||||
}>(`/v1/topology/fabric/edges/${encodeURIComponent(edgeId)}`, body);
|
||||
|
||||
export type TopologyViewCommitBody = {
|
||||
edge_creates?: Array<{
|
||||
client_id?: string;
|
||||
a_node_id: string;
|
||||
b_node_id: string;
|
||||
a_port?: string;
|
||||
b_port?: string;
|
||||
}>;
|
||||
edge_deletes?: string[];
|
||||
edge_port_edits?: Array<{ id: string; a_port?: string; b_port?: string }>;
|
||||
edge_styles?: Array<{
|
||||
fabric_edge_id: string;
|
||||
stroke_color?: string;
|
||||
stroke_width?: number;
|
||||
line_style?: string;
|
||||
}>;
|
||||
node_adds?: string[];
|
||||
node_removes?: string[];
|
||||
positions?: Array<{
|
||||
fabric_node_id: string;
|
||||
x?: number;
|
||||
y?: number;
|
||||
label?: string;
|
||||
locked?: boolean;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type TopologyViewCommitResult = {
|
||||
ok: boolean;
|
||||
view_id: string;
|
||||
edge_id_map: Record<string, string>;
|
||||
skipped_locked: number;
|
||||
graph: TopologyViewGraph | null;
|
||||
};
|
||||
|
||||
export const commitTopologyView = (viewId: string, body: TopologyViewCommitBody) =>
|
||||
apiPost<TopologyViewCommitResult>(
|
||||
`/v1/topology/views/${encodeURIComponent(viewId)}/commit`,
|
||||
body,
|
||||
);
|
||||
|
||||
/** Back-compat aliases used by older call sites during cutover. */
|
||||
export const fetchTopologyMaps = fetchTopologyViews;
|
||||
export const createTopologyMap = createTopologyView;
|
||||
|
|
|
|||
|
|
@ -3086,6 +3086,22 @@
|
|||
opacity: 0.92;
|
||||
}
|
||||
|
||||
.topo-node__lock {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
left: -6px;
|
||||
z-index: 2;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--nx-surface-2) 82%, #94a3b8 18%);
|
||||
border: 1px solid rgb(var(--nx-ink-rgb) / 0.28);
|
||||
color: var(--nx-text-muted);
|
||||
pointer-events: none;
|
||||
}
|
||||
.topo-node__badge {
|
||||
position: absolute;
|
||||
top: -4px;
|
||||
|
|
|
|||
|
|
@ -115,6 +115,8 @@ export type TopologyViewNodeItem = {
|
|||
managed_ne_id: string;
|
||||
ume_ne_id: string;
|
||||
label: string;
|
||||
/** Raw placement override; empty means follow device name/ip. */
|
||||
label_override?: string;
|
||||
x: number;
|
||||
y: number;
|
||||
locked: boolean;
|
||||
|
|
|
|||
116
web/tests/topologySaveDiff.test.mjs
Normal file
116
web/tests/topologySaveDiff.test.mjs
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { test } from "node:test";
|
||||
import { buildViewCommitBody, isRegionNodeId } from "../src/features/topology/lib/saveDiff.ts";
|
||||
|
||||
test("local add/remove nodes; region nodes stay out of add/remove lists", () => {
|
||||
const body = buildViewCommitBody({
|
||||
nodes: [
|
||||
{ id: "fab-a" },
|
||||
{ id: "fab-new" },
|
||||
{ id: "region:x", data: { kind: "region" } },
|
||||
],
|
||||
edges: [],
|
||||
serverNodeIds: ["fab-a", "fab-gone", "region:y"],
|
||||
positions: [
|
||||
{ fabric_node_id: "fab-a", x: 1, y: 2 },
|
||||
{ fabric_node_id: "fab-new", x: 3, y: 4 },
|
||||
{ fabric_node_id: "region:x", x: 5, y: 6 },
|
||||
],
|
||||
pendingCreates: [],
|
||||
pendingDeletes: [],
|
||||
});
|
||||
assert.deepEqual(body.node_adds, ["fab-new"]);
|
||||
assert.deepEqual(body.node_removes, ["fab-gone"]);
|
||||
assert.ok(!body.node_adds.some(isRegionNodeId));
|
||||
assert.ok(!body.node_removes.some(isRegionNodeId));
|
||||
assert.equal(body.positions.length, 3);
|
||||
});
|
||||
|
||||
test("only changed label overrides appear in positions payload from caller", () => {
|
||||
const body = buildViewCommitBody({
|
||||
nodes: [{ id: "fab-a" }],
|
||||
edges: [],
|
||||
serverNodeIds: ["fab-a"],
|
||||
positions: [{ fabric_node_id: "fab-a", x: 1, y: 2, label: "CUSTOM" }],
|
||||
pendingCreates: [],
|
||||
pendingDeletes: [],
|
||||
});
|
||||
assert.equal(body.positions[0].label, "CUSTOM");
|
||||
});
|
||||
|
||||
test("deleted then re-created edge is not in edge_deletes", () => {
|
||||
const body = buildViewCommitBody({
|
||||
nodes: [{ id: "a" }, { id: "b" }],
|
||||
edges: [
|
||||
{
|
||||
id: "local:1",
|
||||
source: "a",
|
||||
target: "b",
|
||||
data: { source_port: "gi0/1", target_port: "gi0/2" },
|
||||
},
|
||||
],
|
||||
serverNodeIds: ["a", "b"],
|
||||
positions: [],
|
||||
pendingCreates: ["local:1"],
|
||||
pendingDeletes: ["edge-server-1", "local:1"],
|
||||
});
|
||||
assert.deepEqual(body.edge_deletes, ["edge-server-1"]);
|
||||
assert.equal(body.edge_creates.length, 1);
|
||||
assert.equal(body.edge_creates[0].client_id, "local:1");
|
||||
assert.equal(body.edge_creates[0].a_port, "gi0/1");
|
||||
});
|
||||
|
||||
test("undo after delete removes edge from edge_deletes when not pending", () => {
|
||||
const body = buildViewCommitBody({
|
||||
nodes: [{ id: "a" }, { id: "b" }],
|
||||
edges: [{ id: "edge-server-1", source: "a", target: "b" }],
|
||||
serverNodeIds: ["a", "b"],
|
||||
positions: [],
|
||||
pendingCreates: [],
|
||||
pendingDeletes: [],
|
||||
});
|
||||
assert.deepEqual(body.edge_deletes, []);
|
||||
assert.deepEqual(body.edge_creates, []);
|
||||
});
|
||||
|
||||
test("styled or port-edited edge that is then deleted only appears in edge_deletes", () => {
|
||||
const style = { stroke_color: "#ff0000", stroke_width: 2, line_style: "dashed" };
|
||||
const body = buildViewCommitBody({
|
||||
nodes: [{ id: "a" }, { id: "b" }],
|
||||
edges: [],
|
||||
serverNodeIds: ["a", "b"],
|
||||
positions: [],
|
||||
pendingCreates: [],
|
||||
pendingDeletes: ["edge-1"],
|
||||
pendingPortEdits: [["edge-1", { a_port: "gi0/1", b_port: "gi0/2" }]],
|
||||
pendingStyles: [["edge-1", style]],
|
||||
});
|
||||
assert.deepEqual(body.edge_deletes, ["edge-1"]);
|
||||
assert.deepEqual(body.edge_port_edits, []);
|
||||
assert.deepEqual(body.edge_styles, []);
|
||||
});
|
||||
|
||||
test("style on a new local edge is sent under its client id; dropped once the edge is gone", () => {
|
||||
const style = { stroke_color: "#00ff00", stroke_width: 3, line_style: "" };
|
||||
const edges = [{ id: "local:9", source: "a", target: "b", data: {} }];
|
||||
const kept = buildViewCommitBody({
|
||||
nodes: [{ id: "a" }, { id: "b" }],
|
||||
edges,
|
||||
serverNodeIds: ["a", "b"],
|
||||
positions: [],
|
||||
pendingCreates: ["local:9"],
|
||||
pendingDeletes: [],
|
||||
pendingStyles: [["local:9", style]],
|
||||
});
|
||||
assert.deepEqual(kept.edge_styles.map((s) => s.fabric_edge_id), ["local:9"]);
|
||||
const dropped = buildViewCommitBody({
|
||||
nodes: [{ id: "a" }, { id: "b" }],
|
||||
edges: [],
|
||||
serverNodeIds: ["a", "b"],
|
||||
positions: [],
|
||||
pendingCreates: [],
|
||||
pendingDeletes: [],
|
||||
pendingStyles: [["local:9", style]],
|
||||
});
|
||||
assert.deepEqual(dropped.edge_styles, []);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue