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.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <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"> <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> <script type="module" crossorigin src="/assets/index-CTcm3RSe.js"></script>
<link rel="modulepreload" crossorigin href="/assets/graph-vendor-C44rQwKI.js"> <link rel="modulepreload" crossorigin href="/assets/graph-vendor-pGkIx_vZ.js">
<link rel="modulepreload" crossorigin href="/assets/ui-vendor-DpLh-vKM.js"> <link rel="modulepreload" crossorigin href="/assets/ui-vendor-tLSpD589.js">
<link rel="stylesheet" crossorigin href="/assets/index-soHg8pn4.css"> <link rel="stylesheet" crossorigin href="/assets/index-BjyEYKkh.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

View File

@@ -86,10 +86,15 @@ function App() {
const pollIntervalRef = useRef(pollInterval); const pollIntervalRef = useRef(pollInterval);
pollIntervalRef.current = pollInterval; pollIntervalRef.current = pollInterval;
const loadData = useCallback(async () => { const loadData = useCallback(async (isBackgroundPoll = false) => {
if (isLoadingRef.current) return; if (isLoadingRef.current) return;
// 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); setIsLoading(true);
}
try { try {
const response = await fetch(`${API_BASE_URL}/api/discover`, { const response = await fetch(`${API_BASE_URL}/api/discover`, {
@@ -184,18 +189,20 @@ function App() {
}, [toggleCommandPalette]); }, [toggleCommandPalette]);
useEffect(() => { useEffect(() => {
loadData(); const isInitialLoad = useTopologyStore.getState().nodes.length === 0;
loadData(!isInitialLoad); // Poll in background if we already have nodes
}, []); }, []);
useEffect(() => { 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); return () => clearInterval(intervalId);
}, [loadData]); }, [loadData, pollInterval]);
// --- WebSocket connection (websocket-engineer skill) --- // --- WebSocket connection (websocket-engineer skill) ---
useEffect(() => { useEffect(() => {
const socket: Socket = ioClient(API_BASE_URL, { const socket: Socket = ioClient(API_BASE_URL, {
transports: ['websocket', 'polling'], transports: ['polling', 'websocket'],
reconnectionDelay: 1000, reconnectionDelay: 1000,
reconnectionDelayMax: 5000, reconnectionDelayMax: 5000,
reconnectionAttempts: Infinity, reconnectionAttempts: Infinity,
@@ -214,7 +221,14 @@ function App() {
}); });
// Listen for real-time topology updates // Listen for real-time topology updates
let lastWsUpdate = 0;
socket.on('topology:update', (data: ApiDiscoveryResponse) => { 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) { if (data?.hosts) {
const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({ const discoveredHosts: DiscoveredHost[] = data.hosts.map((h: ApiHost) => ({
name: h.name, name: h.name,
@@ -288,9 +302,10 @@ const Footer = memo(function Footer() {
const pollIntervalRef = useRef(pollInterval); const pollIntervalRef = useRef(pollInterval);
pollIntervalRef.current = pollInterval; pollIntervalRef.current = pollInterval;
const formatTime = (date: Date | null) => { const formatTime = (date: Date | string | null) => {
if (!date) return 'Never'; if (!date) return 'Never';
return date.toLocaleTimeString(); const d = new Date(date);
return isNaN(d.getTime()) ? 'Never' : d.toLocaleTimeString();
}; };
useEffect(() => { useEffect(() => {

View File

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

View File

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

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, memo } from 'react'; import { useCallback, useMemo, memo, useEffect } from 'react';
import { import {
ReactFlow, ReactFlow,
Background, Background,
@@ -9,13 +9,14 @@ import {
NodeProps, NodeProps,
Handle, Handle,
Position, Position,
useReactFlow
} from '@xyflow/react'; } from '@xyflow/react';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import dagre from 'dagre'; import dagre from 'dagre';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useTopologyStore } from '../../store/topologyStore'; import { useTopologyStore } from '../../store/topologyStore';
import { getNodeColor, getStatusColor } from '../../utils/colors'; 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'; import { NodeType, ServiceCategory } from '../../types';
const nodeIcons: Record<NodeType, React.ReactNode> = { const nodeIcons: Record<NodeType, React.ReactNode> = {
@@ -35,14 +36,20 @@ const nodeIcons: Record<NodeType, React.ReactNode> = {
}; };
const CustomNode = memo(function CustomNode({ data, selected, id }: NodeProps) { const CustomNode = memo(function CustomNode({ data, selected, id }: NodeProps) {
const highlightPath = useTopologyStore((s) => s.highlightPath); const { highlightPath, collapsedNodes, toggleNodeCollapse } = useTopologyStore(useShallow(s => ({
const nodeData = data as { type?: NodeType; category?: ServiceCategory; status?: 'running' | 'stopped' | 'unknown'; label?: string; ip?: string }; 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 nodeColor = getNodeColor(nodeData.type || 'service', nodeData.category);
const statusColor = getStatusColor(nodeData.status || 'unknown'); const statusColor = getStatusColor(nodeData.status || 'unknown');
const isHighlighted = highlightPath.includes(id); const isHighlighted = highlightPath.includes(id);
const isDimmed = highlightPath.length > 0 && !isHighlighted; const isDimmed = highlightPath.length > 0 && !isHighlighted;
const isCollapsed = collapsedNodes.includes(id);
return ( return (
<div className="relative group">
<div <div
className={`px-4 py-3 rounded-xl border-2 transition-all duration-200 ${selected 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]' ? 'border-sky-400 shadow-lg shadow-sky-400/20 scale-[1.02]'
@@ -90,6 +97,24 @@ const CustomNode = memo(function CustomNode({ data, selected, id }: NodeProps) {
<Handle type="source" position={Position.Right} className="!bg-slate-400" /> <Handle type="source" position={Position.Right} className="!bg-slate-400" />
</div> </div>
{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 }; return { nodes: layoutedNodes, edges };
} }
import { useFilteredNodes } from '../../store/topologyStore';
export default function TopologyGraph() { export default function TopologyGraph() {
const { const {
edges: storeEdges, edges: storeEdges,
selectedNodeId, selectedNodeId,
setSelectedNode, setSelectedNode,
getFilteredNodes,
orientation, orientation,
viewMode, viewMode,
highlightPath highlightPath,
collapsedNodes
} = useTopologyStore(useShallow((s) => ({ } = useTopologyStore(useShallow((s) => ({
edges: s.edges, edges: s.edges,
selectedNodeId: s.selectedNodeId, selectedNodeId: s.selectedNodeId,
setSelectedNode: s.setSelectedNode, setSelectedNode: s.setSelectedNode,
getFilteredNodes: s.getFilteredNodes,
orientation: s.orientation, orientation: s.orientation,
viewMode: s.viewMode, viewMode: s.viewMode,
highlightPath: s.highlightPath, 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 // Memoize the layout computation instead of useState + useEffect
const { nodes, edges } = useMemo(() => { const { nodes, edges } = useMemo(() => {
const filteredNodes = getFilteredNodes(); const filteredNodes = filteredNodesList;
if (filteredNodes.length === 0) { if (filteredNodes.length === 0) {
return { nodes: [] as Node[], edges: [] as Edge[] }; return { nodes: [] as Node[], edges: [] as Edge[] };
@@ -171,6 +218,7 @@ export default function TopologyGraph() {
status: node.data.status, status: node.data.status,
category: node.data.category, category: node.data.category,
ip: node.data.ip, ip: node.data.ip,
hasChildren: collapsedNodes.includes(node.id) || storeEdges.some(e => e.source === node.id && nodeIds.has(e.target))
}, },
selected: node.id === selectedNodeId, selected: node.id === selectedNodeId,
})); }));
@@ -209,7 +257,7 @@ export default function TopologyGraph() {
}); });
return getLayoutedElements(newNodes, newEdges, orientation); return getLayoutedElements(newNodes, newEdges, orientation);
}, [getFilteredNodes, storeEdges, selectedNodeId, orientation, viewMode, highlightPath]); }, [filteredNodesList, storeEdges, selectedNodeId, orientation, viewMode, highlightPath]);
const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => { const onNodeClick = useCallback((_: React.MouseEvent, node: Node) => {
setSelectedNode(node.id); setSelectedNode(node.id);
@@ -229,7 +277,7 @@ export default function TopologyGraph() {
nodeTypes={nodeTypes} nodeTypes={nodeTypes}
fitView fitView
fitViewOptions={{ padding: 0.2 }} fitViewOptions={{ padding: 0.2 }}
minZoom={0.1} minZoom={0.4}
maxZoom={2} maxZoom={2}
defaultEdgeOptions={{ defaultEdgeOptions={{
type: 'smoothstep', type: 'smoothstep',

View File

@@ -48,15 +48,17 @@ export default function LeftPanel() {
); );
const selectedNode = nodes.find(n => n.id === selectedNodeId); const selectedNode = nodes.find(n => n.id === selectedNodeId);
const childNodes = nodes.filter(n => n.data.parentId === selectedNodeId);
const groupedChildren = useMemo(() => { const { childNodes, groupedChildren } = useMemo(() => {
return childNodes.reduce((acc, node) => { const children = nodes.filter(n => n.data.parentId === selectedNodeId);
const grouped = children.reduce((acc, node) => {
if (!acc[node.type]) acc[node.type] = []; if (!acc[node.type]) acc[node.type] = [];
acc[node.type].push(node); acc[node.type].push(node);
return acc; return acc;
}, {} as Record<NodeType, TopologyNode[]>); }, {} as Record<NodeType, TopologyNode[]>);
}, [childNodes]);
return { childNodes: children, groupedChildren: grouped };
}, [nodes, selectedNodeId]);
return ( return (
<aside className="w-72 bg-slate-800 border-r border-slate-700 flex flex-col" aria-label="Child nodes panel"> <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 { useState, useCallback, useEffect } from 'react';
import { Info, FileCode, FolderOpen, BarChart3, Star, X, Terminal, Folder } from 'lucide-react'; import { Info, FileCode, FolderOpen, BarChart3, Star, X, Terminal, Folder, Focus } from 'lucide-react';
import { useShallow } from 'zustand/react/shallow'; import { useShallow } from 'zustand/react/shallow';
import { useReactFlow } from '@xyflow/react';
import { useTopologyStore } from '../store/topologyStore'; import { useTopologyStore } from '../store/topologyStore';
import { getNodeColor, getStatusColor, getImportanceLabel, getImportanceColor } from '../utils/colors'; import { getNodeColor, getStatusColor, getImportanceLabel, getImportanceColor } from '../utils/colors';
import { TopologyNode } from '../types'; import { TopologyNode } from '../types';
@@ -17,19 +18,28 @@ const tabs: { id: TabId; label: string; icon: React.ReactNode }[] = [
]; ];
export default function RightPanel() { export default function RightPanel() {
const { nodes, selectedNodeId, setSelectedNode, openTerminal } = useTopologyStore( const { nodes, selectedNodeId, setSelectedNode, openTerminal, getChildNodes } = useTopologyStore(
useShallow((s) => ({ useShallow((s) => ({
nodes: s.nodes, nodes: s.nodes,
selectedNodeId: s.selectedNodeId, selectedNodeId: s.selectedNodeId,
setSelectedNode: s.setSelectedNode, setSelectedNode: s.setSelectedNode,
openTerminal: s.openTerminal, openTerminal: s.openTerminal,
getChildNodes: s.getChildNodes
})) }))
); );
const { fitView } = useReactFlow();
const [activeTab, setActiveTab] = useState<TabId>('details'); const [activeTab, setActiveTab] = useState<TabId>('details');
const [fileBrowserOpen, setFileBrowserOpen] = useState(false); const [fileBrowserOpen, setFileBrowserOpen] = useState(false);
const selectedNode = nodes.find(n => n.id === selectedNodeId); 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) => { const handleTabKeyDown = useCallback((e: React.KeyboardEvent, currentIndex: number) => {
let newIndex = currentIndex; let newIndex = currentIndex;
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { if (e.key === 'ArrowRight' || e.key === 'ArrowDown') {
@@ -53,6 +63,14 @@ export default function RightPanel() {
tabEl?.focus(); 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) { if (!selectedNode) {
return ( 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"> <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"> <div className="flex items-center gap-1">
{isHost && selectedNodeId && ( {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 <button
onClick={() => setFileBrowserOpen(true)} onClick={() => setFileBrowserOpen(true)}
className="p-1 hover:bg-slate-700 rounded transition-colors" className="p-1 hover:bg-slate-700 rounded transition-colors"

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 * Type filter toggle
* ------------------------------------------------------------- */ * ------------------------------------------------------------- */

View File

@@ -39,6 +39,7 @@ interface TopologyState {
highlightPath: string[]; highlightPath: string[];
connectionStatus: 'ws' | 'polling' | 'disconnected'; connectionStatus: 'ws' | 'polling' | 'disconnected';
staleWarningDismissed: boolean; staleWarningDismissed: boolean;
collapsedNodes: string[];
setConnectionStatus: (status: 'ws' | 'polling' | 'disconnected') => void; setConnectionStatus: (status: 'ws' | 'polling' | 'disconnected') => void;
setNodes: (nodes: TopologyNode[]) => void; setNodes: (nodes: TopologyNode[]) => void;
@@ -65,10 +66,10 @@ interface TopologyState {
toggleCommandPalette: () => void; toggleCommandPalette: () => void;
setHighlightPath: (ids: string[]) => void; setHighlightPath: (ids: string[]) => void;
dismissStaleWarning: () => void; dismissStaleWarning: () => void;
toggleNodeCollapse: (nodeId: string) => void;
getSelectedNode: () => TopologyNode | null; getSelectedNode: () => TopologyNode | null;
getChildNodes: () => TopologyNode[]; getChildNodes: () => TopologyNode[];
getFilteredNodes: () => TopologyNode[];
} }
export const useTopologyStore = create<TopologyState>()( export const useTopologyStore = create<TopologyState>()(
@@ -99,6 +100,7 @@ export const useTopologyStore = create<TopologyState>()(
highlightPath: [], highlightPath: [],
connectionStatus: 'polling', connectionStatus: 'polling',
staleWarningDismissed: false, staleWarningDismissed: false,
collapsedNodes: [],
setNodes: (nodes) => set({ nodes }), setNodes: (nodes) => set({ nodes }),
setEdges: (edges) => set({ edges }), setEdges: (edges) => set({ edges }),
@@ -145,6 +147,36 @@ export const useTopologyStore = create<TopologyState>()(
dismissStaleWarning: () => set({ staleWarningDismissed: true }), dismissStaleWarning: () => set({ staleWarningDismissed: true }),
openTerminal: (host) => set({ terminalOpen: true, terminalHost: host }), openTerminal: (host) => set({ terminalOpen: true, terminalHost: host }),
closeTerminal: () => set({ terminalOpen: false, terminalHost: null }), 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: () => { getSelectedNode: () => {
const { nodes, selectedNodeId } = get(); const { nodes, selectedNodeId } = get();
@@ -157,11 +189,53 @@ export const useTopologyStore = create<TopologyState>()(
const selectedNode = nodes.find(n => n.id === selectedNodeId); const selectedNode = nodes.find(n => n.id === selectedNodeId);
if (!selectedNode) return []; if (!selectedNode) return [];
return nodes.filter(n => n.data.parentId === selectedNodeId); return nodes.filter(n => n.data.parentId === selectedNodeId);
}, }
}),
{
name: 'homelab-topology-settings',
version: 2,
storage: createJSONStorage(() => localStorage),
partialize: (state: TopologyState) => ({
viewMode: state.viewMode,
orientation: state.orientation,
searchQuery: state.searchQuery,
typeFilters: state.typeFilters,
statusFilter: state.statusFilter,
leftPanelOpen: state.leftPanelOpen,
rightPanelOpen: state.rightPanelOpen,
pollInterval: state.pollInterval,
collapsedNodes: state.collapsedNodes,
nodes: state.nodes,
edges: state.edges,
hosts: state.hosts,
networkInfo: state.networkInfo,
lastUpdated: state.lastUpdated
})
}), { name: 'TopologyStore' }));
getFilteredNodes: () => { // Focused selector hooks to avoid unnecessary re-renders
const { nodes, viewMode, searchQuery, typeFilters, statusFilter } = get(); export const useSelectedNode = () => useTopologyStore((s) => {
const { nodes, selectedNodeId } = s;
return nodes.find(n => n.id === selectedNodeId) || null;
});
export const useChildNodes = () => useTopologyStore((s) => {
const { nodes, selectedNodeId } = s;
if (!selectedNodeId) return [];
return nodes.filter(n => n.data.parentId === selectedNodeId);
});
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; let filtered = nodes;
if (searchQuery) { if (searchQuery) {
@@ -212,35 +286,55 @@ export const useTopologyStore = create<TopologyState>()(
filtered = filtered.filter(n => includeSet.has(n.id)); filtered = filtered.filter(n => includeSet.has(n.id));
} }
return filtered; // 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;
} }
}),
{
name: 'homelab-topology-settings',
version: 2,
storage: createJSONStorage(() => localStorage),
partialize: (state: TopologyState) => ({
viewMode: state.viewMode,
orientation: state.orientation,
searchQuery: state.searchQuery,
typeFilters: state.typeFilters,
statusFilter: state.statusFilter,
leftPanelOpen: state.leftPanelOpen,
rightPanelOpen: state.rightPanelOpen,
pollInterval: state.pollInterval
})
}), { name: 'TopologyStore' }));
// Focused selector hooks to avoid unnecessary re-renders // Traverse up the tree to see if any ancestor is collapsed
export const useSelectedNode = () => useTopologyStore((s) => { let isHidden = false;
const { nodes, selectedNodeId } = s; const traversalPath: string[] = [];
return nodes.find(n => n.id === selectedNodeId) || null;
});
export const useChildNodes = () => useTopologyStore((s) => { while (currentId) {
const { nodes, selectedNodeId } = s; traversalPath.push(currentId);
if (!selectedNodeId) return [];
return nodes.filter(n => n.data.parentId === selectedNodeId);
});
export const useFilteredNodes = () => useTopologyStore((s) => s.getFilteredNodes()); // 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]);
};