mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Add topology situational overlays and harden live sync.
Show connect/alarm/level on the canvas with display toggles, refresh overlay fields even on dirty edits, fix connect-test dots and create-place messaging, and deep-link node alarms into the network alarms page. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
56569e49d1
commit
497a5e048e
19 changed files with 814 additions and 66 deletions
|
|
@ -291,6 +291,9 @@ class ViewNodeOut(BaseModel):
|
||||||
device_type: str = ""
|
device_type: str = ""
|
||||||
connect_status: str = ""
|
connect_status: str = ""
|
||||||
managed_source: str = "" # manual | ume_sync | lldp | topology | webcrt | …
|
managed_source: str = "" # manual | ume_sync | lldp | topology | webcrt | …
|
||||||
|
# Fabric classify: smaller level = closer to external/WAN; role synced from floor(level).
|
||||||
|
level: float | None = None
|
||||||
|
role: str = ""
|
||||||
# ne | region — region nodes drill into child UME canvases
|
# ne | region — region nodes drill into child UME canvases
|
||||||
kind: str = "ne"
|
kind: str = "ne"
|
||||||
folder_id: str = ""
|
folder_id: str = ""
|
||||||
|
|
|
||||||
|
|
@ -197,6 +197,8 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
||||||
label = (fn.name or fn.ip or vn.fabric_node_id)[:256]
|
label = (fn.name or fn.ip or vn.fabric_node_id)[:256]
|
||||||
connect_status = ""
|
connect_status = ""
|
||||||
managed_source = ""
|
managed_source = ""
|
||||||
|
level: float | None = None
|
||||||
|
role = ""
|
||||||
if fn is not None:
|
if fn is not None:
|
||||||
mid = str(fn.managed_ne_id or "").strip()
|
mid = str(fn.managed_ne_id or "").strip()
|
||||||
uid = str(fn.ume_ne_id or "").strip()
|
uid = str(fn.ume_ne_id or "").strip()
|
||||||
|
|
@ -206,6 +208,8 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
||||||
managed_source = str(mne.source or "").strip()
|
managed_source = str(mne.source or "").strip()
|
||||||
elif uid and uid in ume_by_id:
|
elif uid and uid in ume_by_id:
|
||||||
connect_status = ume_by_id[uid].connection_status or ""
|
connect_status = ume_by_id[uid].connection_status or ""
|
||||||
|
level = fn.level
|
||||||
|
role = str(fn.role or "").strip()
|
||||||
nodes_out.append(
|
nodes_out.append(
|
||||||
ViewNodeOut(
|
ViewNodeOut(
|
||||||
fabric_node_id=vn.fabric_node_id,
|
fabric_node_id=vn.fabric_node_id,
|
||||||
|
|
@ -221,6 +225,8 @@ def get_view_graph(db: Session, view_id: str) -> TopologyViewGraphOut:
|
||||||
device_type=(fn.device_type if fn else "") or "",
|
device_type=(fn.device_type if fn else "") or "",
|
||||||
connect_status=connect_status,
|
connect_status=connect_status,
|
||||||
managed_source=managed_source,
|
managed_source=managed_source,
|
||||||
|
level=level,
|
||||||
|
role=role,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
# Child regions without a placement row still appear (legacy / missed create).
|
# Child regions without a placement row still appear (legacy / missed create).
|
||||||
|
|
|
||||||
|
|
@ -1635,6 +1635,8 @@ const en = {
|
||||||
removeSelected: "Remove selected ({{count}})",
|
removeSelected: "Remove selected ({{count}})",
|
||||||
openWebcrt: "Open terminal",
|
openWebcrt: "Open terminal",
|
||||||
openNe: "NE details",
|
openNe: "NE details",
|
||||||
|
openAlarms: "View alarms",
|
||||||
|
noAlarmLink: "Cannot build an alarm filter for this node",
|
||||||
removeNode: "Remove from canvas",
|
removeNode: "Remove from canvas",
|
||||||
deletePlaceholder: "Delete placeholder",
|
deletePlaceholder: "Delete placeholder",
|
||||||
deletePlaceholderConfirm:
|
deletePlaceholderConfirm:
|
||||||
|
|
@ -1661,6 +1663,10 @@ const en = {
|
||||||
createNeIpPh: "Can fill later during setup",
|
createNeIpPh: "Can fill later during setup",
|
||||||
createNeDone: "Created placeholder {{name}}",
|
createNeDone: "Created placeholder {{name}}",
|
||||||
createManagedNeDone: "Created and placed {{name}}",
|
createManagedNeDone: "Created and placed {{name}}",
|
||||||
|
createManagedNePlaceFailed:
|
||||||
|
"{{name}} was created in inventory, but placing on the map failed: {{error}}. Add it again from NE management or the sidebar.",
|
||||||
|
createManagedNePosFailed:
|
||||||
|
"{{name}} is on the map, but could not move to the click position (kept default layout coords)",
|
||||||
createNeBusy: "Creating…",
|
createNeBusy: "Creating…",
|
||||||
connectTest: "Connectivity test",
|
connectTest: "Connectivity test",
|
||||||
connectTestSelected: "Connectivity test ({{count}})",
|
connectTestSelected: "Connectivity test ({{count}})",
|
||||||
|
|
@ -1723,6 +1729,17 @@ const en = {
|
||||||
hideIp: "Hide IP",
|
hideIp: "Hide IP",
|
||||||
hideVendor: "Hide vendor",
|
hideVendor: "Hide vendor",
|
||||||
showPlaceholderBadge: "Show placeholder badge (TOPO/LLDP)",
|
showPlaceholderBadge: "Show placeholder badge (TOPO/LLDP)",
|
||||||
|
showConnectStatus: "Show connect-status dots",
|
||||||
|
showAlarmOverlay: "Show alarm overlay",
|
||||||
|
showLevelColors: "Color by classify level",
|
||||||
|
levelUnmatched: "Unclassified",
|
||||||
|
level: {
|
||||||
|
external: "External",
|
||||||
|
core: "Core",
|
||||||
|
aggregation: "Aggregation",
|
||||||
|
access: "Access",
|
||||||
|
edge: "Edge",
|
||||||
|
},
|
||||||
hidePorts: "Hide ports",
|
hidePorts: "Hide ports",
|
||||||
edgeFlow: "Edge flow",
|
edgeFlow: "Edge flow",
|
||||||
collapseSidebar: "Collapse sidebar",
|
collapseSidebar: "Collapse sidebar",
|
||||||
|
|
|
||||||
|
|
@ -1621,6 +1621,8 @@ const zh = {
|
||||||
removeSelected: "移除选中 ({{count}})",
|
removeSelected: "移除选中 ({{count}})",
|
||||||
openWebcrt: "打开终端",
|
openWebcrt: "打开终端",
|
||||||
openNe: "网元详情",
|
openNe: "网元详情",
|
||||||
|
openAlarms: "查看告警",
|
||||||
|
noAlarmLink: "该节点无法定位告警过滤条件",
|
||||||
removeNode: "从画布移除",
|
removeNode: "从画布移除",
|
||||||
deletePlaceholder: "删除占位网元",
|
deletePlaceholder: "删除占位网元",
|
||||||
deletePlaceholderConfirm: "永久删除此占位网元(含库存记录与 Fabric 直连链路)?画布撤销无法恢复。",
|
deletePlaceholderConfirm: "永久删除此占位网元(含库存记录与 Fabric 直连链路)?画布撤销无法恢复。",
|
||||||
|
|
@ -1644,6 +1646,9 @@ const zh = {
|
||||||
createNeIpPh: "可稍后在完善时填写",
|
createNeIpPh: "可稍后在完善时填写",
|
||||||
createNeDone: "已新建占位网元 {{name}}",
|
createNeDone: "已新建占位网元 {{name}}",
|
||||||
createManagedNeDone: "已创建网元并上图 {{name}}",
|
createManagedNeDone: "已创建网元并上图 {{name}}",
|
||||||
|
createManagedNePlaceFailed:
|
||||||
|
"网元 {{name}} 已写入库存,但上图失败:{{error}}。请从网元管理或侧栏再次添加。",
|
||||||
|
createManagedNePosFailed: "网元 {{name}} 已上图,但未能移到点击位置(保留默认布局坐标)",
|
||||||
createNeBusy: "创建中…",
|
createNeBusy: "创建中…",
|
||||||
connectTest: "连通性测试",
|
connectTest: "连通性测试",
|
||||||
connectTestSelected: "连通性测试 ({{count}})",
|
connectTestSelected: "连通性测试 ({{count}})",
|
||||||
|
|
@ -1706,6 +1711,17 @@ const zh = {
|
||||||
hideIp: "隐藏 IP",
|
hideIp: "隐藏 IP",
|
||||||
hideVendor: "隐藏厂商",
|
hideVendor: "隐藏厂商",
|
||||||
showPlaceholderBadge: "显示占位角标(TOPO/LLDP)",
|
showPlaceholderBadge: "显示占位角标(TOPO/LLDP)",
|
||||||
|
showConnectStatus: "显示连通状态点",
|
||||||
|
showAlarmOverlay: "显示告警叠层",
|
||||||
|
showLevelColors: "按分类层级着色",
|
||||||
|
levelUnmatched: "未分类",
|
||||||
|
level: {
|
||||||
|
external: "外部",
|
||||||
|
core: "核心",
|
||||||
|
aggregation: "汇聚",
|
||||||
|
access: "接入",
|
||||||
|
edge: "边缘",
|
||||||
|
},
|
||||||
hidePorts: "隐藏端口",
|
hidePorts: "隐藏端口",
|
||||||
edgeFlow: "链路流动",
|
edgeFlow: "链路流动",
|
||||||
collapseSidebar: "折叠侧栏",
|
collapseSidebar: "折叠侧栏",
|
||||||
|
|
|
||||||
|
|
@ -7603,6 +7603,109 @@ pre {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.topo-node__conn {
|
||||||
|
position: absolute;
|
||||||
|
left: -2px;
|
||||||
|
bottom: -2px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 1.5px solid rgba(15, 23, 42, 0.9);
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.25);
|
||||||
|
}
|
||||||
|
.topo-node__conn--pass {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
.topo-node__conn--fail {
|
||||||
|
background: #ef4444;
|
||||||
|
}
|
||||||
|
.topo-node__conn--testing {
|
||||||
|
background: #f59e0b;
|
||||||
|
}
|
||||||
|
.topo-node__conn--unknown {
|
||||||
|
background: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__alarm {
|
||||||
|
position: absolute;
|
||||||
|
top: -4px;
|
||||||
|
right: -5px;
|
||||||
|
z-index: 3;
|
||||||
|
min-width: 14px;
|
||||||
|
padding: 0 3px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: #fff;
|
||||||
|
pointer-events: none;
|
||||||
|
border: 1px solid rgba(15, 23, 42, 0.75);
|
||||||
|
}
|
||||||
|
.topo-node__alarm--critical {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
.topo-node__alarm--major {
|
||||||
|
background: #ea580c;
|
||||||
|
}
|
||||||
|
.topo-node__alarm--minor {
|
||||||
|
background: #ca8a04;
|
||||||
|
}
|
||||||
|
.topo-node__alarm--warning,
|
||||||
|
.topo-node__alarm--indeterminate,
|
||||||
|
.topo-node__alarm--unknown {
|
||||||
|
background: #d97706;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node__unmatched {
|
||||||
|
position: absolute;
|
||||||
|
top: -3px;
|
||||||
|
right: -4px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 12px;
|
||||||
|
height: 12px;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(51, 65, 85, 0.95);
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.55);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 8px;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node.is-level-external .topo-node__glyph {
|
||||||
|
box-shadow: inset 0 0 0 2px #38bdf8;
|
||||||
|
}
|
||||||
|
.topo-node.is-level-core .topo-node__glyph {
|
||||||
|
box-shadow: inset 0 0 0 2px #a78bfa;
|
||||||
|
}
|
||||||
|
.topo-node.is-level-aggregation .topo-node__glyph {
|
||||||
|
box-shadow: inset 0 0 0 2px #34d399;
|
||||||
|
}
|
||||||
|
.topo-node.is-level-access .topo-node__glyph {
|
||||||
|
box-shadow: inset 0 0 0 2px #fbbf24;
|
||||||
|
}
|
||||||
|
.topo-node.is-level-edge .topo-node__glyph {
|
||||||
|
box-shadow: inset 0 0 0 2px #fb7185;
|
||||||
|
}
|
||||||
|
.topo-node.is-level-unmatched .topo-node__glyph {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(148, 163, 184, 0.55);
|
||||||
|
}
|
||||||
|
|
||||||
|
.topo-node.has-alarm--critical .topo-node__glyph {
|
||||||
|
outline: 2px solid rgba(220, 38, 38, 0.85);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
.topo-node.has-alarm--major .topo-node__glyph {
|
||||||
|
outline: 2px solid rgba(234, 88, 12, 0.85);
|
||||||
|
outline-offset: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
.topo-truncate-banner {
|
.topo-truncate-banner {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 8px;
|
top: 8px;
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ import {
|
||||||
SCALE_BUNDLE_WIDTH_KEY,
|
SCALE_BUNDLE_WIDTH_KEY,
|
||||||
SEP,
|
SEP,
|
||||||
SHOW_PLACEHOLDER_BADGE_KEY,
|
SHOW_PLACEHOLDER_BADGE_KEY,
|
||||||
|
SHOW_CONNECT_STATUS_KEY,
|
||||||
|
SHOW_ALARM_OVERLAY_KEY,
|
||||||
|
SHOW_LEVEL_COLORS_KEY,
|
||||||
WORLD_MAP_ENABLED,
|
WORLD_MAP_ENABLED,
|
||||||
} from "./topology/constants";
|
} from "./topology/constants";
|
||||||
import type { CtxMenu, PaletteItem, PaletteSource } from "./topology/pageTypes";
|
import type { CtxMenu, PaletteItem, PaletteSource } from "./topology/pageTypes";
|
||||||
|
|
@ -53,6 +56,7 @@ import { useTopologyTreeNav } from "./topology/hooks/useTopologyTreeNav";
|
||||||
import { useTopologyCanvas } from "./topology/hooks/useTopologyCanvas";
|
import { useTopologyCanvas } from "./topology/hooks/useTopologyCanvas";
|
||||||
import { useTopologyCreateNe } from "./topology/hooks/useTopologyCreateNe";
|
import { useTopologyCreateNe } from "./topology/hooks/useTopologyCreateNe";
|
||||||
import { useTopologyConnectTest } from "./topology/hooks/useTopologyConnectTest";
|
import { useTopologyConnectTest } from "./topology/hooks/useTopologyConnectTest";
|
||||||
|
import { useTopologyAlarmOverlay } from "./topology/hooks/useTopologyAlarmOverlay";
|
||||||
import {
|
import {
|
||||||
lazy,
|
lazy,
|
||||||
Suspense,
|
Suspense,
|
||||||
|
|
@ -139,6 +143,15 @@ export function TopologyPage() {
|
||||||
const [showPlaceholderBadge, setShowPlaceholderBadge] = useState(() =>
|
const [showPlaceholderBadge, setShowPlaceholderBadge] = useState(() =>
|
||||||
loadBoolFlag(SHOW_PLACEHOLDER_BADGE_KEY, false),
|
loadBoolFlag(SHOW_PLACEHOLDER_BADGE_KEY, false),
|
||||||
);
|
);
|
||||||
|
const [showConnectStatus, setShowConnectStatus] = useState(() =>
|
||||||
|
loadBoolFlag(SHOW_CONNECT_STATUS_KEY, true),
|
||||||
|
);
|
||||||
|
const [showAlarmOverlay, setShowAlarmOverlay] = useState(() =>
|
||||||
|
loadBoolFlag(SHOW_ALARM_OVERLAY_KEY, true),
|
||||||
|
);
|
||||||
|
const [showLevelColors, setShowLevelColors] = useState(() =>
|
||||||
|
loadBoolFlag(SHOW_LEVEL_COLORS_KEY, true),
|
||||||
|
);
|
||||||
const [edgeFlow, setEdgeFlow] = useState(false);
|
const [edgeFlow, setEdgeFlow] = useState(false);
|
||||||
const [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults());
|
const [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults());
|
||||||
const [canvasBg, setCanvasBg] = useState(loadCanvasBg);
|
const [canvasBg, setCanvasBg] = useState(loadCanvasBg);
|
||||||
|
|
@ -317,6 +330,9 @@ export function TopologyPage() {
|
||||||
hideVendor,
|
hideVendor,
|
||||||
hidePorts,
|
hidePorts,
|
||||||
showPlaceholderBadge,
|
showPlaceholderBadge,
|
||||||
|
showConnectStatus,
|
||||||
|
showAlarmOverlay,
|
||||||
|
showLevelColors,
|
||||||
expandPhysicalLinks,
|
expandPhysicalLinks,
|
||||||
scaleBundleWidth,
|
scaleBundleWidth,
|
||||||
edgeFlow,
|
edgeFlow,
|
||||||
|
|
@ -728,6 +744,11 @@ export function TopologyPage() {
|
||||||
const selectedNodes = useMemo(() => nodes.filter((n) => n.selected), [nodes]);
|
const selectedNodes = useMemo(() => nodes.filter((n) => n.selected), [nodes]);
|
||||||
const selectedNode = selectedNodes[0] || null;
|
const selectedNode = selectedNodes[0] || null;
|
||||||
const selectedNodeIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]);
|
const selectedNodeIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]);
|
||||||
|
|
||||||
|
const { alarmByNodeId } = useTopologyAlarmOverlay({
|
||||||
|
enabled: canvasMode && showAlarmOverlay && !isWorldFlatCanvas,
|
||||||
|
nodes,
|
||||||
|
});
|
||||||
const selectedEdge = useMemo(() => {
|
const selectedEdge = useMemo(() => {
|
||||||
if (!selectedEdgeId) return null;
|
if (!selectedEdgeId) return null;
|
||||||
return (
|
return (
|
||||||
|
|
@ -844,7 +865,7 @@ export function TopologyPage() {
|
||||||
onManagedFormSaved,
|
onManagedFormSaved,
|
||||||
} = createNe;
|
} = createNe;
|
||||||
|
|
||||||
const connectTest = useTopologyConnectTest({ closeCtxMenu });
|
const connectTest = useTopologyConnectTest({ closeCtxMenu, mapId, setNodes });
|
||||||
const {
|
const {
|
||||||
connectDetailRow,
|
connectDetailRow,
|
||||||
connectTestSubmitting,
|
connectTestSubmitting,
|
||||||
|
|
@ -1387,6 +1408,22 @@ export function TopologyPage() {
|
||||||
showError(t("topology.noNeLink"));
|
showError(t("topology.noNeLink"));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openAlarmsFor = (node: Node<NeNodeData> | null) => {
|
||||||
|
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()}`,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const openPortTrafficForEdge = (edge: Edge | null) => {
|
const openPortTrafficForEdge = (edge: Edge | null) => {
|
||||||
closeCtxMenu();
|
closeCtxMenu();
|
||||||
if (!edge) {
|
if (!edge) {
|
||||||
|
|
@ -2104,6 +2141,12 @@ export function TopologyPage() {
|
||||||
onHideVendorChange={setHideVendor}
|
onHideVendorChange={setHideVendor}
|
||||||
showPlaceholderBadge={showPlaceholderBadge}
|
showPlaceholderBadge={showPlaceholderBadge}
|
||||||
onShowPlaceholderBadgeChange={setShowPlaceholderBadge}
|
onShowPlaceholderBadgeChange={setShowPlaceholderBadge}
|
||||||
|
showConnectStatus={showConnectStatus}
|
||||||
|
onShowConnectStatusChange={setShowConnectStatus}
|
||||||
|
showAlarmOverlay={showAlarmOverlay}
|
||||||
|
onShowAlarmOverlayChange={setShowAlarmOverlay}
|
||||||
|
showLevelColors={showLevelColors}
|
||||||
|
onShowLevelColorsChange={setShowLevelColors}
|
||||||
hidePorts={hidePorts}
|
hidePorts={hidePorts}
|
||||||
onHidePortsChange={setHidePorts}
|
onHidePortsChange={setHidePorts}
|
||||||
expandPhysicalLinks={expandPhysicalLinks}
|
expandPhysicalLinks={expandPhysicalLinks}
|
||||||
|
|
@ -2210,6 +2253,7 @@ export function TopologyPage() {
|
||||||
>
|
>
|
||||||
<TopologyReactFlowView
|
<TopologyReactFlowView
|
||||||
displayOpts={displayOpts}
|
displayOpts={displayOpts}
|
||||||
|
alarmByNodeId={alarmByNodeId}
|
||||||
nodes={nodes}
|
nodes={nodes}
|
||||||
setNodes={setNodes}
|
setNodes={setNodes}
|
||||||
edges={edges}
|
edges={edges}
|
||||||
|
|
@ -2450,6 +2494,7 @@ export function TopologyPage() {
|
||||||
onDiscoverOne={discoverOneFor}
|
onDiscoverOne={discoverOneFor}
|
||||||
onOpenWebcrt={openWebcrtFor}
|
onOpenWebcrt={openWebcrtFor}
|
||||||
onOpenNe={openNeFor}
|
onOpenNe={openNeFor}
|
||||||
|
onOpenAlarms={openAlarmsFor}
|
||||||
onConnectTest={runConnectTestForNode}
|
onConnectTest={runConnectTestForNode}
|
||||||
connectTestBusy={connectTestSubmitting}
|
connectTestBusy={connectTestSubmitting}
|
||||||
onPurgePlaceholder={purgePlaceholderById}
|
onPurgePlaceholder={purgePlaceholderById}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import { useSearchParams } from "react-router-dom";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { ListPager } from "../../components/ListPager";
|
import { ListPager } from "../../components/ListPager";
|
||||||
import { queryKeys } from "../../constants/queryKeys";
|
import { queryKeys } from "../../constants/queryKeys";
|
||||||
|
|
@ -25,6 +26,7 @@ function severityClass(sev: string | null | undefined): string {
|
||||||
export function NetworkAlarmsPage() {
|
export function NetworkAlarmsPage() {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showOk, showError } = useToast();
|
const { showOk, showError } = useToast();
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
const [curSeverity, setCurSeverity] = useState("");
|
const [curSeverity, setCurSeverity] = useState("");
|
||||||
const [curCleared, setCurCleared] = useState("");
|
const [curCleared, setCurCleared] = useState("");
|
||||||
const [curHostName, setCurHostName] = useState("");
|
const [curHostName, setCurHostName] = useState("");
|
||||||
|
|
@ -33,6 +35,32 @@ export function NetworkAlarmsPage() {
|
||||||
const [curPageSize, setCurPageSize] = useState(50);
|
const [curPageSize, setCurPageSize] = useState(50);
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
|
||||||
|
// Deep-link from topology (and similar): ?host=&cleared=&severity=&keyword=
|
||||||
|
useEffect(() => {
|
||||||
|
const host = searchParams.get("host");
|
||||||
|
const cleared = searchParams.get("cleared");
|
||||||
|
const severity = searchParams.get("severity");
|
||||||
|
const keyword = searchParams.get("keyword");
|
||||||
|
let touched = false;
|
||||||
|
if (host != null) {
|
||||||
|
setCurHostName(host);
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
if (cleared != null) {
|
||||||
|
setCurCleared(cleared);
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
if (severity != null) {
|
||||||
|
setCurSeverity(severity);
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
if (keyword != null) {
|
||||||
|
setCurKeyword(keyword);
|
||||||
|
touched = true;
|
||||||
|
}
|
||||||
|
if (touched) setCurPage(1);
|
||||||
|
}, [searchParams]);
|
||||||
|
|
||||||
const debouncedKeyword = useDebouncedValue(curKeyword, 300);
|
const debouncedKeyword = useDebouncedValue(curKeyword, 300);
|
||||||
const debouncedHost = useDebouncedValue(curHostName, 300);
|
const debouncedHost = useDebouncedValue(curHostName, 300);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ export type TopologyCtxMenuProps = {
|
||||||
onDiscoverOne: (node: Node<NeNodeData> | null) => void;
|
onDiscoverOne: (node: Node<NeNodeData> | null) => void;
|
||||||
onOpenWebcrt: (node: Node<NeNodeData> | null) => void;
|
onOpenWebcrt: (node: Node<NeNodeData> | null) => void;
|
||||||
onOpenNe: (node: Node<NeNodeData> | null) => void;
|
onOpenNe: (node: Node<NeNodeData> | null) => void;
|
||||||
|
onOpenAlarms: (node: Node<NeNodeData> | null) => void;
|
||||||
onConnectTest: (node: Node<NeNodeData> | null) => void;
|
onConnectTest: (node: Node<NeNodeData> | null) => void;
|
||||||
connectTestBusy?: boolean;
|
connectTestBusy?: boolean;
|
||||||
onPurgePlaceholder: (id: string) => void;
|
onPurgePlaceholder: (id: string) => void;
|
||||||
|
|
@ -86,6 +87,7 @@ export function TopologyCtxMenu({
|
||||||
onDiscoverOne,
|
onDiscoverOne,
|
||||||
onOpenWebcrt,
|
onOpenWebcrt,
|
||||||
onOpenNe,
|
onOpenNe,
|
||||||
|
onOpenAlarms,
|
||||||
onConnectTest,
|
onConnectTest,
|
||||||
connectTestBusy = false,
|
connectTestBusy = false,
|
||||||
onPurgePlaceholder,
|
onPurgePlaceholder,
|
||||||
|
|
@ -277,6 +279,16 @@ export function TopologyCtxMenu({
|
||||||
{t("topology.openNe")}
|
{t("topology.openNe")}
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
|
<li role="none">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="topo-ctx__item"
|
||||||
|
role="menuitem"
|
||||||
|
onClick={() => onOpenAlarms(selectedNode)}
|
||||||
|
>
|
||||||
|
{t("topology.openAlarms")}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
<li role="none">
|
<li role="none">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ import { ParallelEdge } from "./ParallelEdge";
|
||||||
import { isAggregateEdgeId } from "./linkDisplay";
|
import { isAggregateEdgeId } from "./linkDisplay";
|
||||||
import type { ToolMode, ToolModeBehavior } from "./toolMode";
|
import type { ToolMode, ToolModeBehavior } from "./toolMode";
|
||||||
import type { TopologyViewRole, TopologyWorldTransform } from "../../types";
|
import type { TopologyViewRole, TopologyWorldTransform } from "../../types";
|
||||||
|
import { isPlaceholderSource } from "./searchUtils";
|
||||||
|
import { nodeIconTone } from "./vendorTone";
|
||||||
|
import { levelToneKey } from "./levelTone";
|
||||||
|
import type { NodeAlarmSummary } from "./alarmOverlay";
|
||||||
|
|
||||||
|
export { isPlaceholderSource };
|
||||||
|
|
||||||
export type NeNodeData = {
|
export type NeNodeData = {
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -36,6 +42,10 @@ export type NeNodeData = {
|
||||||
vendor: string;
|
vendor: string;
|
||||||
connect_status: string;
|
connect_status: string;
|
||||||
managed_source?: string;
|
managed_source?: string;
|
||||||
|
/** Fabric classify level (from graph). */
|
||||||
|
level?: number | null;
|
||||||
|
/** Fabric role alias (external|core|aggregation|access|edge). */
|
||||||
|
fabric_role?: string;
|
||||||
kind?: "ne" | "region" | "layer";
|
kind?: "ne" | "region" | "layer";
|
||||||
folder_id?: string;
|
folder_id?: string;
|
||||||
view_id?: string;
|
view_id?: string;
|
||||||
|
|
@ -50,6 +60,9 @@ export type TopoDisplayOpts = {
|
||||||
hidePorts: boolean;
|
hidePorts: boolean;
|
||||||
connectMode: boolean;
|
connectMode: boolean;
|
||||||
showPlaceholderBadge: boolean;
|
showPlaceholderBadge: boolean;
|
||||||
|
showConnectStatus: boolean;
|
||||||
|
showAlarmOverlay: boolean;
|
||||||
|
showLevelColors: boolean;
|
||||||
worldVisualLod: "dot" | "pin" | "full";
|
worldVisualLod: "dot" | "pin" | "full";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -59,15 +72,24 @@ const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
||||||
hidePorts: true,
|
hidePorts: true,
|
||||||
connectMode: false,
|
connectMode: false,
|
||||||
showPlaceholderBadge: false,
|
showPlaceholderBadge: false,
|
||||||
|
showConnectStatus: true,
|
||||||
|
showAlarmOverlay: true,
|
||||||
|
showLevelColors: true,
|
||||||
worldVisualLod: "full",
|
worldVisualLod: "full",
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const TopoAlarmContext = createContext<ReadonlyMap<string, NodeAlarmSummary>>(new Map());
|
||||||
|
const emptyAlarmMap: ReadonlyMap<string, NodeAlarmSummary> = new Map();
|
||||||
|
|
||||||
const SEP = " / ";
|
const SEP = " / ";
|
||||||
|
|
||||||
import { isPlaceholderSource } from "./searchUtils";
|
function normalizeConnectStatus(raw: string | undefined): "pass" | "fail" | "testing" | "unknown" {
|
||||||
import { nodeIconTone } from "./vendorTone";
|
const s = String(raw || "").trim().toLowerCase();
|
||||||
|
if (s === "pass" || s === "ok") return "pass";
|
||||||
export { isPlaceholderSource };
|
if (s === "fail" || s === "error") return "fail";
|
||||||
|
if (s === "testing") return "testing";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
function RouterIcon() {
|
function RouterIcon() {
|
||||||
return (
|
return (
|
||||||
|
|
@ -107,13 +129,25 @@ function FullscreenIcon({ exit }: { exit?: boolean }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
|
const NeNode = memo(function NeNode({ id, data, selected }: NodeProps<Node<NeNodeData>>) {
|
||||||
const { hideIp, hideVendor, connectMode, showPlaceholderBadge, worldVisualLod } =
|
const {
|
||||||
useContext(TopoDisplayContext);
|
hideIp,
|
||||||
|
hideVendor,
|
||||||
|
connectMode,
|
||||||
|
showPlaceholderBadge,
|
||||||
|
showConnectStatus,
|
||||||
|
showAlarmOverlay,
|
||||||
|
showLevelColors,
|
||||||
|
worldVisualLod,
|
||||||
|
} = useContext(TopoDisplayContext);
|
||||||
|
const alarmMap = useContext(TopoAlarmContext);
|
||||||
const isRegion = data.kind === "region";
|
const isRegion = data.kind === "region";
|
||||||
const tone = isRegion ? "region" : nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
|
const tone = isRegion ? "region" : nodeIconTone(data.vendor, data.managed_ne_id, data.ume_ne_id);
|
||||||
const placeholder = !isRegion && isPlaceholderSource(data.managed_source, data.ne_ip);
|
const placeholder = !isRegion && isPlaceholderSource(data.managed_source, data.ne_ip);
|
||||||
const showBadge = placeholder && showPlaceholderBadge;
|
const showBadge = placeholder && showPlaceholderBadge;
|
||||||
|
const connect = normalizeConnectStatus(data.connect_status);
|
||||||
|
const levelKey = levelToneKey(data.level, data.fabric_role);
|
||||||
|
const alarm = !isRegion && showAlarmOverlay ? alarmMap.get(id) : undefined;
|
||||||
const name = data.label || (!hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE");
|
const name = data.label || (!hideIp ? data.ne_ip : "") || (isRegion ? "Region" : "NE");
|
||||||
const secondary = isRegion
|
const secondary = isRegion
|
||||||
? []
|
? []
|
||||||
|
|
@ -122,12 +156,22 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
||||||
hideVendor || !data.vendor ? "" : data.vendor,
|
hideVendor || !data.vendor ? "" : data.vendor,
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const statusClass = [
|
||||||
|
`topo-node--${tone}`,
|
||||||
|
selected ? "is-selected" : "",
|
||||||
|
connectMode && !isRegion ? "is-connect-mode" : "",
|
||||||
|
showBadge ? "is-placeholder" : "",
|
||||||
|
isRegion ? "is-region" : "",
|
||||||
|
showConnectStatus && !isRegion ? `is-connect-${connect}` : "",
|
||||||
|
showLevelColors && !isRegion && levelKey ? `is-level-${levelKey}` : "",
|
||||||
|
alarm ? `has-alarm has-alarm--${alarm.severity}` : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
if (!isRegion && worldVisualLod === "dot") {
|
if (!isRegion && worldVisualLod === "dot") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`topo-node topo-node--dot ${statusClass}`} title={name}>
|
||||||
className={`topo-node topo-node--dot topo-node--${tone}${selected ? " is-selected" : ""}`}
|
|
||||||
title={name}
|
|
||||||
>
|
|
||||||
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
|
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
|
||||||
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
|
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
|
||||||
<span className="topo-node__pixel" aria-hidden="true" />
|
<span className="topo-node__pixel" aria-hidden="true" />
|
||||||
|
|
@ -136,10 +180,7 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
||||||
}
|
}
|
||||||
if (!isRegion && worldVisualLod === "pin") {
|
if (!isRegion && worldVisualLod === "pin") {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={`topo-node topo-node--pin ${statusClass}`} title={name}>
|
||||||
className={`topo-node topo-node--pin topo-node--${tone}${selected ? " is-selected" : ""}`}
|
|
||||||
title={name}
|
|
||||||
>
|
|
||||||
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
|
<Handle type="target" position={Position.Left} className="topo-node__handle topo-node__handle--dot" />
|
||||||
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
|
<Handle type="source" position={Position.Right} className="topo-node__handle topo-node__handle--dot" />
|
||||||
<span className="topo-node__pin" aria-hidden="true" />
|
<span className="topo-node__pin" aria-hidden="true" />
|
||||||
|
|
@ -149,10 +190,16 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
|
className={`topo-node ${statusClass}`}
|
||||||
connectMode && !isRegion ? " is-connect-mode" : ""
|
title={
|
||||||
}${showBadge ? " is-placeholder" : ""}${isRegion ? " is-region" : ""}`}
|
alarm
|
||||||
title={isRegion ? name : showBadge ? data.managed_source || "placeholder" : name}
|
? `${name} · alarm ${alarm.severity}×${alarm.count}`
|
||||||
|
: isRegion
|
||||||
|
? name
|
||||||
|
: showBadge
|
||||||
|
? data.managed_source || "placeholder"
|
||||||
|
: name
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div className="topo-node__glyph">
|
<div className="topo-node__glyph">
|
||||||
<Handle
|
<Handle
|
||||||
|
|
@ -168,7 +215,14 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
||||||
isConnectable={connectMode && !isRegion}
|
isConnectable={connectMode && !isRegion}
|
||||||
/>
|
/>
|
||||||
{!isRegion ? <RouterIcon /> : <RegionCanvasIcon />}
|
{!isRegion ? <RouterIcon /> : <RegionCanvasIcon />}
|
||||||
{showBadge ? (
|
{showConnectStatus && !isRegion ? (
|
||||||
|
<span className={`topo-node__conn topo-node__conn--${connect}`} aria-hidden />
|
||||||
|
) : null}
|
||||||
|
{alarm ? (
|
||||||
|
<span className={`topo-node__alarm topo-node__alarm--${alarm.severity}`} aria-hidden>
|
||||||
|
{alarm.count > 99 ? "99+" : alarm.count}
|
||||||
|
</span>
|
||||||
|
) : showBadge ? (
|
||||||
<span className="topo-node__badge" aria-hidden>
|
<span className="topo-node__badge" aria-hidden>
|
||||||
{String(data.managed_source || "ph").slice(0, 4)}
|
{String(data.managed_source || "ph").slice(0, 4)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -249,6 +303,7 @@ type CtxMenuLike =
|
||||||
|
|
||||||
export type TopologyReactFlowViewProps = {
|
export type TopologyReactFlowViewProps = {
|
||||||
displayOpts: TopoDisplayOpts;
|
displayOpts: TopoDisplayOpts;
|
||||||
|
alarmByNodeId?: ReadonlyMap<string, NodeAlarmSummary>;
|
||||||
nodes: Node<NeNodeData>[];
|
nodes: Node<NeNodeData>[];
|
||||||
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>;
|
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>;
|
||||||
edges: Edge[];
|
edges: Edge[];
|
||||||
|
|
@ -297,6 +352,7 @@ export type TopologyReactFlowViewProps = {
|
||||||
export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) {
|
export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) {
|
||||||
const {
|
const {
|
||||||
displayOpts,
|
displayOpts,
|
||||||
|
alarmByNodeId,
|
||||||
nodes,
|
nodes,
|
||||||
setNodes,
|
setNodes,
|
||||||
setEdges,
|
setEdges,
|
||||||
|
|
@ -338,6 +394,7 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TopoDisplayContext.Provider value={displayOpts}>
|
<TopoDisplayContext.Provider value={displayOpts}>
|
||||||
|
<TopoAlarmContext.Provider value={alarmByNodeId || emptyAlarmMap}>
|
||||||
<ReactFlow
|
<ReactFlow
|
||||||
nodes={
|
nodes={
|
||||||
isWorldFlatCanvas && worldVisualLod !== "full"
|
isWorldFlatCanvas && worldVisualLod !== "full"
|
||||||
|
|
@ -513,6 +570,7 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
</ReactFlow>
|
</ReactFlow>
|
||||||
|
</TopoAlarmContext.Provider>
|
||||||
</TopoDisplayContext.Provider>
|
</TopoDisplayContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,9 @@ import {
|
||||||
SCALE_BUNDLE_WIDTH_KEY,
|
SCALE_BUNDLE_WIDTH_KEY,
|
||||||
SEP,
|
SEP,
|
||||||
SHOW_PLACEHOLDER_BADGE_KEY,
|
SHOW_PLACEHOLDER_BADGE_KEY,
|
||||||
|
SHOW_CONNECT_STATUS_KEY,
|
||||||
|
SHOW_ALARM_OVERLAY_KEY,
|
||||||
|
SHOW_LEVEL_COLORS_KEY,
|
||||||
} from "./constants";
|
} from "./constants";
|
||||||
import {
|
import {
|
||||||
DEFAULT_LABEL_COLORS,
|
DEFAULT_LABEL_COLORS,
|
||||||
|
|
@ -47,6 +50,12 @@ export type TopologyViewToolsProps = {
|
||||||
onHideVendorChange: (v: boolean) => void;
|
onHideVendorChange: (v: boolean) => void;
|
||||||
showPlaceholderBadge: boolean;
|
showPlaceholderBadge: boolean;
|
||||||
onShowPlaceholderBadgeChange: (v: boolean) => void;
|
onShowPlaceholderBadgeChange: (v: boolean) => void;
|
||||||
|
showConnectStatus: boolean;
|
||||||
|
onShowConnectStatusChange: (v: boolean) => void;
|
||||||
|
showAlarmOverlay: boolean;
|
||||||
|
onShowAlarmOverlayChange: (v: boolean) => void;
|
||||||
|
showLevelColors: boolean;
|
||||||
|
onShowLevelColorsChange: (v: boolean) => void;
|
||||||
hidePorts: boolean;
|
hidePorts: boolean;
|
||||||
onHidePortsChange: (v: boolean) => void;
|
onHidePortsChange: (v: boolean) => void;
|
||||||
expandPhysicalLinks: boolean;
|
expandPhysicalLinks: boolean;
|
||||||
|
|
@ -101,6 +110,12 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
|
||||||
onHideVendorChange,
|
onHideVendorChange,
|
||||||
showPlaceholderBadge,
|
showPlaceholderBadge,
|
||||||
onShowPlaceholderBadgeChange,
|
onShowPlaceholderBadgeChange,
|
||||||
|
showConnectStatus,
|
||||||
|
onShowConnectStatusChange,
|
||||||
|
showAlarmOverlay,
|
||||||
|
onShowAlarmOverlayChange,
|
||||||
|
showLevelColors,
|
||||||
|
onShowLevelColorsChange,
|
||||||
hidePorts,
|
hidePorts,
|
||||||
onHidePortsChange,
|
onHidePortsChange,
|
||||||
expandPhysicalLinks,
|
expandPhysicalLinks,
|
||||||
|
|
@ -169,6 +184,42 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
|
||||||
/>
|
/>
|
||||||
{t("topology.showPlaceholderBadge")}
|
{t("topology.showPlaceholderBadge")}
|
||||||
</label>
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showConnectStatus}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked;
|
||||||
|
onShowConnectStatusChange(next);
|
||||||
|
persistBoolFlag(SHOW_CONNECT_STATUS_KEY, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("topology.showConnectStatus")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showAlarmOverlay}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked;
|
||||||
|
onShowAlarmOverlayChange(next);
|
||||||
|
persistBoolFlag(SHOW_ALARM_OVERLAY_KEY, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("topology.showAlarmOverlay")}
|
||||||
|
</label>
|
||||||
|
<label className="topo-display-toggles__item">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={showLevelColors}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = e.target.checked;
|
||||||
|
onShowLevelColorsChange(next);
|
||||||
|
persistBoolFlag(SHOW_LEVEL_COLORS_KEY, next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t("topology.showLevelColors")}
|
||||||
|
</label>
|
||||||
<label className="topo-display-toggles__item">
|
<label className="topo-display-toggles__item">
|
||||||
<input type="checkbox" checked={hidePorts} onChange={(e) => onHidePortsChange(e.target.checked)} />
|
<input type="checkbox" checked={hidePorts} onChange={(e) => onHidePortsChange(e.target.checked)} />
|
||||||
{t("topology.hidePorts")}
|
{t("topology.hidePorts")}
|
||||||
|
|
|
||||||
73
web/src/pages/topology/alarmOverlay.ts
Normal file
73
web/src/pages/topology/alarmOverlay.ts
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
/** Alarm severity helpers for topology overlay (UME current alarms). */
|
||||||
|
|
||||||
|
export type AlarmSeverity =
|
||||||
|
| "critical"
|
||||||
|
| "major"
|
||||||
|
| "minor"
|
||||||
|
| "warning"
|
||||||
|
| "indeterminate"
|
||||||
|
| "cleared"
|
||||||
|
| "unknown";
|
||||||
|
|
||||||
|
export type NodeAlarmSummary = {
|
||||||
|
severity: AlarmSeverity;
|
||||||
|
count: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
const SEV_RANK: Record<string, number> = {
|
||||||
|
critical: 60,
|
||||||
|
major: 50,
|
||||||
|
minor: 40,
|
||||||
|
warning: 30,
|
||||||
|
indeterminate: 20,
|
||||||
|
unknown: 10,
|
||||||
|
cleared: 0,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function normalizeAlarmSeverity(raw: string | undefined | null): AlarmSeverity {
|
||||||
|
const s = String(raw || "").trim().toLowerCase();
|
||||||
|
if (!s) return "unknown";
|
||||||
|
if (s.includes("critical") || s === "5") return "critical";
|
||||||
|
if (s.includes("major") || s === "4") return "major";
|
||||||
|
if (s.includes("minor") || s === "3") return "minor";
|
||||||
|
if (s.includes("warning") || s.includes("warn") || s === "2") return "warning";
|
||||||
|
if (s.includes("indeterminate") || s === "1") return "indeterminate";
|
||||||
|
if (s.includes("clear") || s === "0") return "cleared";
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function severityRank(sev: string): number {
|
||||||
|
return SEV_RANK[normalizeAlarmSeverity(sev)] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function worseSeverity(a: string, b: string): AlarmSeverity {
|
||||||
|
return severityRank(a) >= severityRank(b) ? normalizeAlarmSeverity(a) : normalizeAlarmSeverity(b);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function normalizeHostKey(value: string | undefined | null): string {
|
||||||
|
return String(value || "")
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/\s+/g, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** IPv4 / IPv6-ish — safe for exact alarm join. */
|
||||||
|
export function looksLikeIp(value: string | undefined | null): boolean {
|
||||||
|
const s = normalizeHostKey(value);
|
||||||
|
if (!s) return false;
|
||||||
|
if (/^\d{1,3}(\.\d{1,3}){3}$/.test(s)) return true;
|
||||||
|
// Compact check: colon + hex/digits (avoid matching short names).
|
||||||
|
return s.includes(":") && /^[0-9a-f:%.]+$/i.test(s) && s.length >= 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Host/name keys used for alarm overlay join.
|
||||||
|
* Reject short bare labels (e.g. "r1") that collide across sites.
|
||||||
|
*/
|
||||||
|
export function isStrongHostKey(value: string | undefined | null): boolean {
|
||||||
|
const k = normalizeHostKey(value);
|
||||||
|
if (!k) return false;
|
||||||
|
if (looksLikeIp(k)) return true;
|
||||||
|
if (k.includes(".")) return true; // FQDN / dotted inventory name
|
||||||
|
return k.length >= 8;
|
||||||
|
}
|
||||||
|
|
@ -23,6 +23,9 @@ export const DISCOVER_AUTO_ADD_KEY = "netx.topology.discoverAutoAddUnmatched.v2"
|
||||||
export const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors.v2";
|
export const DISCOVER_PROJECT_NEIGHBORS_KEY = "netx.topology.discoverProjectNeighbors.v2";
|
||||||
export const SCALE_BUNDLE_WIDTH_KEY = "netx.topology.scaleBundleWidth";
|
export const SCALE_BUNDLE_WIDTH_KEY = "netx.topology.scaleBundleWidth";
|
||||||
export const SHOW_PLACEHOLDER_BADGE_KEY = "netx.topology.showPlaceholderBadge";
|
export const SHOW_PLACEHOLDER_BADGE_KEY = "netx.topology.showPlaceholderBadge";
|
||||||
|
export const SHOW_CONNECT_STATUS_KEY = "netx.topology.showConnectStatus";
|
||||||
|
export const SHOW_ALARM_OVERLAY_KEY = "netx.topology.showAlarmOverlay";
|
||||||
|
export const SHOW_LEVEL_COLORS_KEY = "netx.topology.showLevelColors";
|
||||||
export const CANVAS_BG_KEY = "netx.topology.canvasBg";
|
export const CANVAS_BG_KEY = "netx.topology.canvasBg";
|
||||||
export const DEFAULT_CANVAS_BG = "#0f172a";
|
export const DEFAULT_CANVAS_BG = "#0f172a";
|
||||||
export const LEGACY_CANVAS_BG = "#dbeafe";
|
export const LEGACY_CANVAS_BG = "#dbeafe";
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,8 @@ export function graphToFlow(
|
||||||
vendor: n.vendor || "",
|
vendor: n.vendor || "",
|
||||||
connect_status: n.connect_status || "",
|
connect_status: n.connect_status || "",
|
||||||
managed_source: n.managed_source || "",
|
managed_source: n.managed_source || "",
|
||||||
|
level: n.level ?? null,
|
||||||
|
fabric_role: n.role || "",
|
||||||
kind: (n.kind as NeNodeData["kind"]) || (isRegion ? "region" : "ne"),
|
kind: (n.kind as NeNodeData["kind"]) || (isRegion ? "region" : "ne"),
|
||||||
folder_id: n.folder_id || "",
|
folder_id: n.folder_id || "",
|
||||||
view_id: n.view_id || "",
|
view_id: n.view_id || "",
|
||||||
|
|
@ -104,7 +106,7 @@ export function graphFingerprint(graph: TopologyViewGraph): string {
|
||||||
const nodes = [...graph.nodes]
|
const nodes = [...graph.nodes]
|
||||||
.map(
|
.map(
|
||||||
(n) =>
|
(n) =>
|
||||||
`${n.fabric_node_id}:${Math.round(Number(n.x) || 0)}:${Math.round(Number(n.y) || 0)}:${n.label || n.name || ""}`,
|
`${n.fabric_node_id}:${Math.round(Number(n.x) || 0)}:${Math.round(Number(n.y) || 0)}:${n.label || n.name || ""}:${n.connect_status || ""}:${n.level ?? ""}:${n.role || ""}`,
|
||||||
)
|
)
|
||||||
.sort()
|
.sort()
|
||||||
.join("|");
|
.join("|");
|
||||||
|
|
@ -115,6 +117,58 @@ export function graphFingerprint(graph: TopologyViewGraph): string {
|
||||||
return `${nodes}#${edges}#${graph.outside_peers?.length || 0}#${graph.world_transform?.lod || ""}#${graph.scatter?.length || 0}#${graph.world_transform?.total || 0}`;
|
return `${nodes}#${edges}#${graph.outside_peers?.length || 0}#${graph.world_transform?.lod || ""}#${graph.scatter?.length || 0}#${graph.world_transform?.total || 0}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Overlay / inventory fields safe to refresh while the canvas has unsaved geometry edits. */
|
||||||
|
export function overlayFingerprint(graph: TopologyViewGraph): string {
|
||||||
|
return [...graph.nodes]
|
||||||
|
.map(
|
||||||
|
(n) =>
|
||||||
|
`${n.fabric_node_id}:${n.connect_status || ""}:${n.level ?? ""}:${n.role || ""}:${n.ume_ne_id || ""}:${n.managed_ne_id || ""}:${n.ip || ""}:${n.vendor || ""}:${n.managed_source || ""}`,
|
||||||
|
)
|
||||||
|
.sort()
|
||||||
|
.join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Patch live overlay fields onto existing RF nodes without touching positions/selection.
|
||||||
|
* Used when dirty lock blocks a full graph apply.
|
||||||
|
*/
|
||||||
|
export function mergeOverlayIntoNodes(
|
||||||
|
current: Node<NeNodeData>[],
|
||||||
|
graph: TopologyViewGraph,
|
||||||
|
): { nodes: Node<NeNodeData>[]; changed: boolean } {
|
||||||
|
const byId = new Map(graph.nodes.map((n) => [n.fabric_node_id, n]));
|
||||||
|
let changed = false;
|
||||||
|
const nodes = current.map((node) => {
|
||||||
|
const g = byId.get(node.id);
|
||||||
|
if (!g || node.data.kind === "region") return node;
|
||||||
|
const next = {
|
||||||
|
connect_status: g.connect_status || "",
|
||||||
|
level: g.level ?? null,
|
||||||
|
fabric_role: g.role || "",
|
||||||
|
ume_ne_id: g.ume_ne_id || "",
|
||||||
|
managed_ne_id: g.managed_ne_id || "",
|
||||||
|
ne_ip: g.ip || "",
|
||||||
|
vendor: g.vendor || "",
|
||||||
|
managed_source: g.managed_source || "",
|
||||||
|
};
|
||||||
|
if (
|
||||||
|
node.data.connect_status === next.connect_status &&
|
||||||
|
(node.data.level ?? null) === next.level &&
|
||||||
|
(node.data.fabric_role || "") === next.fabric_role &&
|
||||||
|
(node.data.ume_ne_id || "") === next.ume_ne_id &&
|
||||||
|
(node.data.managed_ne_id || "") === next.managed_ne_id &&
|
||||||
|
(node.data.ne_ip || "") === next.ne_ip &&
|
||||||
|
(node.data.vendor || "") === next.vendor &&
|
||||||
|
(node.data.managed_source || "") === next.managed_source
|
||||||
|
) {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
changed = true;
|
||||||
|
return { ...node, data: { ...node.data, ...next } };
|
||||||
|
});
|
||||||
|
return { nodes, changed };
|
||||||
|
}
|
||||||
|
|
||||||
export function applyViewGraph(
|
export function applyViewGraph(
|
||||||
graph: TopologyViewGraph,
|
graph: TopologyViewGraph,
|
||||||
defaults: EdgeDefaults,
|
defaults: EdgeDefaults,
|
||||||
|
|
|
||||||
131
web/src/pages/topology/hooks/useTopologyAlarmOverlay.ts
Normal file
131
web/src/pages/topology/hooks/useTopologyAlarmOverlay.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import type { Node } from "@xyflow/react";
|
||||||
|
import { fetchUmeCurrentAlarms } from "../../../services/api";
|
||||||
|
import { queryKeys } from "../../../constants/queryKeys";
|
||||||
|
import type { UmeAlarmItem } from "../../../types";
|
||||||
|
import { fetchAllPages } from "../../../utils/csvExport";
|
||||||
|
import {
|
||||||
|
isStrongHostKey,
|
||||||
|
looksLikeIp,
|
||||||
|
normalizeAlarmSeverity,
|
||||||
|
normalizeHostKey,
|
||||||
|
worseSeverity,
|
||||||
|
type NodeAlarmSummary,
|
||||||
|
} from "../alarmOverlay";
|
||||||
|
import type { NeNodeData } from "../TopologyReactFlowView";
|
||||||
|
|
||||||
|
function accumulate(
|
||||||
|
map: Map<string, NodeAlarmSummary>,
|
||||||
|
key: string,
|
||||||
|
severity: string,
|
||||||
|
) {
|
||||||
|
const k = key.trim();
|
||||||
|
if (!k) return;
|
||||||
|
const sev = normalizeAlarmSeverity(severity);
|
||||||
|
if (sev === "cleared") return;
|
||||||
|
const prev = map.get(k);
|
||||||
|
if (!prev) {
|
||||||
|
map.set(k, { severity: sev, count: 1 });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
map.set(k, {
|
||||||
|
severity: worseSeverity(prev.severity, sev),
|
||||||
|
count: prev.count + 1,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildIndexes(items: UmeAlarmItem[]) {
|
||||||
|
const byNeId = new Map<string, NodeAlarmSummary>();
|
||||||
|
const byHost = new Map<string, NodeAlarmSummary>();
|
||||||
|
for (const item of items) {
|
||||||
|
if (String(item.is_cleared || "").toLowerCase() === "true" || item.is_cleared === "1") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
accumulate(byNeId, String(item.ne_id || "").trim(), item.perceived_severity);
|
||||||
|
// Only index strong host keys — skip short names that false-match canvas labels.
|
||||||
|
for (const raw of [item.host_name, item.ne_name, item.user_label]) {
|
||||||
|
const key = normalizeHostKey(raw);
|
||||||
|
if (isStrongHostKey(key)) {
|
||||||
|
accumulate(byHost, key, item.perceived_severity);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { byNeId, byHost };
|
||||||
|
}
|
||||||
|
|
||||||
|
type Args = {
|
||||||
|
enabled: boolean;
|
||||||
|
nodes: Node<NeNodeData>[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALARM_PAGE_SIZE = 500;
|
||||||
|
const ALARM_MAX_ROWS = 5000;
|
||||||
|
|
||||||
|
/** Poll uncleared UME alarms and join onto canvas nodes by ume_ne_id / strong name / IP. */
|
||||||
|
export function useTopologyAlarmOverlay({ enabled, nodes }: Args) {
|
||||||
|
const query = useQuery({
|
||||||
|
queryKey: [...queryKeys.umeCurrentAlarmsAll, "topo-overlay", "uncleared"],
|
||||||
|
queryFn: () =>
|
||||||
|
fetchAllPages<UmeAlarmItem>({
|
||||||
|
pageSize: ALARM_PAGE_SIZE,
|
||||||
|
maxRows: ALARM_MAX_ROWS,
|
||||||
|
fetchPage: (page, pageSize) =>
|
||||||
|
fetchUmeCurrentAlarms({
|
||||||
|
severity: "",
|
||||||
|
isCleared: "false",
|
||||||
|
hostName: "",
|
||||||
|
keyword: "",
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
enabled,
|
||||||
|
refetchInterval: enabled ? 30_000 : false,
|
||||||
|
staleTime: 15_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
const alarmByNodeId = useMemo(() => {
|
||||||
|
const out = new Map<string, NodeAlarmSummary>();
|
||||||
|
if (!enabled) return out;
|
||||||
|
const items = query.data || [];
|
||||||
|
if (!items.length || !nodes.length) return out;
|
||||||
|
const { byNeId, byHost } = buildIndexes(items);
|
||||||
|
|
||||||
|
// Host keys claimed by >1 canvas node are ambiguous → skip name/IP join for those keys.
|
||||||
|
const hostClaimCount = new Map<string, number>();
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.data.kind === "region") continue;
|
||||||
|
for (const raw of [n.data.label, n.data.ne_ip]) {
|
||||||
|
const key = normalizeHostKey(raw);
|
||||||
|
if (!isStrongHostKey(key)) continue;
|
||||||
|
hostClaimCount.set(key, (hostClaimCount.get(key) || 0) + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const n of nodes) {
|
||||||
|
if (n.data.kind === "region") continue;
|
||||||
|
const umeId = String(n.data.ume_ne_id || "").trim();
|
||||||
|
let hit = umeId ? byNeId.get(umeId) : undefined;
|
||||||
|
if (!hit) {
|
||||||
|
const candidates = [normalizeHostKey(n.data.ne_ip), normalizeHostKey(n.data.label)].filter(
|
||||||
|
(k) => isStrongHostKey(k) && (hostClaimCount.get(k) || 0) === 1,
|
||||||
|
);
|
||||||
|
// Prefer IP over label when both present.
|
||||||
|
candidates.sort((a, b) => Number(looksLikeIp(b)) - Number(looksLikeIp(a)));
|
||||||
|
for (const key of candidates) {
|
||||||
|
hit = byHost.get(key);
|
||||||
|
if (hit) break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (hit) out.set(n.id, hit);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [enabled, query.data, nodes]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
alarmByNodeId,
|
||||||
|
alarmLoading: query.isLoading,
|
||||||
|
alarmError: query.error ? String(query.error) : "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -12,7 +12,7 @@ import {
|
||||||
} from "../constants";
|
} from "../constants";
|
||||||
import type { EdgeDefaults, EdgeStyleData } from "../edgeStyle";
|
import type { EdgeDefaults, EdgeStyleData } from "../edgeStyle";
|
||||||
import { withEdgeVisual } from "../edgeStyle";
|
import { withEdgeVisual } from "../edgeStyle";
|
||||||
import { graphFingerprint, graphToFlow } from "../graphFlow";
|
import { graphFingerprint, graphToFlow, mergeOverlayIntoNodes, overlayFingerprint } from "../graphFlow";
|
||||||
import {
|
import {
|
||||||
buildLinkDisplayEdges,
|
buildLinkDisplayEdges,
|
||||||
type LinkMember,
|
type LinkMember,
|
||||||
|
|
@ -45,6 +45,9 @@ export type UseTopologyCanvasOptions = {
|
||||||
hideVendor: boolean;
|
hideVendor: boolean;
|
||||||
hidePorts: boolean;
|
hidePorts: boolean;
|
||||||
showPlaceholderBadge: boolean;
|
showPlaceholderBadge: boolean;
|
||||||
|
showConnectStatus: boolean;
|
||||||
|
showAlarmOverlay: boolean;
|
||||||
|
showLevelColors: boolean;
|
||||||
expandPhysicalLinks: boolean;
|
expandPhysicalLinks: boolean;
|
||||||
scaleBundleWidth: boolean;
|
scaleBundleWidth: boolean;
|
||||||
edgeFlow: boolean;
|
edgeFlow: boolean;
|
||||||
|
|
@ -82,6 +85,9 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
hideVendor,
|
hideVendor,
|
||||||
hidePorts,
|
hidePorts,
|
||||||
showPlaceholderBadge,
|
showPlaceholderBadge,
|
||||||
|
showConnectStatus,
|
||||||
|
showAlarmOverlay,
|
||||||
|
showLevelColors,
|
||||||
expandPhysicalLinks,
|
expandPhysicalLinks,
|
||||||
scaleBundleWidth,
|
scaleBundleWidth,
|
||||||
edgeFlow,
|
edgeFlow,
|
||||||
|
|
@ -106,6 +112,7 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||||
const appliedMapIdRef = useRef("");
|
const appliedMapIdRef = useRef("");
|
||||||
const appliedGraphFpRef = useRef("");
|
const appliedGraphFpRef = useRef("");
|
||||||
|
const appliedOverlayFpRef = useRef("");
|
||||||
const pendingFitRef = useRef(false);
|
const pendingFitRef = useRef(false);
|
||||||
const nodesRef = useRef<Node<NeNodeData>[]>([]);
|
const nodesRef = useRef<Node<NeNodeData>[]>([]);
|
||||||
const [canvasZoom, setCanvasZoom] = useState(1);
|
const [canvasZoom, setCanvasZoom] = useState(1);
|
||||||
|
|
@ -149,9 +156,22 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
hidePorts,
|
hidePorts,
|
||||||
connectMode: toolMode === "connect",
|
connectMode: toolMode === "connect",
|
||||||
showPlaceholderBadge,
|
showPlaceholderBadge,
|
||||||
|
showConnectStatus,
|
||||||
|
showAlarmOverlay,
|
||||||
|
showLevelColors,
|
||||||
worldVisualLod,
|
worldVisualLod,
|
||||||
}),
|
}),
|
||||||
[hideIp, hideVendor, hidePorts, toolMode, showPlaceholderBadge, worldVisualLod],
|
[
|
||||||
|
hideIp,
|
||||||
|
hideVendor,
|
||||||
|
hidePorts,
|
||||||
|
toolMode,
|
||||||
|
showPlaceholderBadge,
|
||||||
|
showConnectStatus,
|
||||||
|
showAlarmOverlay,
|
||||||
|
showLevelColors,
|
||||||
|
worldVisualLod,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const displayEdges = useMemo(() => {
|
const displayEdges = useMemo(() => {
|
||||||
|
|
@ -286,13 +306,27 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
appliedMapIdRef.current = "";
|
appliedMapIdRef.current = "";
|
||||||
appliedGraphFpRef.current = "";
|
appliedGraphFpRef.current = "";
|
||||||
|
appliedOverlayFpRef.current = "";
|
||||||
}, [mapId]);
|
}, [mapId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canvasMode) return;
|
if (!canvasMode) return;
|
||||||
if (!mapId || !graphQuery.data) return;
|
if (!mapId || !graphQuery.data) return;
|
||||||
const entering = appliedMapIdRef.current !== mapId;
|
const entering = appliedMapIdRef.current !== mapId;
|
||||||
if (!entering && dirtyRef.current) return;
|
|
||||||
|
// Dirty geometry: still refresh connect/level/inventory overlays from live graph.
|
||||||
|
if (!entering && dirtyRef.current) {
|
||||||
|
const ofp = overlayFingerprint(graphQuery.data);
|
||||||
|
if (ofp === appliedOverlayFpRef.current) return;
|
||||||
|
const { nodes: merged, changed } = mergeOverlayIntoNodes(nodesRef.current, graphQuery.data);
|
||||||
|
appliedOverlayFpRef.current = ofp;
|
||||||
|
if (changed) {
|
||||||
|
historyLockRef.current = true;
|
||||||
|
setNodes(merged);
|
||||||
|
historyLockRef.current = false;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const fp = graphFingerprint(graphQuery.data);
|
const fp = graphFingerprint(graphQuery.data);
|
||||||
if (!entering && fp === appliedGraphFpRef.current) return;
|
if (!entering && fp === appliedGraphFpRef.current) return;
|
||||||
|
|
@ -310,6 +344,7 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
||||||
setNodes(nextNodes);
|
setNodes(nextNodes);
|
||||||
setEdges(rfEdges);
|
setEdges(rfEdges);
|
||||||
appliedGraphFpRef.current = fp;
|
appliedGraphFpRef.current = fp;
|
||||||
|
appliedOverlayFpRef.current = overlayFingerprint(graphQuery.data);
|
||||||
if (entering) {
|
if (entering) {
|
||||||
appliedMapIdRef.current = mapId;
|
appliedMapIdRef.current = mapId;
|
||||||
historyRef.current = [];
|
historyRef.current = [];
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState, type Dispatch, type SetStateAction } from "react";
|
||||||
import type { Node } from "@xyflow/react";
|
import type { Node } from "@xyflow/react";
|
||||||
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { connectTestManagedNe, fetchManagedNeById } from "../../../services/api";
|
import { connectTestManagedNe, fetchManagedNeById } from "../../../services/api";
|
||||||
|
import { queryKeys } from "../../../constants/queryKeys";
|
||||||
import { useI18n } from "../../../i18n";
|
import { useI18n } from "../../../i18n";
|
||||||
import { useToast } from "../../../hooks/useToast";
|
import { useToast } from "../../../hooks/useToast";
|
||||||
import type { ManagedNeItem } from "../../../types";
|
import type { ManagedNeItem } from "../../../types";
|
||||||
|
|
@ -8,30 +10,101 @@ import type { NeNodeData } from "../TopologyReactFlowView";
|
||||||
|
|
||||||
type Args = {
|
type Args = {
|
||||||
closeCtxMenu: () => void;
|
closeCtxMenu: () => void;
|
||||||
|
mapId?: string | null;
|
||||||
|
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export function useTopologyConnectTest({ closeCtxMenu }: Args) {
|
/** Patch canvas node connect_status without wiping dirty local edits. */
|
||||||
|
function patchNodesConnectStatus(
|
||||||
|
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>,
|
||||||
|
updates: Array<{ id: string; status: string }>,
|
||||||
|
) {
|
||||||
|
if (!updates.length) return;
|
||||||
|
const byId = new Map(updates.map((u) => [u.id, u.status]));
|
||||||
|
setNodes((ns) =>
|
||||||
|
ns.map((n) => {
|
||||||
|
const mid = String(n.data.managed_ne_id || "").trim();
|
||||||
|
if (!mid || !byId.has(mid)) return n;
|
||||||
|
const status = byId.get(mid) || "unknown";
|
||||||
|
if (n.data.connect_status === status) return n;
|
||||||
|
return { ...n, data: { ...n.data, connect_status: status } };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTopologyConnectTest({ closeCtxMenu, mapId, setNodes }: Args) {
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const { showOk, showError } = useToast();
|
const { showOk, showError } = useToast();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [detailRow, setDetailRow] = useState<ManagedNeItem | null>(null);
|
const [detailRow, setDetailRow] = useState<ManagedNeItem | null>(null);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
/** Managed NE ids whose canvas dots should track poll results. */
|
||||||
|
const [trackingIds, setTrackingIds] = useState<string[]>([]);
|
||||||
|
|
||||||
const refreshRow = useCallback(async (id: string) => {
|
const finishTracking = useCallback(() => {
|
||||||
const row = await fetchManagedNeById(id);
|
setTrackingIds([]);
|
||||||
setDetailRow(row);
|
if (mapId) {
|
||||||
return row;
|
void queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
|
||||||
}, []);
|
}
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
|
||||||
|
}, [mapId, queryClient]);
|
||||||
|
|
||||||
|
// Poll tracked connect tests → update dots (works even when graph apply is dirty-locked).
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!detailRow || detailRow.connect_status !== "testing") return;
|
if (!trackingIds.length) return;
|
||||||
const id = detailRow.id;
|
let cancelled = false;
|
||||||
const timer = window.setInterval(() => {
|
const ids = trackingIds;
|
||||||
void refreshRow(id).catch(() => {
|
|
||||||
/* ignore transient poll errors */
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const rows = await Promise.all(ids.map((id) => fetchManagedNeById(id)));
|
||||||
|
if (cancelled) return;
|
||||||
|
patchNodesConnectStatus(
|
||||||
|
setNodes,
|
||||||
|
rows.map((row) => ({ id: row.id, status: row.connect_status || "unknown" })),
|
||||||
|
);
|
||||||
|
setDetailRow((prev) => {
|
||||||
|
if (!prev) {
|
||||||
|
return ids.length === 1 ? rows[0] : prev;
|
||||||
|
}
|
||||||
|
const match = rows.find((r) => r.id === prev.id);
|
||||||
|
return match || prev;
|
||||||
});
|
});
|
||||||
}, 2000);
|
if (rows.every((r) => r.connect_status !== "testing")) {
|
||||||
return () => window.clearInterval(timer);
|
finishTracking();
|
||||||
}, [detailRow?.id, detailRow?.connect_status, refreshRow]);
|
}
|
||||||
|
} catch {
|
||||||
|
/* ignore transient poll errors */
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void tick();
|
||||||
|
const timer = window.setInterval(() => void tick(), 2000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [trackingIds, setNodes, finishTracking]);
|
||||||
|
|
||||||
|
const startTracking = useCallback(
|
||||||
|
(ids: string[], openDetail: boolean) => {
|
||||||
|
const unique = [...new Set(ids.map((x) => String(x || "").trim()).filter(Boolean))];
|
||||||
|
if (!unique.length) return;
|
||||||
|
patchNodesConnectStatus(
|
||||||
|
setNodes,
|
||||||
|
unique.map((id) => ({ id, status: "testing" })),
|
||||||
|
);
|
||||||
|
setTrackingIds(unique);
|
||||||
|
if (openDetail && unique.length === 1) {
|
||||||
|
void fetchManagedNeById(unique[0])
|
||||||
|
.then((row) => setDetailRow(row))
|
||||||
|
.catch(() => {
|
||||||
|
/* ignore */
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[setNodes],
|
||||||
|
);
|
||||||
|
|
||||||
const runForNode = useCallback(
|
const runForNode = useCallback(
|
||||||
async (node: Node<NeNodeData> | null) => {
|
async (node: Node<NeNodeData> | null) => {
|
||||||
|
|
@ -45,17 +118,16 @@ export function useTopologyConnectTest({ closeCtxMenu }: Args) {
|
||||||
try {
|
try {
|
||||||
const res = await connectTestManagedNe([managedId]);
|
const res = await connectTestManagedNe([managedId]);
|
||||||
showOk(t("managedNe.connect.submitted", { n: res.submitted }));
|
showOk(t("managedNe.connect.submitted", { n: res.submitted }));
|
||||||
await refreshRow(managedId);
|
startTracking([managedId], true);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(String(err));
|
showError(String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[closeCtxMenu, refreshRow, showError, showOk, t],
|
[closeCtxMenu, showError, showOk, startTracking, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
/** Batch: submit connect tests; open detail only when exactly one managed NE. */
|
|
||||||
const runForNodes = useCallback(
|
const runForNodes = useCallback(
|
||||||
async (nodes: Node<NeNodeData>[]) => {
|
async (nodes: Node<NeNodeData>[]) => {
|
||||||
closeCtxMenu();
|
closeCtxMenu();
|
||||||
|
|
@ -70,7 +142,7 @@ export function useTopologyConnectTest({ closeCtxMenu }: Args) {
|
||||||
showError(t("topology.connectTestNeedManaged"));
|
showError(t("topology.connectTestNeedManaged"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const skipped = nodes.length - ids.length;
|
const skipped = Math.max(0, nodes.length - ids.length);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
try {
|
try {
|
||||||
const res = await connectTestManagedNe(ids);
|
const res = await connectTestManagedNe(ids);
|
||||||
|
|
@ -80,24 +152,22 @@ export function useTopologyConnectTest({ closeCtxMenu }: Args) {
|
||||||
? `${msg} · ${t("topology.connectTestSkipped").replace("{{count}}", String(skipped))}`
|
? `${msg} · ${t("topology.connectTestSkipped").replace("{{count}}", String(skipped))}`
|
||||||
: msg,
|
: msg,
|
||||||
);
|
);
|
||||||
if (ids.length === 1) {
|
startTracking(ids, ids.length === 1);
|
||||||
await refreshRow(ids[0]);
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(String(err));
|
showError(String(err));
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[closeCtxMenu, refreshRow, showError, showOk, t],
|
[closeCtxMenu, showError, showOk, startTracking, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
const onRetestSubmitted = useCallback(
|
const onRetestSubmitted = useCallback(
|
||||||
(rowId: string) => {
|
(rowId: string) => {
|
||||||
showOk(t("managedNe.connect.submitted", { n: 1 }));
|
showOk(t("managedNe.connect.submitted", { n: 1 }));
|
||||||
void refreshRow(rowId);
|
startTracking([rowId], true);
|
||||||
},
|
},
|
||||||
[refreshRow, showOk, t],
|
[showOk, startTracking, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -167,19 +167,24 @@ export function useTopologyCreateNe({
|
||||||
const onManagedFormSaved = useCallback(
|
const onManagedFormSaved = useCallback(
|
||||||
async (item: ManagedNeItem) => {
|
async (item: ManagedNeItem) => {
|
||||||
setManagedFormOpen(false);
|
setManagedFormOpen(false);
|
||||||
|
void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
|
||||||
if (!mapId) {
|
if (!mapId) {
|
||||||
showOk(t("managedNe.form.created"));
|
showOk(t("managedNe.form.created"));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const at = placeAtRef.current || { flowX: 80, flowY: 80 };
|
const at = placeAtRef.current || { flowX: 80, flowY: 80 };
|
||||||
|
const displayName = item.name || item.ip_address || item.id;
|
||||||
setManagedBusy(true);
|
setManagedBusy(true);
|
||||||
try {
|
try {
|
||||||
const graph = await addTopologyViewNodes(mapId, {
|
let graph = await addTopologyViewNodes(mapId, {
|
||||||
managed_ne_ids: [item.id],
|
managed_ne_ids: [item.id],
|
||||||
layout: "grid",
|
layout: "grid",
|
||||||
});
|
});
|
||||||
const added = graph.nodes.find((n) => n.managed_ne_id === item.id);
|
const added = graph.nodes.find((n) => n.managed_ne_id === item.id);
|
||||||
|
let placedAtClick = false;
|
||||||
if (added?.fabric_node_id) {
|
if (added?.fabric_node_id) {
|
||||||
|
try {
|
||||||
await patchTopologyPositions(mapId, [
|
await patchTopologyPositions(mapId, [
|
||||||
{
|
{
|
||||||
fabric_node_id: added.fabric_node_id,
|
fabric_node_id: added.fabric_node_id,
|
||||||
|
|
@ -188,17 +193,30 @@ export function useTopologyCreateNe({
|
||||||
label: added.label || added.name || item.name || "",
|
label: added.label || added.name || item.name || "",
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
graph = await fetchTopologyGraph(mapId);
|
||||||
|
placedAtClick = true;
|
||||||
|
} catch {
|
||||||
|
// NE is already on the map; keep grid layout coords.
|
||||||
}
|
}
|
||||||
const refreshed = await fetchTopologyGraph(mapId);
|
}
|
||||||
applyGraph(refreshed);
|
applyGraph(graph);
|
||||||
if (added?.fabric_node_id) {
|
if (added?.fabric_node_id) {
|
||||||
focusNode(added.fabric_node_id, false);
|
focusNode(added.fabric_node_id, false);
|
||||||
}
|
}
|
||||||
showOk(t("topology.createManagedNeDone").replace("{{name}}", item.name || item.ip_address));
|
if (placedAtClick) {
|
||||||
void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
|
showOk(t("topology.createManagedNeDone").replace("{{name}}", displayName));
|
||||||
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
|
} else if (added?.fabric_node_id) {
|
||||||
|
showError(t("topology.createManagedNePosFailed").replace("{{name}}", displayName));
|
||||||
|
} else {
|
||||||
|
showOk(t("topology.createManagedNeDone").replace("{{name}}", displayName));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
showError(String(err));
|
// Inventory already has the NE; placement failed → avoid silent orphan confusion.
|
||||||
|
showError(
|
||||||
|
t("topology.createManagedNePlaceFailed")
|
||||||
|
.replace("{{name}}", displayName)
|
||||||
|
.replace("{{error}}", String(err)),
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
setManagedBusy(false);
|
setManagedBusy(false);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
web/src/pages/topology/levelTone.ts
Normal file
21
web/src/pages/topology/levelTone.ts
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
/** Map fabric level/role to a short CSS tone key for canvas chrome. */
|
||||||
|
|
||||||
|
export type LevelToneKey = "external" | "core" | "aggregation" | "access" | "edge" | "";
|
||||||
|
|
||||||
|
export function levelToneKey(level: number | null | undefined, role?: string | null): LevelToneKey {
|
||||||
|
const r = String(role || "").trim().toLowerCase();
|
||||||
|
if (r === "external" || r === "core" || r === "access" || r === "edge") return r;
|
||||||
|
if (r === "aggregation" || r === "aggregate" || r === "agg") return "aggregation";
|
||||||
|
if (level == null || Number.isNaN(Number(level))) return "";
|
||||||
|
const maj = Math.floor(Number(level));
|
||||||
|
if (maj <= 0) return "external";
|
||||||
|
if (maj === 1) return "core";
|
||||||
|
if (maj === 2) return "aggregation";
|
||||||
|
if (maj === 3) return "access";
|
||||||
|
return "edge";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function levelLabelKey(tone: LevelToneKey): string {
|
||||||
|
if (!tone) return "topology.levelUnmatched";
|
||||||
|
return `topology.level.${tone}`;
|
||||||
|
}
|
||||||
|
|
@ -537,6 +537,10 @@ export type TopologyViewNodeItem = {
|
||||||
device_type: string;
|
device_type: string;
|
||||||
connect_status: string;
|
connect_status: string;
|
||||||
managed_source?: string;
|
managed_source?: string;
|
||||||
|
/** Fabric classify rank; smaller = closer to external/WAN. */
|
||||||
|
level?: number | null;
|
||||||
|
/** Synced alias from floor(level): external|core|aggregation|access|edge|"" */
|
||||||
|
role?: string;
|
||||||
/** ne | region — region drills into a child canvas */
|
/** ne | region — region drills into a child canvas */
|
||||||
kind?: string;
|
kind?: string;
|
||||||
folder_id?: string;
|
folder_id?: string;
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue