Skip to content

Commit 1f5fbd5

Browse files
committed
show nodes in scrollable graph with update animations
1 parent c32ac2d commit 1f5fbd5

23 files changed

+611
-829
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
import { useCallback, useEffect, useMemo, useState, useRef } from 'react';
2+
import ReactFlow, {
3+
Controls,
4+
Background,
5+
useNodesState,
6+
ReactFlowProvider,
7+
Node as FlowNode,
8+
} from 'reactflow';
9+
import 'reactflow/dist/style.css';
10+
11+
import { Message } from '@/page';
12+
import { Badge } from '@/components/ui/badge';
13+
import ClearQueueModal from '@/components/ClearQueueModal';
14+
import Sidebar from '@/components/Sidebar';
15+
import { NODE_SPACING, TOP_OFFSET } from '@/lib/constants';
16+
import { CustomNode } from '@/components/CustomNode';
17+
import { getContrastTextColor } from '@/lib/utils';
18+
import { ModeToggle } from '@/components/ModeToggle';
19+
20+
export interface NodeData {
21+
vm_name: string;
22+
region: string;
23+
icon_name: string;
24+
label?: string;
25+
isUpdated?: boolean;
26+
color?: string;
27+
}
28+
29+
interface Node extends FlowNode {
30+
data: NodeData;
31+
}
32+
33+
function LabelNode({ data }: { data: { label: string; color: string } }) {
34+
return (
35+
<div
36+
className="flex items-center justify-center rounded-md px-3 py-1 font-bold"
37+
style={{
38+
backgroundColor: data.color,
39+
opacity: 0.9,
40+
color: getContrastTextColor(data.color),
41+
}}
42+
>
43+
{data.label}
44+
</div>
45+
);
46+
}
47+
48+
interface NodeGraphProps {
49+
isConnected: boolean;
50+
setShowConfirm: (show: boolean) => void;
51+
clearQueue: () => void;
52+
vmStates: Map<string, Message>;
53+
queueSize: number;
54+
messageCount: number;
55+
lastPoll: string;
56+
showConfirm: boolean;
57+
}
58+
59+
export default function NodeGraph({
60+
isConnected,
61+
setShowConfirm,
62+
clearQueue,
63+
vmStates,
64+
queueSize,
65+
messageCount,
66+
lastPoll,
67+
showConfirm,
68+
}: NodeGraphProps) {
69+
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
70+
const [highlightedRegion, setHighlightedRegion] = useState(null);
71+
const [updatedNodes, setUpdatedNodes] = useState<Set<string>>(new Set());
72+
const prevVmStatesRef = useRef<Map<string, Message>>(new Map());
73+
74+
// Detect changes in vmStates to track recently updated nodes
75+
useEffect(() => {
76+
const newUpdatedNodes = new Set<string>();
77+
78+
vmStates.forEach((vm, vmName) => {
79+
const prevVm = prevVmStatesRef.current.get(vmName);
80+
// If VM is new or has changed data, mark it as updated
81+
if (!prevVm || JSON.stringify(prevVm) !== JSON.stringify(vm)) {
82+
newUpdatedNodes.add(vmName);
83+
}
84+
});
85+
86+
prevVmStatesRef.current = new Map(vmStates);
87+
88+
if (newUpdatedNodes.size > 0) {
89+
console.log('2');
90+
setUpdatedNodes(newUpdatedNodes);
91+
92+
// Not the best way to do this, but it works for now
93+
94+
// Clear the updated status after animation duration
95+
const timeoutId = setTimeout(() => {
96+
setUpdatedNodes(new Set());
97+
}, 500);
98+
99+
return () => clearTimeout(timeoutId);
100+
}
101+
}, [vmStates]);
102+
103+
const regions = Array.from(vmStates.values()).reduce((acc, vm) => {
104+
acc[vm.region] = vm.color;
105+
return acc;
106+
}, {});
107+
108+
const nodes = Array.from(vmStates.values());
109+
110+
return (
111+
<div className="fixed inset-0 overflow-hidden bg-white dark:bg-black">
112+
<div className="absolute inset-0">
113+
<ReactFlowProvider>
114+
<Flow
115+
nodes={nodes}
116+
onNodeSelect={setSelectedNode}
117+
highlightedRegion={highlightedRegion}
118+
updatedNodes={updatedNodes}
119+
/>
120+
</ReactFlowProvider>
121+
</div>
122+
123+
{/* Header floating on top */}
124+
<header className="bg-background/50 absolute top-0 right-0 left-0 z-10 backdrop-blur-md">
125+
<div className="flex h-16 items-center justify-between px-4">
126+
<div className="flex items-center gap-2">
127+
<h1 className="text-xl font-bold">Network Visualization</h1>
128+
129+
<Badge
130+
variant={isConnected ? 'default' : 'outline'}
131+
className={`${isConnected ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}
132+
onClick={() => setShowConfirm(true)}
133+
>
134+
{isConnected ? 'Connected' : 'Disconnected'}
135+
</Badge>
136+
</div>
137+
<div className="ml-auto flex items-center gap-4">
138+
<div className="flex items-center gap-2">
139+
<span className="text-sm">Unique VMs:</span>
140+
<Badge>{vmStates.size}</Badge>
141+
</div>
142+
143+
<div className="flex items-center gap-2">
144+
<span className="text-sm">Queue Size:</span>
145+
<Badge>{queueSize}</Badge>
146+
</div>
147+
148+
<div className="flex items-center gap-2">
149+
<span className="text-sm">Messages Processed:</span>
150+
<Badge>{messageCount}</Badge>
151+
</div>
152+
153+
<div className="flex items-center gap-2">
154+
<span className="text-sm">Last Update:</span>
155+
<Badge>{lastPoll}</Badge>
156+
</div>
157+
</div>
158+
</div>
159+
</header>
160+
161+
{/* Sidebar floating on top */}
162+
<aside className="absolute top-16 left-0 z-10 hidden w-[240px] p-4 md:block">
163+
<Sidebar
164+
highlightedRegion={highlightedRegion}
165+
setHighlightedRegion={setHighlightedRegion}
166+
regions={regions}
167+
selectedNode={selectedNode}
168+
setShowConfirm={setShowConfirm}
169+
/>
170+
</aside>
171+
172+
{/* Region legend floating at the bottom */}
173+
<div className="absolute bottom-4 left-1/2 z-10 flex -translate-x-1/2 transform flex-wrap justify-center gap-3 rounded-lg bg-black/50 px-4 py-2 backdrop-blur-xl">
174+
{Object.entries(regions).map(([region, color]) => (
175+
<div key={region} className="flex items-center gap-2">
176+
<div
177+
className="h-3 w-3 rounded-full"
178+
style={{ backgroundColor: color as string }}
179+
></div>
180+
<span className="text-xs font-semibold text-gray-800 dark:text-white">{region}</span>
181+
</div>
182+
))}
183+
184+
{Object.keys(regions).length === 0 && (
185+
<span className="text-xs font-semibold text-gray-800 dark:text-white">No regions</span>
186+
)}
187+
</div>
188+
189+
{/* Mode toggle floating at the bottom */}
190+
<div className="absolute right-4 bottom-4">
191+
<ModeToggle />
192+
</div>
193+
194+
{/* Clear queue modal */}
195+
{showConfirm && <ClearQueueModal setShowConfirm={setShowConfirm} clearQueue={clearQueue} />}
196+
</div>
197+
);
198+
}
199+
200+
function Flow({
201+
nodes,
202+
onNodeSelect,
203+
highlightedRegion,
204+
updatedNodes,
205+
}: {
206+
nodes: Message[];
207+
onNodeSelect: (node: Node) => void;
208+
highlightedRegion: string | null;
209+
updatedNodes: Set<string>;
210+
}) {
211+
const [rfNodes, setRfNodes, onNodesChange] = useNodesState([]);
212+
const nodeTypes = useMemo(
213+
() => ({
214+
custom: CustomNode,
215+
label: LabelNode,
216+
}),
217+
[],
218+
);
219+
220+
const onNodeClick = useCallback(
221+
(_event, node) => {
222+
onNodeSelect(node);
223+
},
224+
[onNodeSelect],
225+
);
226+
227+
useEffect(() => {
228+
const nodesByRegion = nodes.reduce<Record<string, Message[]>>((acc, node) => {
229+
if (!acc[node.region]) {
230+
acc[node.region] = [];
231+
}
232+
acc[node.region].push(node);
233+
return acc;
234+
}, {});
235+
236+
const initialNodes = [];
237+
let currentX = 0;
238+
239+
Object.entries(nodesByRegion).forEach(([region, regionNodes]) => {
240+
if (regionNodes.length === 0) return;
241+
242+
// Calculate grid layout for this region's nodes
243+
const regionNodesCount = regionNodes.length;
244+
const rows = Math.ceil(Math.sqrt(regionNodesCount * 1.5));
245+
246+
const regionColor = regionNodes[0].color;
247+
if (!highlightedRegion || region == highlightedRegion) {
248+
// Add a label node for this region
249+
initialNodes.push({
250+
id: `label-${region}`,
251+
type: 'label',
252+
data: {
253+
label: region,
254+
color: regionColor,
255+
},
256+
position: {
257+
x: currentX + (Math.ceil(regionNodesCount / rows) * NODE_SPACING) / 2 - 40, // Center the label
258+
y: TOP_OFFSET - 40, // Position above the region's nodes
259+
},
260+
draggable: false,
261+
selectable: false,
262+
});
263+
}
264+
265+
regionNodes.forEach((vm, i) => {
266+
const col = Math.floor(i / rows);
267+
const row = i % rows;
268+
const shouldHide = highlightedRegion && vm.region !== highlightedRegion;
269+
const isUpdated = updatedNodes.has(vm.vm_name);
270+
271+
initialNodes.push({
272+
id: vm.vm_name,
273+
type: 'custom',
274+
data: {
275+
...vm,
276+
label: vm.vm_name,
277+
isUpdated, // Pass the update state to the custom node
278+
},
279+
position: {
280+
x: col * NODE_SPACING + currentX,
281+
y: row * NODE_SPACING + TOP_OFFSET,
282+
},
283+
style: shouldHide ? { display: 'none' } : {},
284+
});
285+
});
286+
287+
// Update X position for next region (add padding between regions)
288+
const colsInRegion = Math.ceil(regionNodesCount / rows);
289+
currentX += colsInRegion * NODE_SPACING + NODE_SPACING; // Extra spacing between regions
290+
});
291+
292+
setRfNodes(initialNodes);
293+
}, [nodes, setRfNodes, highlightedRegion, updatedNodes]);
294+
295+
return (
296+
<ReactFlow
297+
attributionPosition="top-right"
298+
onNodeClick={onNodeClick}
299+
nodes={rfNodes}
300+
edges={[]}
301+
onNodesChange={onNodesChange}
302+
fitView
303+
nodeTypes={nodeTypes}
304+
nodesDraggable={false}
305+
nodesConnectable={false}
306+
className="bg-gray-100 dark:bg-slate-900"
307+
>
308+
<Controls />
309+
<Background />
310+
</ReactFlow>
311+
);
312+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
interface ClearQueueModalProps {
2+
setShowConfirm: (show: boolean) => void;
3+
clearQueue: () => void;
4+
}
5+
6+
export default function ClearQueueModal({ setShowConfirm, clearQueue }: ClearQueueModalProps) {
7+
return (
8+
<div className="fixed inset-0 z-10 flex items-center justify-center bg-black/50">
9+
<div className="w-full max-w-md rounded-lg bg-white p-6 shadow-lg">
10+
<h2 className="mb-4 text-lg font-bold">Confirm Queue Clear</h2>
11+
<p className="mb-6">Are you sure you want to clear the queue? This cannot be undone.</p>
12+
<div className="flex justify-end gap-3">
13+
<button
14+
onClick={() => setShowConfirm(false)}
15+
className="cursor-pointer rounded border border-gray-300 px-4 py-2 hover:bg-gray-100"
16+
>
17+
Cancel
18+
</button>
19+
<button
20+
onClick={clearQueue}
21+
className="cursor-pointer rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
22+
>
23+
Clear Queue
24+
</button>
25+
</div>
26+
</div>
27+
</div>
28+
);
29+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { Message } from '@/page';
2+
3+
interface CustomNodeProps {
4+
data: Message & {
5+
isUpdated: boolean;
6+
label: string;
7+
};
8+
}
9+
10+
export function CustomNode({ data }: CustomNodeProps) {
11+
return (
12+
<div
13+
className={`relative flex items-center justify-center rounded-full bg-gray-300 ${data.isUpdated ? 'animate-pulse' : ''}`}
14+
style={{
15+
width: `38px`,
16+
height: `38px`,
17+
backgroundColor: data.color,
18+
}}
19+
>
20+
<div
21+
className="absolute flex items-center justify-center rounded-full bg-gray-500"
22+
style={{
23+
width: `${data.isUpdated ? 0 : 32}px`,
24+
height: `${data.isUpdated ? 0 : 32}px`,
25+
}}
26+
>
27+
{data.icon_name}
28+
</div>
29+
</div>
30+
);
31+
}

0 commit comments

Comments
 (0)