diff --git a/netx_api/topology_schemas.py b/netx_api/topology_schemas.py index bf7fd51..e42f6be 100644 --- a/netx_api/topology_schemas.py +++ b/netx_api/topology_schemas.py @@ -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 = "" diff --git a/netx_api/topology_views_graph.py b/netx_api/topology_views_graph.py index f59a68e..41e692d 100644 --- a/netx_api/topology_views_graph.py +++ b/netx_api/topology_views_graph.py @@ -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). diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 52e1f30..0d433a8 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 4631812..d1a8168 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "折叠侧栏", diff --git a/web/src/index.css b/web/src/index.css index 18f48e4..45b62c5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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; diff --git a/web/src/pages/TopologyPage.tsx b/web/src/pages/TopologyPage.tsx index 80a81e1..54e5400 100644 --- a/web/src/pages/TopologyPage.tsx +++ b/web/src/pages/TopologyPage.tsx @@ -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(() => 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 | 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() { > { + 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); diff --git a/web/src/pages/topology/TopologyCtxMenu.tsx b/web/src/pages/topology/TopologyCtxMenu.tsx index bf4a505..dcf09ea 100644 --- a/web/src/pages/topology/TopologyCtxMenu.tsx +++ b/web/src/pages/topology/TopologyCtxMenu.tsx @@ -43,6 +43,7 @@ export type TopologyCtxMenuProps = { onDiscoverOne: (node: Node | null) => void; onOpenWebcrt: (node: Node | null) => void; onOpenNe: (node: Node | null) => void; + onOpenAlarms: (node: Node | null) => void; onConnectTest: (node: Node | 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")} +
  • + +