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:
8
dist/index.html
vendored
8
dist/index.html
vendored
@@ -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>
|
||||
|
||||
31
src/App.tsx
31
src/App.tsx
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
})
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
* ------------------------------------------------------------- */
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user