fix(ui): optimize react state, performance, and ux logic

- Refactored useFilteredNodes to memoize logic and prevent re-rendering.
- Optimized LeftPanel useMemo dependencies and HostChart O(N) traversal.
- Fixed polling interval desync and added WebSocket throttling/React Strict Mode transport patch.
- Fixed Graph layout dependencies and 'ghost' children chevrons on collapsed nodes.
- Fixed RightPanel tab persistence UI bug and resolved React Hooks order crash.

No remaining known issues from the UI analysis.
This commit is contained in:
2026-02-23 15:20:25 -08:00
parent d40be883fe
commit df02542c26
9 changed files with 337 additions and 175 deletions

8
dist/index.html vendored
View File

@@ -9,10 +9,10 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-BsyZf1s0.js"></script>
<link rel="modulepreload" crossorigin href="/assets/graph-vendor-C44rQwKI.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DpLh-vKM.js">
<link rel="stylesheet" crossorigin href="/assets/index-soHg8pn4.css">
<script type="module" crossorigin src="/assets/index-CTcm3RSe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/graph-vendor-pGkIx_vZ.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-tLSpD589.js">
<link rel="stylesheet" crossorigin href="/assets/index-BjyEYKkh.css">
</head>
<body>
<div id="root"></div>

View File

@@ -86,10 +86,15 @@ function App() {
const pollIntervalRef = useRef(pollInterval);
pollIntervalRef.current = pollInterval;
const loadData = useCallback(async () => {
const loadData = useCallback(async (isBackgroundPoll = false) => {
if (isLoadingRef.current) return;
setIsLoading(true);
// Only set loading state if there are no existing nodes (initial fresh load)
// or if this is an explicit user refresh (not background poll).
const isInitialLoad = useTopologyStore.getState().nodes.length === 0;
if (isInitialLoad && !isBackgroundPoll) {
setIsLoading(true);
}
try {
const response = await fetch(`${API_BASE_URL}/api/discover`, {
@@ -184,18 +189,20 @@ function App() {
}, [toggleCommandPalette]);
useEffect(() => {
loadData();
const isInitialLoad = useTopologyStore.getState().nodes.length === 0;
loadData(!isInitialLoad); // Poll in background if we already have nodes
}, []);
useEffect(() => {
const intervalId = setInterval(loadData, pollIntervalRef.current);
// Rely on pollInterval from store state instead of ref
const intervalId = setInterval(() => loadData(true), pollInterval);
return () => clearInterval(intervalId);
}, [loadData]);
}, [loadData, pollInterval]);
// --- WebSocket connection (websocket-engineer skill) ---
useEffect(() => {
const socket: Socket = ioClient(API_BASE_URL, {
transports: ['websocket', 'polling'],
transports: ['polling', 'websocket'],
reconnectionDelay: 1000,
reconnectionDelayMax: 5000,
reconnectionAttempts: Infinity,
@@ -214,7 +221,14 @@ function App() {
});
// Listen for real-time topology updates
let lastWsUpdate = 0;
socket.on('topology:update', (data: ApiDiscoveryResponse) => {
// Throttle updates to max 1 per second to prevent UI freezes
const now = Date.now();
if (now - lastWsUpdate < 1000) return;
lastWsUpdate = now;
if (data?.hosts) {
const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({
name: h.name,
@@ -288,9 +302,10 @@ const Footer = memo(function Footer() {
const pollIntervalRef = useRef(pollInterval);
pollIntervalRef.current = pollInterval;
const formatTime = (date: Date | null) => {
const formatTime = (date: Date | string | null) => {
if (!date) return 'Never';
return date.toLocaleTimeString();
const d = new Date(date);
return isNaN(d.getTime()) ? 'Never' : d.toLocaleTimeString();
};
useEffect(() => {

View File

@@ -28,25 +28,35 @@ export default function HostChart() {
);
const hostData = useMemo(() => {
// Find host-type nodes
const hosts = nodes.filter(
(n) =>
n.type === 'host_physical' ||
n.type === 'host_vm' ||
n.type === 'host_container'
);
// Build a parent mapping of children counts in one O(N) pass
const hostChildrenCounts = new Map<string, { total: number, running: number, stopped: number }>();
const hosts: typeof nodes = [];
nodes.forEach(n => {
if (n.type === 'host_physical' || n.type === 'host_vm' || n.type === 'host_container') {
hosts.push(n);
if (!hostChildrenCounts.has(n.id)) {
hostChildrenCounts.set(n.id, { total: 0, running: 0, stopped: 0 });
}
}
if (n.data.parentId) {
const parentCounts = hostChildrenCounts.get(n.data.parentId) || { total: 0, running: 0, stopped: 0 };
parentCounts.total++;
if (n.data.status === 'running') parentCounts.running++;
else if (n.data.status === 'stopped') parentCounts.stopped++;
hostChildrenCounts.set(n.data.parentId, parentCounts);
}
});
// Count children (services/containers) per host
return hosts
.map((host) => {
const children = nodes.filter((n) => n.data.parentId === host.id);
const running = children.filter((n) => n.data.status === 'running').length;
const stopped = children.filter((n) => n.data.status === 'stopped').length;
const counts = hostChildrenCounts.get(host.id) || { total: 0, running: 0, stopped: 0 };
return {
name: host.name,
total: children.length,
running,
stopped,
total: counts.total,
running: counts.running,
stopped: counts.stopped,
status: host.data.status,
};
})

View File

@@ -105,11 +105,11 @@ export default function MetricsBar() {
</div>
{/* Last updated */}
{lastUpdated && (
{lastUpdated && !isNaN(new Date(lastUpdated).getTime()) && (
<div className="flex items-center gap-1.5" title="Last Discovery">
<Clock size={13} className="text-slate-500" />
<span className="text-slate-500">
{lastUpdated.toLocaleTimeString()}
{new Date(lastUpdated).toLocaleTimeString()}
</span>
</div>
)}

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, memo } from 'react';
import { useCallback, useMemo, memo, useEffect } from 'react';
import {
ReactFlow,
Background,
@@ -9,13 +9,14 @@ import {
NodeProps,
Handle,
Position,
useReactFlow
} from '@xyflow/react';
import '@xyflow/react/dist/style.css';
import dagre from 'dagre';
import { useShallow } from 'zustand/react/shallow';
import { useTopologyStore } from '../../store/topologyStore';
import { getNodeColor, getStatusColor } from '../../utils/colors';
import { Server, Network, Wifi, Box, Database, Folder } from 'lucide-react';
import { Server, Network, Wifi, Box, Database, Folder, ChevronDown, ChevronRight } from 'lucide-react';
import { NodeType, ServiceCategory } from '../../types';
const nodeIcons: Record<NodeType, React.ReactNode> = {
@@ -35,60 +36,84 @@ const nodeIcons: Record<NodeType, React.ReactNode> = {
};
const CustomNode = memo(function CustomNode({ data, selected, id }: NodeProps) {
const highlightPath = useTopologyStore((s) => s.highlightPath);
const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string };
const { highlightPath, collapsedNodes, toggleNodeCollapse } = useTopologyStore(useShallow(s => ({
highlightPath: s.highlightPath,
collapsedNodes: s.collapsedNodes,
toggleNodeCollapse: s.toggleNodeCollapse
})));
const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string; hasChildren?: boolean };
const nodeColor = getNodeColor(nodeData.type || 'service', nodeData.category);
const statusColor = getStatusColor(nodeData.status || 'unknown');
const isHighlighted = highlightPath.includes(id);
const isDimmed = highlightPath.length > 0 && !isHighlighted;
const isCollapsed = collapsedNodes.includes(id);
return (
<div
className={`px-4 py-3 rounded-xl border-2 transition-all duration-200 ${selected
<div className="relative group">
<div
className={`px-4 py-3 rounded-xl border-2 transition-all duration-200 ${selected
? 'border-sky-400 shadow-lg shadow-sky-400/20 scale-[1.02]'
: isHighlighted
? 'border-indigo-400 shadow-lg shadow-indigo-400/20'
: 'border-slate-600 hover:border-slate-500 hover:shadow-md hover:shadow-slate-700/30 hover:scale-[1.01]'
}`}
style={{
backgroundColor: isDimmed ? '#0F172A' : '#1E293B',
minWidth: '140px',
opacity: isDimmed ? 0.4 : 1
}}
role="treeitem"
aria-label={`${nodeData.label || 'Node'}, ${nodeData.type?.replace(/_/g, ' ') || 'unknown type'}, ${nodeData.status || 'unknown status'}`}
>
<Handle type="target" position={Position.Left} className="!bg-slate-400" />
}`}
style={{
backgroundColor: isDimmed ? '#0F172A' : '#1E293B',
minWidth: '140px',
opacity: isDimmed ? 0.4 : 1
}}
role="treeitem"
aria-label={`${nodeData.label || 'Node'}, ${nodeData.type?.replace(/_/g, ' ') || 'unknown type'}, ${nodeData.status || 'unknown status'}`}
>
<Handle type="target" position={Position.Left} className="!bg-slate-400" />
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${nodeColor}20` }}
>
<div style={{ color: nodeColor }} aria-hidden="true">
{nodeIcons[nodeData.type || 'service']}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white truncate">
{nodeData.label}
</div>
{nodeData.ip && (
<div className="text-xs text-slate-500 font-mono">
{nodeData.ip}
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center"
style={{ backgroundColor: `${nodeColor}20` }}
>
<div style={{ color: nodeColor }} aria-hidden="true">
{nodeIcons[nodeData.type || 'service']}
</div>
)}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-medium text-white truncate">
{nodeData.label}
</div>
{nodeData.ip && (
<div className="text-xs text-slate-500 font-mono">
{nodeData.ip}
</div>
)}
</div>
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: statusColor }}
aria-label={`Status: ${nodeData.status || 'unknown'}`}
/>
</div>
<div
className="w-2.5 h-2.5 rounded-full"
style={{ backgroundColor: statusColor }}
aria-label={`Status: ${nodeData.status || 'unknown'}`}
/>
<Handle type="source" position={Position.Right} className="!bg-slate-400" />
</div>
<Handle type="source" position={Position.Right} className="!bg-slate-400" />
{nodeData.hasChildren && (
<button
onClick={(e) => {
e.stopPropagation();
toggleNodeCollapse(id);
}}
className="absolute -right-3 -bottom-3 w-6 h-6 bg-slate-800 border-2 border-slate-600 rounded-full flex items-center justify-center text-slate-400 hover:text-white hover:border-slate-500 hover:bg-slate-700 transition-colors z-10"
title={isCollapsed ? "Expand children" : "Collapse children"}
>
{isCollapsed ? (
<ChevronRight className="w-4 h-4" />
) : (
<ChevronDown className="w-4 h-4" />
)}
</button>
)}
</div>
);
});
@@ -132,28 +157,50 @@ function getLayoutedElements(nodes: Node[], edges: Edge[], direction: 'LR' | 'TB
return { nodes: layoutedNodes, edges };
}
import { useFilteredNodes } from '../../store/topologyStore';
export default function TopologyGraph() {
const {
edges: storeEdges,
selectedNodeId,
setSelectedNode,
getFilteredNodes,
orientation,
viewMode,
highlightPath
highlightPath,
collapsedNodes
} = useTopologyStore(useShallow((s) => ({
edges: s.edges,
selectedNodeId: s.selectedNodeId,
setSelectedNode: s.setSelectedNode,
getFilteredNodes: s.getFilteredNodes,
orientation: s.orientation,
viewMode: s.viewMode,
highlightPath: s.highlightPath,
collapsedNodes: s.collapsedNodes
})));
const filteredNodesList = useFilteredNodes();
const { fitView } = useReactFlow();
// Watch for collapse/expand events to recenter the graph nicely
const collapsedNodesHash = collapsedNodes.join(',');
useEffect(() => {
// Wait for dagre to compute the new layout locations before centering
const timeoutId = setTimeout(() => {
// Find the first node available if none is selected
const currentNodes = useTopologyStore.getState().nodes;
const targetNodeId = selectedNodeId || currentNodes[0]?.id;
if (targetNodeId) {
fitView({ nodes: [{ id: targetNodeId }], duration: 800, maxZoom: 1 });
}
}, 100);
return () => clearTimeout(timeoutId);
}, [collapsedNodesHash, fitView, selectedNodeId]);
// Memoize the layout computation instead of useState + useEffect
const { nodes, edges } = useMemo(() => {
const filteredNodes = getFilteredNodes();
const filteredNodes = filteredNodesList;
if (filteredNodes.length === 0) {
return { nodes: [] as Node[], edges: [] as Edge[] };
@@ -171,6 +218,7 @@ export default function TopologyGraph() {
status: node.data.status,
category: node.data.category,
ip: node.data.ip,
hasChildren: collapsedNodes.includes(node.id) || storeEdges.some(e => e.source === node.id && nodeIds.has(e.target))
},
selected: node.id === selectedNodeId,
}));
@@ -209,7 +257,7 @@ export default function TopologyGraph() {
});
return getLayoutedElements(newNodes, newEdges, orientation);
}, [getFilteredNodes, storeEdges, selectedNodeId, orientation, viewMode, highlightPath]);
}, [filteredNodesList, storeEdges, selectedNodeId, orientation, viewMode, highlightPath]);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNode(node.id);
@@ -229,7 +277,7 @@ export default function TopologyGraph() {
nodeTypes={nodeTypes}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
minZoom={0.4}
maxZoom={2}
defaultEdgeOptions={{
type: 'smoothstep',

View File

@@ -48,15 +48,17 @@ export default function LeftPanel() {
);
const selectedNode = nodes.find(n => n.id === selectedNodeId);
const childNodes = nodes.filter(n => n.data.parentId === selectedNodeId);
const groupedChildren = useMemo(() => {
return childNodes.reduce((acc, node) => {
const { childNodes, groupedChildren } = useMemo(() => {
const children = nodes.filter(n => n.data.parentId === selectedNodeId);
const grouped = children.reduce((acc, node) => {
if (!acc[node.type]) acc[node.type] = [];
acc[node.type].push(node);
return acc;
}, {} as Record<NodeType, TopologyNode[]>);
}, [childNodes]);
return { childNodes: children, groupedChildren: grouped };
}, [nodes, selectedNodeId]);
return (
<aside className="w-72 bg-slate-800 border-r border-slate-700 flex flex-col" aria-label="Child nodes panel">

View File

@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react';
import { Info, FileCode, FolderOpen, BarChart3, Star, X, Terminal, Folder } from 'lucide-react';
import { useState, useCallback, useEffect } from 'react';
import { Info, FileCode, FolderOpen, BarChart3, Star, X, Terminal, Folder, Focus } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow';
import { useReactFlow } from '@xyflow/react';
import { useTopologyStore } from '../store/topologyStore';
import { getNodeColor, getStatusColor, getImportanceLabel, getImportanceColor } from '../utils/colors';
import { TopologyNode } from '../types';
@@ -17,19 +18,28 @@ const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
];
export default function RightPanel() {
const { nodes, selectedNodeId, setSelectedNode, openTerminal } = useTopologyStore(
const { nodes, selectedNodeId, setSelectedNode, openTerminal, getChildNodes } = useTopologyStore(
useShallow((s) => ({
nodes: s.nodes,
selectedNodeId: s.selectedNodeId,
setSelectedNode: s.setSelectedNode,
openTerminal: s.openTerminal,
getChildNodes: s.getChildNodes
}))
);
const { fitView } = useReactFlow();
const [activeTab, setActiveTab] = useState<TabId>('details');
const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const selectedNode = nodes.find(n => n.id === selectedNodeId);
// Fallback to details tab if usage is active but node isn't a service
useEffect(() => {
if (activeTab === 'usage' && selectedNode && selectedNode.type !== 'service') {
setActiveTab('details');
}
}, [selectedNode, activeTab]);
const handleTabKeyDown = useCallback((e: React.KeyboardEvent, currentIndex: number) => {
let newIndex = currentIndex;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
@@ -53,6 +63,14 @@ export default function RightPanel() {
tabEl?.focus();
}, []);
const handleFocus = useCallback(() => {
if (!selectedNodeId) return;
const children = getChildNodes();
const nodesToFocus = [{ id: selectedNodeId }, ...children.map(c => ({ id: c.id }))];
fitView({ nodes: nodesToFocus, duration: 800, padding: 0.2, maxZoom: 1.2 });
}, [selectedNodeId, getChildNodes, fitView]);
if (!selectedNode) {
return (
<aside className="w-80 bg-slate-800 border-l border-slate-700 flex flex-col items-center justify-center p-4" aria-label="Node details panel">
@@ -74,6 +92,14 @@ export default function RightPanel() {
<div className="flex items-center gap-1">
{isHost && selectedNodeId && (
<>
<button
onClick={handleFocus}
className="p-1 hover:bg-slate-700 rounded transition-colors"
aria-label="Focus node and children"
title="Focus node and children"
>
<Focus className="w-4 h-4 text-slate-400" aria-hidden="true" />
</button>
<button
onClick={() => setFileBrowserOpen(true)}
className="p-1 hover:bg-slate-700 rounded transition-colors"
@@ -112,8 +138,8 @@ export default function RightPanel() {
onClick={() => setActiveTab(tab.id)}
onKeyDown={(e) => handleTabKeyDown(e, index)}
className={`flex-1 flex items-center justify-center gap-1 py-3 text-xs transition-colors ${activeTab === tab.id
? 'text-indigo-400 border-b-2 border-indigo-400 bg-indigo-500/10'
: 'text-slate-400 hover:text-white'
? 'text-indigo-400 border-b-2 border-indigo-400 bg-indigo-500/10'
: 'text-slate-400 hover:text-white'
}`}
>
<span aria-hidden="true">{tab.icon}</span>

View File

@@ -94,39 +94,6 @@ describe('topologyStore', () => {
});
});
/* ----------------------------------------------------------------
* Filtering
* ------------------------------------------------------------- */
describe('getFilteredNodes', () => {
test('returns all nodes when no filters active', () => {
useTopologyStore.getState().setNodes(mockNodes);
const filtered = useTopologyStore.getState().getFilteredNodes();
expect(filtered).toHaveLength(4);
});
test('filters by search query', () => {
useTopologyStore.getState().setNodes(mockNodes);
useTopologyStore.getState().setSearchQuery('traefik');
const filtered = useTopologyStore.getState().getFilteredNodes();
expect(filtered.some(n => n.name === 'Traefik')).toBe(true);
});
test('filters by status', () => {
useTopologyStore.getState().setNodes(mockNodes);
useTopologyStore.getState().setStatusFilter('stopped');
const filtered = useTopologyStore.getState().getFilteredNodes();
expect(filtered.every(n => n.data.status === 'stopped')).toBe(true);
});
test('filters by type', () => {
useTopologyStore.getState().setNodes(mockNodes);
useTopologyStore.getState().toggleTypeFilter('service');
const filtered = useTopologyStore.getState().getFilteredNodes();
expect(filtered.every(n => n.type === 'service')).toBe(true);
});
});
/* ----------------------------------------------------------------
* Type filter toggle
* ------------------------------------------------------------- */

View File

@@ -39,6 +39,7 @@ interface TopologyState {
highlightPath: string[];
connectionStatus: 'ws' | 'polling' | 'disconnected';
staleWarningDismissed: boolean;
collapsedNodes: string[];
setConnectionStatus: (status: 'ws' | 'polling' | 'disconnected') => void;
setNodes: (nodes: TopologyNode[]) => void;
@@ -65,10 +66,10 @@ interface TopologyState {
toggleCommandPalette: () => void;
setHighlightPath: (ids: string[]) => void;
dismissStaleWarning: () => void;
toggleNodeCollapse: (nodeId: string) => void;
getSelectedNode: () => TopologyNode | null;
getChildNodes: () => TopologyNode[];
getFilteredNodes: () => TopologyNode[];
}
export const useTopologyStore = create<TopologyState>()(
@@ -99,6 +100,7 @@ export const useTopologyStore = create<TopologyState>()(
highlightPath: [],
connectionStatus: 'polling',
staleWarningDismissed: false,
collapsedNodes: [],
setNodes: (nodes) => set({ nodes }),
setEdges: (edges) => set({ edges }),
@@ -145,6 +147,36 @@ export const useTopologyStore = create<TopologyState>()(
dismissStaleWarning: () => set({ staleWarningDismissed: true }),
openTerminal: (host) => set({ terminalOpen: true, terminalHost: host }),
closeTerminal: () => set({ terminalOpen: false, terminalHost: null }),
toggleNodeCollapse: (nodeId) => set((state) => {
const isCollapsing = !state.collapsedNodes.includes(nodeId);
if (isCollapsing) {
// Standard collapse behavior: add the node to the list.
return {
collapsedNodes: [...state.collapsedNodes, nodeId]
};
} else {
// Expansion behavior (Progressive Disclosure):
// Remove the parent node from the collapsed list so its children are visible.
// But immediately collapse those newly revealed children so they don't burst open their own entire subtrees.
const immediateChildrenIds = state.nodes
.filter(n => n.data.parentId === nodeId)
.map(n => n.id);
const newCollapsedNodes = state.collapsedNodes.filter(id => id !== nodeId);
// Add the children to the collapsed list if they aren't already there
immediateChildrenIds.forEach(childId => {
if (!newCollapsedNodes.includes(childId)) {
newCollapsedNodes.push(childId);
}
});
return {
collapsedNodes: newCollapsedNodes
};
}
}),
getSelectedNode: () => {
const { nodes, selectedNodeId } = get();
@@ -157,62 +189,6 @@ export const useTopologyStore = create<TopologyState>()(
const selectedNode = nodes.find(n => n.id === selectedNodeId);
if (!selectedNode) return [];
return nodes.filter(n => n.data.parentId === selectedNodeId);
},
getFilteredNodes: () => {
const { nodes, viewMode, searchQuery, typeFilters, statusFilter } = get();
let filtered = nodes;
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(n =>
n.name.toLowerCase().includes(query) ||
n.data.ip?.toLowerCase().includes(query)
);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(n => n.data.status === statusFilter);
}
if (typeFilters.length > 0 && typeFilters.length < ALL_NODE_TYPES.length) {
filtered = filtered.filter(n => typeFilters.includes(n.type));
}
let allowedTypes: NodeType[] = [];
if (viewMode === 'network') {
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
} else if (viewMode === 'host') {
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
} else if (viewMode === 'service') {
allowedTypes = ['host_physical', 'host_vm', 'host_container', 'service', 'volume'];
} else if (viewMode === 'filesystem') {
allowedTypes = ['volume', 'mount', 'path'];
}
if (allowedTypes.length > 0) {
const nodeMap = new Map(nodes.map(n => [n.id, n]));
const includeSet = new Set<string>();
nodes.forEach(node => {
if (allowedTypes.includes(node.type)) {
includeSet.add(node.id);
let current: TopologyNode | undefined = node;
while (current?.data?.parentId) {
const parentId = current.data.parentId;
includeSet.add(parentId);
current = nodeMap.get(parentId);
if (!current) break;
}
}
});
filtered = filtered.filter(n => includeSet.has(n.id));
}
return filtered;
}
}),
{
@@ -227,7 +203,13 @@ export const useTopologyStore = create<TopologyState>()(
statusFilter: state.statusFilter,
leftPanelOpen: state.leftPanelOpen,
rightPanelOpen: state.rightPanelOpen,
pollInterval: state.pollInterval
pollInterval: state.pollInterval,
collapsedNodes: state.collapsedNodes,
nodes: state.nodes,
edges: state.edges,
hosts: state.hosts,
networkInfo: state.networkInfo,
lastUpdated: state.lastUpdated
})
}), { name: 'TopologyStore' }));
@@ -243,4 +225,116 @@ export const useChildNodes = () => useTopologyStore((s) => {
return nodes.filter(n => n.data.parentId === selectedNodeId);
});
export const useFilteredNodes = () => useTopologyStore((s) => s.getFilteredNodes());
import { useMemo } from 'react';
export const useFilteredNodes = () => {
const nodes = useTopologyStore((s) => s.nodes);
const viewMode = useTopologyStore((s) => s.viewMode);
const searchQuery = useTopologyStore((s) => s.searchQuery);
const typeFilters = useTopologyStore((s) => s.typeFilters);
const statusFilter = useTopologyStore((s) => s.statusFilter);
const collapsedNodes = useTopologyStore((s) => s.collapsedNodes);
return useMemo(() => {
let filtered = nodes;
if (searchQuery) {
const query = searchQuery.toLowerCase();
filtered = filtered.filter(n =>
n.name.toLowerCase().includes(query) ||
n.data.ip?.toLowerCase().includes(query)
);
}
if (statusFilter !== 'all') {
filtered = filtered.filter(n => n.data.status === statusFilter);
}
if (typeFilters.length > 0 && typeFilters.length < ALL_NODE_TYPES.length) {
filtered = filtered.filter(n => typeFilters.includes(n.type));
}
let allowedTypes: NodeType[] = [];
if (viewMode === 'network') {
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
} else if (viewMode === 'host') {
allowedTypes = ['gateway', 'vlan', 'wifi', 'host_physical', 'host_vm', 'host_container'];
} else if (viewMode === 'service') {
allowedTypes = ['host_physical', 'host_vm', 'host_container', 'service', 'volume'];
} else if (viewMode === 'filesystem') {
allowedTypes = ['volume', 'mount', 'path'];
}
if (allowedTypes.length > 0) {
const nodeMap = new Map(nodes.map(n => [n.id, n]));
const includeSet = new Set<string>();
nodes.forEach(node => {
if (allowedTypes.includes(node.type)) {
includeSet.add(node.id);
let current: TopologyNode | undefined = node;
while (current?.data?.parentId) {
const parentId = current.data.parentId;
includeSet.add(parentId);
current = nodeMap.get(parentId);
if (!current) break;
}
}
});
filtered = filtered.filter(n => includeSet.has(n.id));
}
// Filter out children of collapsed nodes
if (collapsedNodes.length > 0) {
const nodeMap = new Map(nodes.map(n => [n.id, n]));
const collapsedSet = new Set(collapsedNodes);
const visibilityCache = new Map<string, boolean>();
filtered = filtered.filter(node => {
// Start from the current node's parent
let currentId: string | undefined = node.data.parentId;
// Check if we already know the answer
if (currentId && visibilityCache.has(currentId)) {
return visibilityCache.get(currentId) !== false;
}
// Traverse up the tree to see if any ancestor is collapsed
let isHidden = false;
const traversalPath: string[] = [];
while (currentId) {
traversalPath.push(currentId);
// If this ancestor is collapsed, the child is hidden
if (collapsedSet.has(currentId)) {
isHidden = true;
break;
}
// If we hit an ancestor in the cache, use its result
if (visibilityCache.has(currentId)) {
isHidden = visibilityCache.get(currentId) === false;
break;
}
// Move up to the next parent
currentId = nodeMap.get(currentId)?.data?.parentId;
}
// Cache the result for this specific parent (and all parents in its path)
let parentToCache = node.data.parentId;
if (parentToCache) {
// If it's hidden, the immediate parent must be marked hidden so sibling nodes immediately abort
visibilityCache.set(parentToCache, !isHidden);
}
return !isHidden; // Show node if not hidden
});
}
return filtered;
}, [nodes, viewMode, searchQuery, typeFilters, statusFilter, collapsedNodes]);
};