Skip to content

Commit 5820e8c

Browse files
feat(gui): add M3 network topology visualization
Backend: - Add topology API routes (list, get by name) - Add topology schemas for nodes and links - Use spring layout algorithm for node positioning - Load topologies from existing data/raw files Frontend: - Add NetworkGraph component with SVG rendering - Add pan, zoom, and reset controls - Add node/link tooltips on hover - Add UtilizationLegend component with color scale - Add TopologyPage for standalone topology viewing - Add topology tab to RunDetailPage - Add Topology nav item in sidebar Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 24e3e14 commit 5820e8c

13 files changed

Lines changed: 894 additions & 8 deletions

File tree

frontend/src/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { NewRunPage } from '@/pages/NewRunPage'
55
import { RunDetailPage } from '@/pages/RunDetailPage'
66
import { ConfigEditorPage } from '@/pages/ConfigEditorPage'
77
import { SettingsPage } from '@/pages/SettingsPage'
8+
import { TopologyPage } from '@/pages/TopologyPage'
89

910
function App() {
1011
return (
@@ -15,6 +16,7 @@ function App() {
1516
<Route path="runs/new" element={<NewRunPage />} />
1617
<Route path="runs/:runId" element={<RunDetailPage />} />
1718
<Route path="config" element={<ConfigEditorPage />} />
19+
<Route path="topology" element={<TopologyPage />} />
1820
<Route path="settings" element={<SettingsPage />} />
1921
</Route>
2022
</Routes>

frontend/src/api/client.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import type {
88
ArtifactListResponse,
99
HealthResponse,
1010
VersionResponse,
11+
TopologyListResponse,
12+
TopologyResponse,
1113
} from './types'
1214

1315
const api = axios.create({
@@ -84,4 +86,17 @@ export const systemApi = {
8486
},
8587
}
8688

89+
// Topology API
90+
export const topologyApi = {
91+
list: async () => {
92+
const { data } = await api.get<TopologyListResponse>('/topology')
93+
return data
94+
},
95+
96+
get: async (name: string) => {
97+
const { data } = await api.get<TopologyResponse>(`/topology/${name}`)
98+
return data
99+
},
100+
}
101+
87102
export default api

frontend/src/api/types.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,3 +70,36 @@ export interface VersionResponse {
7070
api_version: string
7171
fusion_version: string
7272
}
73+
74+
// Topology types
75+
export interface TopologyNode {
76+
id: string
77+
label: string
78+
x: number
79+
y: number
80+
type: string
81+
}
82+
83+
export interface TopologyLink {
84+
id: string
85+
source: string
86+
target: string
87+
length_km: number
88+
utilization: number
89+
}
90+
91+
export interface TopologyResponse {
92+
name: string
93+
nodes: TopologyNode[]
94+
links: TopologyLink[]
95+
}
96+
97+
export interface TopologyListItem {
98+
name: string
99+
node_count: number
100+
link_count: number
101+
}
102+
103+
export interface TopologyListResponse {
104+
topologies: TopologyListItem[]
105+
}

frontend/src/components/layout/Sidebar.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,12 @@
11
import { NavLink } from 'react-router-dom'
2-
import { LayoutDashboard, Play, Settings, FileCode } from 'lucide-react'
2+
import { LayoutDashboard, Play, Settings, FileCode, Network } from 'lucide-react'
33
import { cn } from '@/lib/utils'
44

55
const navItems = [
66
{ to: '/', icon: LayoutDashboard, label: 'Runs' },
77
{ to: '/runs/new', icon: Play, label: 'New Run' },
88
{ to: '/config', icon: FileCode, label: 'Config Editor' },
9+
{ to: '/topology', icon: Network, label: 'Topology' },
910
]
1011

1112
export function Sidebar() {
Lines changed: 243 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,243 @@
1+
import { useState, useRef, useCallback, useMemo } from 'react'
2+
import type { TopologyNode, TopologyLink } from '@/api/types'
3+
4+
interface NetworkGraphProps {
5+
nodes: TopologyNode[]
6+
links: TopologyLink[]
7+
width?: number
8+
height?: number
9+
onNodeClick?: (node: TopologyNode) => void
10+
onLinkClick?: (link: TopologyLink) => void
11+
}
12+
13+
interface TooltipState {
14+
visible: boolean
15+
x: number
16+
y: number
17+
content: string
18+
}
19+
20+
// Color scale for utilization (0-100%)
21+
function getUtilizationColor(utilization: number): string {
22+
if (utilization <= 0) return '#94a3b8' // gray-400
23+
if (utilization < 25) return '#22c55e' // green-500
24+
if (utilization < 50) return '#84cc16' // lime-500
25+
if (utilization < 75) return '#eab308' // yellow-500
26+
if (utilization < 90) return '#f97316' // orange-500
27+
return '#ef4444' // red-500
28+
}
29+
30+
export function NetworkGraph({
31+
nodes,
32+
links,
33+
width = 800,
34+
height = 600,
35+
onNodeClick,
36+
onLinkClick,
37+
}: NetworkGraphProps) {
38+
const svgRef = useRef<SVGSVGElement>(null)
39+
const [tooltip, setTooltip] = useState<TooltipState>({
40+
visible: false,
41+
x: 0,
42+
y: 0,
43+
content: '',
44+
})
45+
const [transform, setTransform] = useState({ x: 0, y: 0, scale: 1 })
46+
const [isPanning, setIsPanning] = useState(false)
47+
const [panStart, setPanStart] = useState({ x: 0, y: 0 })
48+
49+
// Create a map for quick node lookup
50+
const nodeMap = useMemo(() => {
51+
const map = new Map<string, TopologyNode>()
52+
nodes.forEach((node) => map.set(node.id, node))
53+
return map
54+
}, [nodes])
55+
56+
// Calculate bounds and center the graph
57+
const viewBox = useMemo(() => {
58+
if (nodes.length === 0) return { minX: 0, minY: 0, width: 800, height: 600 }
59+
60+
const xs = nodes.map((n) => n.x)
61+
const ys = nodes.map((n) => n.y)
62+
const minX = Math.min(...xs) - 50
63+
const maxX = Math.max(...xs) + 50
64+
const minY = Math.min(...ys) - 50
65+
const maxY = Math.max(...ys) + 50
66+
67+
return {
68+
minX,
69+
minY,
70+
width: maxX - minX,
71+
height: maxY - minY,
72+
}
73+
}, [nodes])
74+
75+
const handleMouseDown = useCallback(
76+
(e: React.MouseEvent) => {
77+
if (e.button === 0) {
78+
setIsPanning(true)
79+
setPanStart({ x: e.clientX - transform.x, y: e.clientY - transform.y })
80+
}
81+
},
82+
[transform]
83+
)
84+
85+
const handleMouseMove = useCallback(
86+
(e: React.MouseEvent) => {
87+
if (isPanning) {
88+
setTransform((prev) => ({
89+
...prev,
90+
x: e.clientX - panStart.x,
91+
y: e.clientY - panStart.y,
92+
}))
93+
}
94+
},
95+
[isPanning, panStart]
96+
)
97+
98+
const handleMouseUp = useCallback(() => {
99+
setIsPanning(false)
100+
}, [])
101+
102+
const handleWheel = useCallback((e: React.WheelEvent) => {
103+
e.preventDefault()
104+
const delta = e.deltaY > 0 ? 0.9 : 1.1
105+
setTransform((prev) => ({
106+
...prev,
107+
scale: Math.min(Math.max(prev.scale * delta, 0.1), 5),
108+
}))
109+
}, [])
110+
111+
const showTooltip = useCallback((e: React.MouseEvent, content: string) => {
112+
const rect = svgRef.current?.getBoundingClientRect()
113+
if (rect) {
114+
setTooltip({
115+
visible: true,
116+
x: e.clientX - rect.left + 10,
117+
y: e.clientY - rect.top - 10,
118+
content,
119+
})
120+
}
121+
}, [])
122+
123+
const hideTooltip = useCallback(() => {
124+
setTooltip((prev) => ({ ...prev, visible: false }))
125+
}, [])
126+
127+
const resetView = useCallback(() => {
128+
setTransform({ x: 0, y: 0, scale: 1 })
129+
}, [])
130+
131+
return (
132+
<div className="relative">
133+
{/* Controls */}
134+
<div className="absolute right-2 top-2 z-10 flex gap-1">
135+
<button
136+
onClick={() => setTransform((p) => ({ ...p, scale: p.scale * 1.2 }))}
137+
className="rounded bg-white px-2 py-1 text-sm shadow hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600"
138+
title="Zoom in"
139+
>
140+
+
141+
</button>
142+
<button
143+
onClick={() => setTransform((p) => ({ ...p, scale: p.scale * 0.8 }))}
144+
className="rounded bg-white px-2 py-1 text-sm shadow hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600"
145+
title="Zoom out"
146+
>
147+
-
148+
</button>
149+
<button
150+
onClick={resetView}
151+
className="rounded bg-white px-2 py-1 text-sm shadow hover:bg-gray-100 dark:bg-gray-700 dark:hover:bg-gray-600"
152+
title="Reset view"
153+
>
154+
Reset
155+
</button>
156+
</div>
157+
158+
{/* Tooltip */}
159+
{tooltip.visible && (
160+
<div
161+
className="pointer-events-none absolute z-20 rounded bg-gray-900 px-2 py-1 text-xs text-white shadow-lg dark:bg-gray-700"
162+
style={{ left: tooltip.x, top: tooltip.y }}
163+
>
164+
{tooltip.content}
165+
</div>
166+
)}
167+
168+
{/* SVG Canvas */}
169+
<svg
170+
ref={svgRef}
171+
width={width}
172+
height={height}
173+
viewBox={`${viewBox.minX} ${viewBox.minY} ${viewBox.width} ${viewBox.height}`}
174+
className="cursor-grab rounded-lg border border-gray-200 bg-white active:cursor-grabbing dark:border-gray-700 dark:bg-gray-800"
175+
onMouseDown={handleMouseDown}
176+
onMouseMove={handleMouseMove}
177+
onMouseUp={handleMouseUp}
178+
onMouseLeave={handleMouseUp}
179+
onWheel={handleWheel}
180+
>
181+
<g
182+
transform={`translate(${transform.x / transform.scale}, ${transform.y / transform.scale}) scale(${transform.scale})`}
183+
>
184+
{/* Links */}
185+
{links.map((link) => {
186+
const source = nodeMap.get(link.source)
187+
const target = nodeMap.get(link.target)
188+
if (!source || !target) return null
189+
190+
return (
191+
<line
192+
key={link.id}
193+
x1={source.x}
194+
y1={source.y}
195+
x2={target.x}
196+
y2={target.y}
197+
stroke={getUtilizationColor(link.utilization)}
198+
strokeWidth={3}
199+
className="cursor-pointer transition-all hover:stroke-[5]"
200+
onMouseEnter={(e) =>
201+
showTooltip(
202+
e,
203+
`${link.source} - ${link.target}\nLength: ${link.length_km.toFixed(0)} km\nUtilization: ${link.utilization.toFixed(1)}%`
204+
)
205+
}
206+
onMouseLeave={hideTooltip}
207+
onClick={() => onLinkClick?.(link)}
208+
/>
209+
)
210+
})}
211+
212+
{/* Nodes */}
213+
{nodes.map((node) => (
214+
<g
215+
key={node.id}
216+
transform={`translate(${node.x}, ${node.y})`}
217+
className="cursor-pointer"
218+
onMouseEnter={(e) => showTooltip(e, `Node ${node.id}\n${node.label}`)}
219+
onMouseLeave={hideTooltip}
220+
onClick={() => onNodeClick?.(node)}
221+
>
222+
<circle
223+
r={12}
224+
fill="#3b82f6"
225+
stroke="#1d4ed8"
226+
strokeWidth={2}
227+
className="transition-all hover:fill-blue-400"
228+
/>
229+
<text
230+
y={25}
231+
textAnchor="middle"
232+
className="fill-gray-700 text-xs font-medium dark:fill-gray-300"
233+
style={{ fontSize: '10px' }}
234+
>
235+
{node.id}
236+
</text>
237+
</g>
238+
))}
239+
</g>
240+
</svg>
241+
</div>
242+
)
243+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
const legendItems = [
2+
{ label: 'Idle', color: '#94a3b8', range: '0%' },
3+
{ label: 'Low', color: '#22c55e', range: '1-24%' },
4+
{ label: 'Medium', color: '#84cc16', range: '25-49%' },
5+
{ label: 'High', color: '#eab308', range: '50-74%' },
6+
{ label: 'Very High', color: '#f97316', range: '75-89%' },
7+
{ label: 'Critical', color: '#ef4444', range: '90-100%' },
8+
]
9+
10+
export function UtilizationLegend() {
11+
return (
12+
<div className="rounded-lg border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
13+
<h4 className="mb-2 text-xs font-medium text-gray-700 dark:text-gray-300">
14+
Link Utilization
15+
</h4>
16+
<div className="space-y-1">
17+
{legendItems.map((item) => (
18+
<div key={item.label} className="flex items-center gap-2 text-xs">
19+
<div
20+
className="h-3 w-6 rounded"
21+
style={{ backgroundColor: item.color }}
22+
/>
23+
<span className="text-gray-600 dark:text-gray-400">{item.label}</span>
24+
<span className="ml-auto text-gray-400 dark:text-gray-500">
25+
{item.range}
26+
</span>
27+
</div>
28+
))}
29+
</div>
30+
</div>
31+
)
32+
}

0 commit comments

Comments
 (0)