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:
oliver 2026-10-12 22:11:22 +08:00
parent 60a0ea6021
commit f301544b99
40 changed files with 5587 additions and 1916 deletions

File diff suppressed because it is too large Load diff

View file

@ -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) =>

View 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>
);
}

View file

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

View file

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

View file

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

View file

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

View 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,
};
}

View 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 };
}

View 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,
};
}

View file

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

View 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]);
}

View 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 };
}

View 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,
};
}

View 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,
};
}

View 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;
}

View file

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

View 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"));
}

View file

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

View file

@ -0,0 +1,2 @@
/** Loose bag for TopologyPage → TopologyPageView wiring (pure layout split). */
export type TopologyPageViewModel = Record<string, any>;

View 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,
};
}

View file

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

View file

@ -112,9 +112,12 @@ const topology = {
save: "保存",
saveDirty: "保存 *",
unsavedConfirm: "有未保存的更改,确定丢弃并切换吗?",
unsavedSaveBeforeAdd: "当前有未保存修改,保存后继续添加?",
saving: "保存中…",
saved: "拓扑已保存",
savedLockedSkipped: "拓扑已保存({{count}} 个锁定节点未移动)",
saveFailed: "保存失败:{{detail}}",
renameNodeClearHint: "留空则恢复跟随设备名",
readOnlyBanner: "只读模式(需要 ne:write 权限才能编辑)",
readOnlyHint: "只读模式,无编辑权限",
discoverTimeout: "发现任务超时,请稍后在 LLDP 任务页查看进度",

View file

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

View file

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

View file

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