mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +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 = ""
|
||||
connect_status: str = ""
|
||||
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
|
||||
kind: str = "ne"
|
||||
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]
|
||||
connect_status = ""
|
||||
managed_source = ""
|
||||
level: float | None = None
|
||||
role = ""
|
||||
if fn is not None:
|
||||
mid = str(fn.managed_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()
|
||||
elif uid and uid in ume_by_id:
|
||||
connect_status = ume_by_id[uid].connection_status or ""
|
||||
level = fn.level
|
||||
role = str(fn.role or "").strip()
|
||||
nodes_out.append(
|
||||
ViewNodeOut(
|
||||
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 "",
|
||||
connect_status=connect_status,
|
||||
managed_source=managed_source,
|
||||
level=level,
|
||||
role=role,
|
||||
)
|
||||
)
|
||||
# Child regions without a placement row still appear (legacy / missed create).
|
||||
|
|
|
|||
|
|
@ -1635,6 +1635,8 @@ const en = {
|
|||
removeSelected: "Remove selected ({{count}})",
|
||||
openWebcrt: "Open terminal",
|
||||
openNe: "NE details",
|
||||
openAlarms: "View alarms",
|
||||
noAlarmLink: "Cannot build an alarm filter for this node",
|
||||
removeNode: "Remove from canvas",
|
||||
deletePlaceholder: "Delete placeholder",
|
||||
deletePlaceholderConfirm:
|
||||
|
|
@ -1661,6 +1663,10 @@ const en = {
|
|||
createNeIpPh: "Can fill later during setup",
|
||||
createNeDone: "Created placeholder {{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…",
|
||||
connectTest: "Connectivity test",
|
||||
connectTestSelected: "Connectivity test ({{count}})",
|
||||
|
|
@ -1723,6 +1729,17 @@ const en = {
|
|||
hideIp: "Hide IP",
|
||||
hideVendor: "Hide vendor",
|
||||
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",
|
||||
edgeFlow: "Edge flow",
|
||||
collapseSidebar: "Collapse sidebar",
|
||||
|
|
|
|||
|
|
@ -1621,6 +1621,8 @@ const zh = {
|
|||
removeSelected: "移除选中 ({{count}})",
|
||||
openWebcrt: "打开终端",
|
||||
openNe: "网元详情",
|
||||
openAlarms: "查看告警",
|
||||
noAlarmLink: "该节点无法定位告警过滤条件",
|
||||
removeNode: "从画布移除",
|
||||
deletePlaceholder: "删除占位网元",
|
||||
deletePlaceholderConfirm: "永久删除此占位网元(含库存记录与 Fabric 直连链路)?画布撤销无法恢复。",
|
||||
|
|
@ -1644,6 +1646,9 @@ const zh = {
|
|||
createNeIpPh: "可稍后在完善时填写",
|
||||
createNeDone: "已新建占位网元 {{name}}",
|
||||
createManagedNeDone: "已创建网元并上图 {{name}}",
|
||||
createManagedNePlaceFailed:
|
||||
"网元 {{name}} 已写入库存,但上图失败:{{error}}。请从网元管理或侧栏再次添加。",
|
||||
createManagedNePosFailed: "网元 {{name}} 已上图,但未能移到点击位置(保留默认布局坐标)",
|
||||
createNeBusy: "创建中…",
|
||||
connectTest: "连通性测试",
|
||||
connectTestSelected: "连通性测试 ({{count}})",
|
||||
|
|
@ -1706,6 +1711,17 @@ const zh = {
|
|||
hideIp: "隐藏 IP",
|
||||
hideVendor: "隐藏厂商",
|
||||
showPlaceholderBadge: "显示占位角标(TOPO/LLDP)",
|
||||
showConnectStatus: "显示连通状态点",
|
||||
showAlarmOverlay: "显示告警叠层",
|
||||
showLevelColors: "按分类层级着色",
|
||||
levelUnmatched: "未分类",
|
||||
level: {
|
||||
external: "外部",
|
||||
core: "核心",
|
||||
aggregation: "汇聚",
|
||||
access: "接入",
|
||||
edge: "边缘",
|
||||
},
|
||||
hidePorts: "隐藏端口",
|
||||
edgeFlow: "链路流动",
|
||||
collapseSidebar: "折叠侧栏",
|
||||
|
|
|
|||
|
|
@ -7603,6 +7603,109 @@ pre {
|
|||
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 {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,9 @@ import {
|
|||
SCALE_BUNDLE_WIDTH_KEY,
|
||||
SEP,
|
||||
SHOW_PLACEHOLDER_BADGE_KEY,
|
||||
SHOW_CONNECT_STATUS_KEY,
|
||||
SHOW_ALARM_OVERLAY_KEY,
|
||||
SHOW_LEVEL_COLORS_KEY,
|
||||
WORLD_MAP_ENABLED,
|
||||
} from "./topology/constants";
|
||||
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 { useTopologyCreateNe } from "./topology/hooks/useTopologyCreateNe";
|
||||
import { useTopologyConnectTest } from "./topology/hooks/useTopologyConnectTest";
|
||||
import { useTopologyAlarmOverlay } from "./topology/hooks/useTopologyAlarmOverlay";
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
|
|
@ -139,6 +143,15 @@ export function TopologyPage() {
|
|||
const [showPlaceholderBadge, setShowPlaceholderBadge] = useState(() =>
|
||||
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 [edgeDefaults, setEdgeDefaults] = useState<EdgeDefaults>(() => loadEdgeDefaults());
|
||||
const [canvasBg, setCanvasBg] = useState(loadCanvasBg);
|
||||
|
|
@ -317,6 +330,9 @@ export function TopologyPage() {
|
|||
hideVendor,
|
||||
hidePorts,
|
||||
showPlaceholderBadge,
|
||||
showConnectStatus,
|
||||
showAlarmOverlay,
|
||||
showLevelColors,
|
||||
expandPhysicalLinks,
|
||||
scaleBundleWidth,
|
||||
edgeFlow,
|
||||
|
|
@ -728,6 +744,11 @@ export function TopologyPage() {
|
|||
const selectedNodes = useMemo(() => nodes.filter((n) => n.selected), [nodes]);
|
||||
const selectedNode = selectedNodes[0] || null;
|
||||
const selectedNodeIds = useMemo(() => selectedNodes.map((n) => n.id), [selectedNodes]);
|
||||
|
||||
const { alarmByNodeId } = useTopologyAlarmOverlay({
|
||||
enabled: canvasMode && showAlarmOverlay && !isWorldFlatCanvas,
|
||||
nodes,
|
||||
});
|
||||
const selectedEdge = useMemo(() => {
|
||||
if (!selectedEdgeId) return null;
|
||||
return (
|
||||
|
|
@ -844,7 +865,7 @@ export function TopologyPage() {
|
|||
onManagedFormSaved,
|
||||
} = createNe;
|
||||
|
||||
const connectTest = useTopologyConnectTest({ closeCtxMenu });
|
||||
const connectTest = useTopologyConnectTest({ closeCtxMenu, mapId, setNodes });
|
||||
const {
|
||||
connectDetailRow,
|
||||
connectTestSubmitting,
|
||||
|
|
@ -1387,6 +1408,22 @@ export function TopologyPage() {
|
|||
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) => {
|
||||
closeCtxMenu();
|
||||
if (!edge) {
|
||||
|
|
@ -2104,6 +2141,12 @@ export function TopologyPage() {
|
|||
onHideVendorChange={setHideVendor}
|
||||
showPlaceholderBadge={showPlaceholderBadge}
|
||||
onShowPlaceholderBadgeChange={setShowPlaceholderBadge}
|
||||
showConnectStatus={showConnectStatus}
|
||||
onShowConnectStatusChange={setShowConnectStatus}
|
||||
showAlarmOverlay={showAlarmOverlay}
|
||||
onShowAlarmOverlayChange={setShowAlarmOverlay}
|
||||
showLevelColors={showLevelColors}
|
||||
onShowLevelColorsChange={setShowLevelColors}
|
||||
hidePorts={hidePorts}
|
||||
onHidePortsChange={setHidePorts}
|
||||
expandPhysicalLinks={expandPhysicalLinks}
|
||||
|
|
@ -2210,6 +2253,7 @@ export function TopologyPage() {
|
|||
>
|
||||
<TopologyReactFlowView
|
||||
displayOpts={displayOpts}
|
||||
alarmByNodeId={alarmByNodeId}
|
||||
nodes={nodes}
|
||||
setNodes={setNodes}
|
||||
edges={edges}
|
||||
|
|
@ -2450,6 +2494,7 @@ export function TopologyPage() {
|
|||
onDiscoverOne={discoverOneFor}
|
||||
onOpenWebcrt={openWebcrtFor}
|
||||
onOpenNe={openNeFor}
|
||||
onOpenAlarms={openAlarmsFor}
|
||||
onConnectTest={runConnectTestForNode}
|
||||
connectTestBusy={connectTestSubmitting}
|
||||
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 { ListPager } from "../../components/ListPager";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
|
|
@ -25,6 +26,7 @@ function severityClass(sev: string | null | undefined): string {
|
|||
export function NetworkAlarmsPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [curSeverity, setCurSeverity] = useState("");
|
||||
const [curCleared, setCurCleared] = useState("");
|
||||
const [curHostName, setCurHostName] = useState("");
|
||||
|
|
@ -33,6 +35,32 @@ export function NetworkAlarmsPage() {
|
|||
const [curPageSize, setCurPageSize] = useState(50);
|
||||
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 debouncedHost = useDebouncedValue(curHostName, 300);
|
||||
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ export type TopologyCtxMenuProps = {
|
|||
onDiscoverOne: (node: Node<NeNodeData> | null) => void;
|
||||
onOpenWebcrt: (node: Node<NeNodeData> | null) => void;
|
||||
onOpenNe: (node: Node<NeNodeData> | null) => void;
|
||||
onOpenAlarms: (node: Node<NeNodeData> | null) => void;
|
||||
onConnectTest: (node: Node<NeNodeData> | null) => void;
|
||||
connectTestBusy?: boolean;
|
||||
onPurgePlaceholder: (id: string) => void;
|
||||
|
|
@ -86,6 +87,7 @@ export function TopologyCtxMenu({
|
|||
onDiscoverOne,
|
||||
onOpenWebcrt,
|
||||
onOpenNe,
|
||||
onOpenAlarms,
|
||||
onConnectTest,
|
||||
connectTestBusy = false,
|
||||
onPurgePlaceholder,
|
||||
|
|
@ -277,6 +279,16 @@ export function TopologyCtxMenu({
|
|||
{t("topology.openNe")}
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
className="topo-ctx__item"
|
||||
role="menuitem"
|
||||
onClick={() => onOpenAlarms(selectedNode)}
|
||||
>
|
||||
{t("topology.openAlarms")}
|
||||
</button>
|
||||
</li>
|
||||
<li role="none">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -27,6 +27,12 @@ import { ParallelEdge } from "./ParallelEdge";
|
|||
import { isAggregateEdgeId } from "./linkDisplay";
|
||||
import type { ToolMode, ToolModeBehavior } from "./toolMode";
|
||||
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 = {
|
||||
label: string;
|
||||
|
|
@ -36,6 +42,10 @@ export type NeNodeData = {
|
|||
vendor: string;
|
||||
connect_status: 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";
|
||||
folder_id?: string;
|
||||
view_id?: string;
|
||||
|
|
@ -50,6 +60,9 @@ export type TopoDisplayOpts = {
|
|||
hidePorts: boolean;
|
||||
connectMode: boolean;
|
||||
showPlaceholderBadge: boolean;
|
||||
showConnectStatus: boolean;
|
||||
showAlarmOverlay: boolean;
|
||||
showLevelColors: boolean;
|
||||
worldVisualLod: "dot" | "pin" | "full";
|
||||
};
|
||||
|
||||
|
|
@ -59,15 +72,24 @@ const TopoDisplayContext = createContext<TopoDisplayOpts>({
|
|||
hidePorts: true,
|
||||
connectMode: false,
|
||||
showPlaceholderBadge: false,
|
||||
showConnectStatus: true,
|
||||
showAlarmOverlay: true,
|
||||
showLevelColors: true,
|
||||
worldVisualLod: "full",
|
||||
});
|
||||
|
||||
const TopoAlarmContext = createContext<ReadonlyMap<string, NodeAlarmSummary>>(new Map());
|
||||
const emptyAlarmMap: ReadonlyMap<string, NodeAlarmSummary> = new Map();
|
||||
|
||||
const SEP = " / ";
|
||||
|
||||
import { isPlaceholderSource } from "./searchUtils";
|
||||
import { nodeIconTone } from "./vendorTone";
|
||||
|
||||
export { isPlaceholderSource };
|
||||
function normalizeConnectStatus(raw: string | undefined): "pass" | "fail" | "testing" | "unknown" {
|
||||
const s = String(raw || "").trim().toLowerCase();
|
||||
if (s === "pass" || s === "ok") return "pass";
|
||||
if (s === "fail" || s === "error") return "fail";
|
||||
if (s === "testing") return "testing";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
function RouterIcon() {
|
||||
return (
|
||||
|
|
@ -107,13 +129,25 @@ function FullscreenIcon({ exit }: { exit?: boolean }) {
|
|||
);
|
||||
}
|
||||
|
||||
const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeData>>) {
|
||||
const { hideIp, hideVendor, connectMode, showPlaceholderBadge, worldVisualLod } =
|
||||
useContext(TopoDisplayContext);
|
||||
const NeNode = memo(function NeNode({ id, data, selected }: NodeProps<Node<NeNodeData>>) {
|
||||
const {
|
||||
hideIp,
|
||||
hideVendor,
|
||||
connectMode,
|
||||
showPlaceholderBadge,
|
||||
showConnectStatus,
|
||||
showAlarmOverlay,
|
||||
showLevelColors,
|
||||
worldVisualLod,
|
||||
} = useContext(TopoDisplayContext);
|
||||
const alarmMap = useContext(TopoAlarmContext);
|
||||
const isRegion = data.kind === "region";
|
||||
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 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 secondary = isRegion
|
||||
? []
|
||||
|
|
@ -122,12 +156,22 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
|||
hideVendor || !data.vendor ? "" : data.vendor,
|
||||
].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") {
|
||||
return (
|
||||
<div
|
||||
className={`topo-node topo-node--dot topo-node--${tone}${selected ? " is-selected" : ""}`}
|
||||
title={name}
|
||||
>
|
||||
<div className={`topo-node topo-node--dot ${statusClass}`} title={name}>
|
||||
<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" />
|
||||
<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") {
|
||||
return (
|
||||
<div
|
||||
className={`topo-node topo-node--pin topo-node--${tone}${selected ? " is-selected" : ""}`}
|
||||
title={name}
|
||||
>
|
||||
<div className={`topo-node topo-node--pin ${statusClass}`} title={name}>
|
||||
<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" />
|
||||
<span className="topo-node__pin" aria-hidden="true" />
|
||||
|
|
@ -149,10 +190,16 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`topo-node topo-node--${tone}${selected ? " is-selected" : ""}${
|
||||
connectMode && !isRegion ? " is-connect-mode" : ""
|
||||
}${showBadge ? " is-placeholder" : ""}${isRegion ? " is-region" : ""}`}
|
||||
title={isRegion ? name : showBadge ? data.managed_source || "placeholder" : name}
|
||||
className={`topo-node ${statusClass}`}
|
||||
title={
|
||||
alarm
|
||||
? `${name} · alarm ${alarm.severity}×${alarm.count}`
|
||||
: isRegion
|
||||
? name
|
||||
: showBadge
|
||||
? data.managed_source || "placeholder"
|
||||
: name
|
||||
}
|
||||
>
|
||||
<div className="topo-node__glyph">
|
||||
<Handle
|
||||
|
|
@ -168,7 +215,14 @@ const NeNode = memo(function NeNode({ data, selected }: NodeProps<Node<NeNodeDat
|
|||
isConnectable={connectMode && !isRegion}
|
||||
/>
|
||||
{!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>
|
||||
{String(data.managed_source || "ph").slice(0, 4)}
|
||||
</span>
|
||||
|
|
@ -249,6 +303,7 @@ type CtxMenuLike =
|
|||
|
||||
export type TopologyReactFlowViewProps = {
|
||||
displayOpts: TopoDisplayOpts;
|
||||
alarmByNodeId?: ReadonlyMap<string, NodeAlarmSummary>;
|
||||
nodes: Node<NeNodeData>[];
|
||||
setNodes: Dispatch<SetStateAction<Node<NeNodeData>[]>>;
|
||||
edges: Edge[];
|
||||
|
|
@ -297,6 +352,7 @@ export type TopologyReactFlowViewProps = {
|
|||
export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) {
|
||||
const {
|
||||
displayOpts,
|
||||
alarmByNodeId,
|
||||
nodes,
|
||||
setNodes,
|
||||
setEdges,
|
||||
|
|
@ -338,6 +394,7 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
|||
|
||||
return (
|
||||
<TopoDisplayContext.Provider value={displayOpts}>
|
||||
<TopoAlarmContext.Provider value={alarmByNodeId || emptyAlarmMap}>
|
||||
<ReactFlow
|
||||
nodes={
|
||||
isWorldFlatCanvas && worldVisualLod !== "full"
|
||||
|
|
@ -513,6 +570,7 @@ export default function TopologyReactFlowView(props: TopologyReactFlowViewProps)
|
|||
/>
|
||||
) : null}
|
||||
</ReactFlow>
|
||||
</TopoAlarmContext.Provider>
|
||||
</TopoDisplayContext.Provider>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,6 +13,9 @@ import {
|
|||
SCALE_BUNDLE_WIDTH_KEY,
|
||||
SEP,
|
||||
SHOW_PLACEHOLDER_BADGE_KEY,
|
||||
SHOW_CONNECT_STATUS_KEY,
|
||||
SHOW_ALARM_OVERLAY_KEY,
|
||||
SHOW_LEVEL_COLORS_KEY,
|
||||
} from "./constants";
|
||||
import {
|
||||
DEFAULT_LABEL_COLORS,
|
||||
|
|
@ -47,6 +50,12 @@ export type TopologyViewToolsProps = {
|
|||
onHideVendorChange: (v: boolean) => void;
|
||||
showPlaceholderBadge: boolean;
|
||||
onShowPlaceholderBadgeChange: (v: boolean) => void;
|
||||
showConnectStatus: boolean;
|
||||
onShowConnectStatusChange: (v: boolean) => void;
|
||||
showAlarmOverlay: boolean;
|
||||
onShowAlarmOverlayChange: (v: boolean) => void;
|
||||
showLevelColors: boolean;
|
||||
onShowLevelColorsChange: (v: boolean) => void;
|
||||
hidePorts: boolean;
|
||||
onHidePortsChange: (v: boolean) => void;
|
||||
expandPhysicalLinks: boolean;
|
||||
|
|
@ -101,6 +110,12 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
|
|||
onHideVendorChange,
|
||||
showPlaceholderBadge,
|
||||
onShowPlaceholderBadgeChange,
|
||||
showConnectStatus,
|
||||
onShowConnectStatusChange,
|
||||
showAlarmOverlay,
|
||||
onShowAlarmOverlayChange,
|
||||
showLevelColors,
|
||||
onShowLevelColorsChange,
|
||||
hidePorts,
|
||||
onHidePortsChange,
|
||||
expandPhysicalLinks,
|
||||
|
|
@ -169,6 +184,42 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
|
|||
/>
|
||||
{t("topology.showPlaceholderBadge")}
|
||||
</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">
|
||||
<input type="checkbox" checked={hidePorts} onChange={(e) => onHidePortsChange(e.target.checked)} />
|
||||
{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 SCALE_BUNDLE_WIDTH_KEY = "netx.topology.scaleBundleWidth";
|
||||
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 DEFAULT_CANVAS_BG = "#0f172a";
|
||||
export const LEGACY_CANVAS_BG = "#dbeafe";
|
||||
|
|
|
|||
|
|
@ -49,6 +49,8 @@ export function graphToFlow(
|
|||
vendor: n.vendor || "",
|
||||
connect_status: n.connect_status || "",
|
||||
managed_source: n.managed_source || "",
|
||||
level: n.level ?? null,
|
||||
fabric_role: n.role || "",
|
||||
kind: (n.kind as NeNodeData["kind"]) || (isRegion ? "region" : "ne"),
|
||||
folder_id: n.folder_id || "",
|
||||
view_id: n.view_id || "",
|
||||
|
|
@ -104,7 +106,7 @@ export function graphFingerprint(graph: TopologyViewGraph): string {
|
|||
const nodes = [...graph.nodes]
|
||||
.map(
|
||||
(n) =>
|
||||
`${n.fabric_node_id}:${Math.round(Number(n.x) || 0)}:${Math.round(Number(n.y) || 0)}:${n.label || n.name || ""}`,
|
||||
`${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()
|
||||
.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}`;
|
||||
}
|
||||
|
||||
/** 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(
|
||||
graph: TopologyViewGraph,
|
||||
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";
|
||||
import type { EdgeDefaults, EdgeStyleData } from "../edgeStyle";
|
||||
import { withEdgeVisual } from "../edgeStyle";
|
||||
import { graphFingerprint, graphToFlow } from "../graphFlow";
|
||||
import { graphFingerprint, graphToFlow, mergeOverlayIntoNodes, overlayFingerprint } from "../graphFlow";
|
||||
import {
|
||||
buildLinkDisplayEdges,
|
||||
type LinkMember,
|
||||
|
|
@ -45,6 +45,9 @@ export type UseTopologyCanvasOptions = {
|
|||
hideVendor: boolean;
|
||||
hidePorts: boolean;
|
||||
showPlaceholderBadge: boolean;
|
||||
showConnectStatus: boolean;
|
||||
showAlarmOverlay: boolean;
|
||||
showLevelColors: boolean;
|
||||
expandPhysicalLinks: boolean;
|
||||
scaleBundleWidth: boolean;
|
||||
edgeFlow: boolean;
|
||||
|
|
@ -82,6 +85,9 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
hideVendor,
|
||||
hidePorts,
|
||||
showPlaceholderBadge,
|
||||
showConnectStatus,
|
||||
showAlarmOverlay,
|
||||
showLevelColors,
|
||||
expandPhysicalLinks,
|
||||
scaleBundleWidth,
|
||||
edgeFlow,
|
||||
|
|
@ -106,6 +112,7 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
const appliedMapIdRef = useRef("");
|
||||
const appliedGraphFpRef = useRef("");
|
||||
const appliedOverlayFpRef = useRef("");
|
||||
const pendingFitRef = useRef(false);
|
||||
const nodesRef = useRef<Node<NeNodeData>[]>([]);
|
||||
const [canvasZoom, setCanvasZoom] = useState(1);
|
||||
|
|
@ -149,9 +156,22 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
hidePorts,
|
||||
connectMode: toolMode === "connect",
|
||||
showPlaceholderBadge,
|
||||
showConnectStatus,
|
||||
showAlarmOverlay,
|
||||
showLevelColors,
|
||||
worldVisualLod,
|
||||
}),
|
||||
[hideIp, hideVendor, hidePorts, toolMode, showPlaceholderBadge, worldVisualLod],
|
||||
[
|
||||
hideIp,
|
||||
hideVendor,
|
||||
hidePorts,
|
||||
toolMode,
|
||||
showPlaceholderBadge,
|
||||
showConnectStatus,
|
||||
showAlarmOverlay,
|
||||
showLevelColors,
|
||||
worldVisualLod,
|
||||
],
|
||||
);
|
||||
|
||||
const displayEdges = useMemo(() => {
|
||||
|
|
@ -286,13 +306,27 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
useEffect(() => {
|
||||
appliedMapIdRef.current = "";
|
||||
appliedGraphFpRef.current = "";
|
||||
appliedOverlayFpRef.current = "";
|
||||
}, [mapId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canvasMode) return;
|
||||
if (!mapId || !graphQuery.data) return;
|
||||
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);
|
||||
if (!entering && fp === appliedGraphFpRef.current) return;
|
||||
|
|
@ -310,6 +344,7 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
|
|||
setNodes(nextNodes);
|
||||
setEdges(rfEdges);
|
||||
appliedGraphFpRef.current = fp;
|
||||
appliedOverlayFpRef.current = overlayFingerprint(graphQuery.data);
|
||||
if (entering) {
|
||||
appliedMapIdRef.current = mapId;
|
||||
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 { useQueryClient } from "@tanstack/react-query";
|
||||
import { connectTestManagedNe, fetchManagedNeById } from "../../../services/api";
|
||||
import { queryKeys } from "../../../constants/queryKeys";
|
||||
import { useI18n } from "../../../i18n";
|
||||
import { useToast } from "../../../hooks/useToast";
|
||||
import type { ManagedNeItem } from "../../../types";
|
||||
|
|
@ -8,30 +10,101 @@ import type { NeNodeData } from "../TopologyReactFlowView";
|
|||
|
||||
type Args = {
|
||||
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 { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [detailRow, setDetailRow] = useState<ManagedNeItem | null>(null);
|
||||
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 row = await fetchManagedNeById(id);
|
||||
setDetailRow(row);
|
||||
return row;
|
||||
}, []);
|
||||
const finishTracking = useCallback(() => {
|
||||
setTrackingIds([]);
|
||||
if (mapId) {
|
||||
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(() => {
|
||||
if (!detailRow || detailRow.connect_status !== "testing") return;
|
||||
const id = detailRow.id;
|
||||
const timer = window.setInterval(() => {
|
||||
void refreshRow(id).catch(() => {
|
||||
/* ignore transient poll errors */
|
||||
if (!trackingIds.length) return;
|
||||
let cancelled = false;
|
||||
const ids = trackingIds;
|
||||
|
||||
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);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [detailRow?.id, detailRow?.connect_status, refreshRow]);
|
||||
if (rows.every((r) => r.connect_status !== "testing")) {
|
||||
finishTracking();
|
||||
}
|
||||
} 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(
|
||||
async (node: Node<NeNodeData> | null) => {
|
||||
|
|
@ -45,17 +118,16 @@ export function useTopologyConnectTest({ closeCtxMenu }: Args) {
|
|||
try {
|
||||
const res = await connectTestManagedNe([managedId]);
|
||||
showOk(t("managedNe.connect.submitted", { n: res.submitted }));
|
||||
await refreshRow(managedId);
|
||||
startTracking([managedId], true);
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
} finally {
|
||||
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(
|
||||
async (nodes: Node<NeNodeData>[]) => {
|
||||
closeCtxMenu();
|
||||
|
|
@ -70,7 +142,7 @@ export function useTopologyConnectTest({ closeCtxMenu }: Args) {
|
|||
showError(t("topology.connectTestNeedManaged"));
|
||||
return;
|
||||
}
|
||||
const skipped = nodes.length - ids.length;
|
||||
const skipped = Math.max(0, nodes.length - ids.length);
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const res = await connectTestManagedNe(ids);
|
||||
|
|
@ -80,24 +152,22 @@ export function useTopologyConnectTest({ closeCtxMenu }: Args) {
|
|||
? `${msg} · ${t("topology.connectTestSkipped").replace("{{count}}", String(skipped))}`
|
||||
: msg,
|
||||
);
|
||||
if (ids.length === 1) {
|
||||
await refreshRow(ids[0]);
|
||||
}
|
||||
startTracking(ids, ids.length === 1);
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
},
|
||||
[closeCtxMenu, refreshRow, showError, showOk, t],
|
||||
[closeCtxMenu, showError, showOk, startTracking, t],
|
||||
);
|
||||
|
||||
const onRetestSubmitted = useCallback(
|
||||
(rowId: string) => {
|
||||
showOk(t("managedNe.connect.submitted", { n: 1 }));
|
||||
void refreshRow(rowId);
|
||||
startTracking([rowId], true);
|
||||
},
|
||||
[refreshRow, showOk, t],
|
||||
[showOk, startTracking, t],
|
||||
);
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -167,19 +167,24 @@ export function useTopologyCreateNe({
|
|||
const onManagedFormSaved = useCallback(
|
||||
async (item: ManagedNeItem) => {
|
||||
setManagedFormOpen(false);
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
|
||||
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
|
||||
if (!mapId) {
|
||||
showOk(t("managedNe.form.created"));
|
||||
return;
|
||||
}
|
||||
const at = placeAtRef.current || { flowX: 80, flowY: 80 };
|
||||
const displayName = item.name || item.ip_address || item.id;
|
||||
setManagedBusy(true);
|
||||
try {
|
||||
const graph = await addTopologyViewNodes(mapId, {
|
||||
let graph = await addTopologyViewNodes(mapId, {
|
||||
managed_ne_ids: [item.id],
|
||||
layout: "grid",
|
||||
});
|
||||
const added = graph.nodes.find((n) => n.managed_ne_id === item.id);
|
||||
let placedAtClick = false;
|
||||
if (added?.fabric_node_id) {
|
||||
try {
|
||||
await patchTopologyPositions(mapId, [
|
||||
{
|
||||
fabric_node_id: added.fabric_node_id,
|
||||
|
|
@ -188,17 +193,30 @@ export function useTopologyCreateNe({
|
|||
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) {
|
||||
focusNode(added.fabric_node_id, false);
|
||||
}
|
||||
showOk(t("topology.createManagedNeDone").replace("{{name}}", item.name || item.ip_address));
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.managedNeAll });
|
||||
void queryClient.invalidateQueries({ queryKey: ["webcrtTargets"] });
|
||||
if (placedAtClick) {
|
||||
showOk(t("topology.createManagedNeDone").replace("{{name}}", displayName));
|
||||
} else if (added?.fabric_node_id) {
|
||||
showError(t("topology.createManagedNePosFailed").replace("{{name}}", displayName));
|
||||
} else {
|
||||
showOk(t("topology.createManagedNeDone").replace("{{name}}", displayName));
|
||||
}
|
||||
} 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 {
|
||||
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;
|
||||
connect_status: 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 */
|
||||
kind?: string;
|
||||
folder_id?: string;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue