diff --git a/dashboard-frontend/src/components/GeoMap.tsx b/dashboard-frontend/src/components/GeoMap.tsx
new file mode 100644
index 0000000..fb055fe
--- /dev/null
+++ b/dashboard-frontend/src/components/GeoMap.tsx
@@ -0,0 +1,267 @@
+import { useEffect, useMemo } from 'react'
+import { MapContainer, TileLayer, CircleMarker, Polyline, Popup, Tooltip, useMap } from 'react-leaflet'
+import type { LatLngBoundsExpression, LatLngTuple } from 'leaflet'
+import 'leaflet/dist/leaflet.css'
+import type { NodeInfo, EdgeInfo } from '@/lib/api'
+import { ExternalLink, MapPin } from 'lucide-react'
+
+// Fix Leaflet default marker icon issue with Vite
+import L from 'leaflet'
+import markerIcon from 'leaflet/dist/images/marker-icon.png'
+import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png'
+import markerShadow from 'leaflet/dist/images/marker-shadow.png'
+
+// @ts-expect-error - Leaflet icon fix
+delete L.Icon.Default.prototype._getIconUrl
+L.Icon.Default.mergeOptions({
+ iconUrl: markerIcon,
+ iconRetinaUrl: markerIcon2x,
+ shadowUrl: markerShadow,
+})
+
+interface GeoMapProps {
+ nodes: NodeInfo[]
+ edges: EdgeInfo[]
+ selectedNodeId: number | null
+ onSelectNode: (nodeId: number | null) => void
+}
+
+const REGION_COLORS = ['#3b82f6', '#a78bfa', '#06b6d4', '#f59e0b', '#22c55e', '#ec4899', '#8b5cf6', '#14b8a6']
+const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER']
+
+function getQualityColor(snr: number): string {
+ if (snr > 12) return '#22c55e'
+ if (snr > 8) return '#4ade80'
+ if (snr > 5) return '#f59e0b'
+ if (snr > 3) return '#f97316'
+ return '#ef4444'
+}
+
+function getRegionIndex(lat: number | null): number {
+ if (lat === null) return 0
+ if (lat > 46) return 0
+ if (lat > 44.5) return 1
+ if (lat > 43) return 2
+ return 3
+}
+
+function formatLastHeard(lastHeard: string | null): string {
+ if (!lastHeard) return 'Unknown'
+ const date = new Date(lastHeard)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMs / 3600000)
+ const diffDays = Math.floor(diffMs / 86400000)
+
+ if (diffMins < 1) return 'Just now'
+ if (diffMins < 60) return `${diffMins}m ago`
+ if (diffHours < 24) return `${diffHours}h ago`
+ return `${diffDays}d ago`
+}
+
+// Component to fit bounds on mount
+function FitBounds({ bounds }: { bounds: LatLngBoundsExpression | null }) {
+ const map = useMap()
+
+ useEffect(() => {
+ if (bounds) {
+ map.fitBounds(bounds, { padding: [50, 50] })
+ }
+ }, [map, bounds])
+
+ return null
+}
+
+interface NodePopupProps {
+ node: NodeInfo
+}
+
+function NodePopup({ node }: NodePopupProps) {
+ const hasCoords = node.latitude !== null && node.longitude !== null
+ const batteryText = node.battery_level !== null
+ ? (node.battery_level > 100 || (node.voltage && node.voltage > 4.1) ? 'USB ⚡' : `${node.battery_level.toFixed(0)}%`)
+ : 'Unknown'
+
+ return (
+
+
{node.short_name}
+
{node.long_name}
+
+
+
Role
+
{node.role}
+
+
Hardware
+
{node.hardware || 'Unknown'}
+
+
Battery
+
{batteryText}
+
+
Last Heard
+
{formatLastHeard(node.last_heard)}
+
+
+ {hasCoords && (
+
+ )}
+
+ )
+}
+
+export default function GeoMap({
+ nodes,
+ edges,
+ selectedNodeId,
+ onSelectNode,
+}: GeoMapProps) {
+ // Filter nodes with valid coordinates
+ const geoNodes = useMemo(() =>
+ nodes.filter((n) => n.latitude !== null && n.longitude !== null),
+ [nodes]
+ )
+
+ const nodesWithoutCoords = nodes.length - geoNodes.length
+
+ // Create node map for edge lookup
+ const nodeMap = useMemo(() =>
+ new Map(geoNodes.map((n) => [n.node_num, n])),
+ [geoNodes]
+ )
+
+ // Filter edges where both nodes have coordinates
+ const geoEdges = useMemo(() =>
+ edges.filter((e) => nodeMap.has(e.from_node) && nodeMap.has(e.to_node)),
+ [edges, nodeMap]
+ )
+
+ // Calculate bounds
+ const bounds = useMemo((): LatLngBoundsExpression | null => {
+ if (geoNodes.length === 0) return null
+ const lats = geoNodes.map((n) => n.latitude!)
+ const lons = geoNodes.map((n) => n.longitude!)
+ return [
+ [Math.min(...lats), Math.min(...lons)],
+ [Math.max(...lats), Math.max(...lons)],
+ ]
+ }, [geoNodes])
+
+ // Default center (Idaho)
+ const defaultCenter: LatLngTuple = [43.6, -114.4]
+
+ // Get neighbors of selected node
+ const selectedNeighbors = useMemo(() => {
+ const neighbors = new Set()
+ if (selectedNodeId !== null) {
+ edges.forEach((e) => {
+ if (e.from_node === selectedNodeId) neighbors.add(e.to_node)
+ if (e.to_node === selectedNodeId) neighbors.add(e.from_node)
+ })
+ }
+ return neighbors
+ }, [selectedNodeId, edges])
+
+ return (
+
+
+
+
+
+
+ {/* Edges */}
+ {geoEdges.map((edge, i) => {
+ const fromNode = nodeMap.get(edge.from_node)!
+ const toNode = nodeMap.get(edge.to_node)!
+ const isRelated = selectedNodeId === null ||
+ edge.from_node === selectedNodeId ||
+ edge.to_node === selectedNodeId
+
+ return (
+
+ )
+ })}
+
+ {/* Nodes */}
+ {geoNodes.map((node) => {
+ const isSelected = node.node_num === selectedNodeId
+ const isNeighbor = selectedNeighbors.has(node.node_num)
+ const isRelated = selectedNodeId === null || isSelected || isNeighbor
+ const isInfra = INFRA_ROLES.includes(node.role)
+ const regionIndex = getRegionIndex(node.latitude)
+ const color = REGION_COLORS[regionIndex % REGION_COLORS.length]
+
+ return (
+ onSelectNode(isSelected ? null : node.node_num),
+ }}
+ >
+
+ {node.short_name}
+
+
+
+
+
+ )
+ })}
+
+
+ {/* Stats overlay */}
+
+
+
+ Showing {geoNodes.length} of {nodes.length} nodes
+ {nodesWithoutCoords > 0 && (
+ ({nodesWithoutCoords} without coordinates)
+ )}
+
+
+
+ )
+}
diff --git a/dashboard-frontend/src/components/NodeDetail.tsx b/dashboard-frontend/src/components/NodeDetail.tsx
new file mode 100644
index 0000000..54570f9
--- /dev/null
+++ b/dashboard-frontend/src/components/NodeDetail.tsx
@@ -0,0 +1,242 @@
+import { useMemo } from 'react'
+import { ExternalLink, Radio, Zap } from 'lucide-react'
+import type { NodeInfo, EdgeInfo } from '@/lib/api'
+
+interface NodeDetailProps {
+ node: NodeInfo | null
+ edges: EdgeInfo[]
+ nodes: NodeInfo[]
+ onSelectNode: (nodeId: number) => void
+}
+
+const REGION_COLORS = ['#3b82f6', '#a78bfa', '#06b6d4', '#f59e0b', '#22c55e', '#ec4899', '#8b5cf6', '#14b8a6']
+const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER']
+
+function getQualityColor(snr: number): string {
+ if (snr > 12) return '#22c55e'
+ if (snr > 8) return '#4ade80'
+ if (snr > 5) return '#f59e0b'
+ if (snr > 3) return '#f97316'
+ return '#ef4444'
+}
+
+function getQualityLabel(snr: number): string {
+ if (snr > 12) return 'excellent'
+ if (snr > 8) return 'good'
+ if (snr > 5) return 'fair'
+ if (snr > 3) return 'marginal'
+ return 'poor'
+}
+
+function getRegionIndex(lat: number | null): number {
+ if (lat === null) return 0
+ if (lat > 46) return 0
+ if (lat > 44.5) return 1
+ if (lat > 43) return 2
+ return 3
+}
+
+function getRegionName(index: number): string {
+ const names = ['Northern ID', 'Central ID', 'SW Idaho', 'SC Idaho']
+ return names[index] || 'Unknown'
+}
+
+function formatLastHeard(lastHeard: string | null): string {
+ if (!lastHeard) return 'Unknown'
+ const date = new Date(lastHeard)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMs / 3600000)
+ const diffDays = Math.floor(diffMs / 86400000)
+
+ if (diffMins < 1) return 'Just now'
+ if (diffMins < 60) return `${diffMins}m ago`
+ if (diffHours < 24) return `${diffHours}h ago`
+ return `${diffDays}d ago`
+}
+
+function getStatusColor(lastHeard: string | null): string {
+ if (!lastHeard) return 'bg-slate-500'
+ const date = new Date(lastHeard)
+ const now = new Date()
+ const diffHours = (now.getTime() - date.getTime()) / 3600000
+ if (diffHours < 1) return 'bg-green-500'
+ if (diffHours < 24) return 'bg-amber-500'
+ return 'bg-slate-500'
+}
+
+export default function NodeDetail({
+ node,
+ edges,
+ nodes,
+ onSelectNode,
+}: NodeDetailProps) {
+ // Get neighbors with edge info
+ const neighbors = useMemo(() => {
+ if (!node) return []
+
+ const nodeMap = new Map(nodes.map((n) => [n.node_num, n]))
+ const neighborData: Array<{
+ node: NodeInfo
+ snr: number
+ quality: string
+ }> = []
+
+ edges.forEach((e) => {
+ if (e.from_node === node.node_num) {
+ const neighbor = nodeMap.get(e.to_node)
+ if (neighbor) {
+ neighborData.push({ node: neighbor, snr: e.snr, quality: e.quality })
+ }
+ } else if (e.to_node === node.node_num) {
+ const neighbor = nodeMap.get(e.from_node)
+ if (neighbor) {
+ neighborData.push({ node: neighbor, snr: e.snr, quality: e.quality })
+ }
+ }
+ })
+
+ // Sort by SNR descending
+ return neighborData.sort((a, b) => b.snr - a.snr)
+ }, [node, edges, nodes])
+
+ if (!node) {
+ return (
+
+
+
+
+
+ Click a node to inspect
+
+
+ )
+ }
+
+ const isInfra = INFRA_ROLES.includes(node.role)
+ const regionIndex = getRegionIndex(node.latitude)
+ const regionColor = REGION_COLORS[regionIndex % REGION_COLORS.length]
+ const hasCoords = node.latitude !== null && node.longitude !== null
+ const batteryText = node.battery_level !== null
+ ? (node.battery_level > 100 || (node.voltage && node.voltage > 4.1) ? 'USB' : `${node.battery_level.toFixed(0)}%`)
+ : '—'
+ const isPowered = node.battery_level !== null && (node.battery_level > 100 || (node.voltage && node.voltage > 4.1))
+
+ return (
+
+ {/* Header */}
+
+ {/* Node ID badge */}
+
+ {node.node_id_hex}
+
+
+ {/* Name */}
+
{node.short_name}
+
{node.long_name}
+
+
+ {/* Info grid */}
+
+
+
Role
+
+ {node.role}
+
+
+
+
Region
+
{getRegionName(regionIndex)}
+
+
+
Battery
+
+ {isPowered && }
+ {batteryText}
+
+
+
+
Status
+
+
+
{formatLastHeard(node.last_heard)}
+
+
+
+
Hardware
+
+ {node.hardware || 'Unknown'}
+
+
+
+
+ {/* External links */}
+ {hasCoords && (
+
+ )}
+
+ {/* Neighbors */}
+
+
+ Neighbors ({neighbors.length})
+
+ {neighbors.length > 0 ? (
+
+ {neighbors.map((n) => (
+
+ ))}
+
+ ) : (
+
+ No known neighbors
+
+ )}
+
+
+ )
+}
diff --git a/dashboard-frontend/src/components/NodeTable.tsx b/dashboard-frontend/src/components/NodeTable.tsx
new file mode 100644
index 0000000..aed0930
--- /dev/null
+++ b/dashboard-frontend/src/components/NodeTable.tsx
@@ -0,0 +1,296 @@
+import { useState, useMemo } from 'react'
+import { ChevronUp, ChevronDown, Search, Filter } from 'lucide-react'
+import type { NodeInfo } from '@/lib/api'
+
+interface NodeTableProps {
+ nodes: NodeInfo[]
+ selectedNodeId: number | null
+ onSelectNode: (nodeId: number) => void
+}
+
+type SortField = 'short_name' | 'role' | 'battery_level' | 'last_heard' | 'hardware'
+type SortDir = 'asc' | 'desc'
+type QuickFilter = 'all' | 'infra' | 'online'
+
+const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER']
+
+function getStatusColor(lastHeard: string | null): string {
+ if (!lastHeard) return 'bg-slate-500'
+ const date = new Date(lastHeard)
+ const now = new Date()
+ const diffHours = (now.getTime() - date.getTime()) / 3600000
+ if (diffHours < 1) return 'bg-green-500'
+ if (diffHours < 24) return 'bg-amber-500'
+ return 'bg-slate-500'
+}
+
+function formatLastHeard(lastHeard: string | null): string {
+ if (!lastHeard) return '—'
+ const date = new Date(lastHeard)
+ const now = new Date()
+ const diffMs = now.getTime() - date.getTime()
+ const diffMins = Math.floor(diffMs / 60000)
+ const diffHours = Math.floor(diffMs / 3600000)
+ const diffDays = Math.floor(diffMs / 86400000)
+
+ if (diffMins < 1) return 'Just now'
+ if (diffMins < 60) return `${diffMins}m ago`
+ if (diffHours < 24) return `${diffHours}h ago`
+ return `${diffDays}d ago`
+}
+
+function formatBattery(node: NodeInfo): string {
+ if (node.battery_level === null) return '—'
+ if (node.battery_level > 100 || (node.voltage && node.voltage > 4.1)) {
+ return 'USB ⚡'
+ }
+ return `${node.battery_level.toFixed(0)}%`
+}
+
+function getRegionName(lat: number | null): string {
+ if (lat === null) return '—'
+ if (lat > 46) return 'Northern'
+ if (lat > 44.5) return 'Central'
+ if (lat > 43) return 'SW Idaho'
+ return 'SC Idaho'
+}
+
+export default function NodeTable({
+ nodes,
+ selectedNodeId,
+ onSelectNode,
+}: NodeTableProps) {
+ const [searchTerm, setSearchTerm] = useState('')
+ const [sortField, setSortField] = useState('short_name')
+ const [sortDir, setSortDir] = useState('asc')
+ const [quickFilter, setQuickFilter] = useState('all')
+
+ // Filter and sort nodes
+ const filteredNodes = useMemo(() => {
+ let result = [...nodes]
+
+ // Quick filter
+ if (quickFilter === 'infra') {
+ result = result.filter((n) => INFRA_ROLES.includes(n.role))
+ } else if (quickFilter === 'online') {
+ result = result.filter((n) => {
+ if (!n.last_heard) return false
+ const date = new Date(n.last_heard)
+ const now = new Date()
+ const diffHours = (now.getTime() - date.getTime()) / 3600000
+ return diffHours < 1
+ })
+ }
+
+ // Search filter
+ if (searchTerm) {
+ const term = searchTerm.toLowerCase()
+ result = result.filter((n) =>
+ n.short_name.toLowerCase().includes(term) ||
+ n.long_name.toLowerCase().includes(term) ||
+ n.role.toLowerCase().includes(term) ||
+ getRegionName(n.latitude).toLowerCase().includes(term)
+ )
+ }
+
+ // Sort
+ result.sort((a, b) => {
+ let aVal: string | number = ''
+ let bVal: string | number = ''
+
+ switch (sortField) {
+ case 'short_name':
+ aVal = a.short_name.toLowerCase()
+ bVal = b.short_name.toLowerCase()
+ break
+ case 'role':
+ aVal = a.role
+ bVal = b.role
+ break
+ case 'battery_level':
+ aVal = a.battery_level ?? -1
+ bVal = b.battery_level ?? -1
+ break
+ case 'last_heard':
+ aVal = a.last_heard ? new Date(a.last_heard).getTime() : 0
+ bVal = b.last_heard ? new Date(b.last_heard).getTime() : 0
+ break
+ case 'hardware':
+ aVal = a.hardware.toLowerCase()
+ bVal = b.hardware.toLowerCase()
+ break
+ }
+
+ if (aVal < bVal) return sortDir === 'asc' ? -1 : 1
+ if (aVal > bVal) return sortDir === 'asc' ? 1 : -1
+ return 0
+ })
+
+ return result
+ }, [nodes, searchTerm, sortField, sortDir, quickFilter])
+
+ const handleSort = (field: SortField) => {
+ if (sortField === field) {
+ setSortDir(sortDir === 'asc' ? 'desc' : 'asc')
+ } else {
+ setSortField(field)
+ setSortDir('asc')
+ }
+ }
+
+ const SortIcon = ({ field }: { field: SortField }) => {
+ if (sortField !== field) return null
+ return sortDir === 'asc' ? (
+
+ ) : (
+
+ )
+ }
+
+ return (
+
+ {/* Filter bar */}
+
+ {/* Search */}
+
+
+ setSearchTerm(e.target.value)}
+ className="w-full pl-9 pr-3 py-1.5 bg-bg-hover border border-border rounded text-sm text-slate-200 placeholder-slate-500 focus:outline-none focus:border-accent"
+ />
+
+
+ {/* Quick filters */}
+
+
+ {(['all', 'infra', 'online'] as QuickFilter[]).map((filter) => (
+
+ ))}
+
+
+ {/* Count */}
+
+ {filteredNodes.length} of {nodes.length} nodes
+
+
+
+ {/* Table */}
+
+
+
+
+ |
+ handleSort('short_name')}
+ >
+ Name
+ |
+ handleSort('role')}
+ >
+ Role
+ |
+ Region |
+ handleSort('battery_level')}
+ >
+ Battery
+ |
+ handleSort('last_heard')}
+ >
+ Last Heard
+ |
+ handleSort('hardware')}
+ >
+ Hardware
+ |
+
+
+
+ {filteredNodes.slice(0, 100).map((node) => {
+ const isInfra = INFRA_ROLES.includes(node.role)
+ const isSelected = node.node_num === selectedNodeId
+
+ return (
+ onSelectNode(node.node_num)}
+ className={`cursor-pointer transition-colors ${
+ isSelected
+ ? 'bg-accent/10'
+ : 'hover:bg-bg-hover'
+ }`}
+ >
+ |
+
+ |
+
+ {node.short_name}
+
+ {node.long_name}
+
+ |
+
+
+ {node.role}
+
+ |
+
+ {getRegionName(node.latitude)}
+ |
+
+ {formatBattery(node)}
+ |
+
+ {formatLastHeard(node.last_heard)}
+ |
+
+ {node.hardware || '—'}
+ |
+
+ )
+ })}
+
+
+
+ {filteredNodes.length > 100 && (
+
+ Showing first 100 of {filteredNodes.length} nodes
+
+ )}
+
+ {filteredNodes.length === 0 && (
+
+ No nodes match your filters
+
+ )}
+
+
+ )
+}
diff --git a/dashboard-frontend/src/components/TopologyGraph.tsx b/dashboard-frontend/src/components/TopologyGraph.tsx
new file mode 100644
index 0000000..162def7
--- /dev/null
+++ b/dashboard-frontend/src/components/TopologyGraph.tsx
@@ -0,0 +1,428 @@
+import { useEffect, useRef, useCallback, useState } from 'react'
+import * as d3 from 'd3'
+import type { NodeInfo, EdgeInfo } from '@/lib/api'
+
+interface TopologyGraphProps {
+ nodes: NodeInfo[]
+ edges: EdgeInfo[]
+ selectedNodeId: number | null
+ onSelectNode: (nodeId: number | null) => void
+}
+
+interface SimNode extends d3.SimulationNodeDatum {
+ id: number
+ shortName: string
+ longName: string
+ role: string
+ isInfra: boolean
+ regionIndex: number
+ x?: number
+ y?: number
+ fx?: number | null
+ fy?: number | null
+}
+
+interface SimLink extends d3.SimulationLinkDatum {
+ source: SimNode | number
+ target: SimNode | number
+ snr: number
+ quality: string
+}
+
+interface Particle {
+ edgeIndex: number
+ t: number
+ speed: number
+ size: number
+}
+
+const REGION_COLORS = ['#3b82f6', '#a78bfa', '#06b6d4', '#f59e0b', '#22c55e', '#ec4899', '#8b5cf6', '#14b8a6']
+
+function getQualityColor(snr: number): string {
+ if (snr > 12) return '#22c55e' // excellent - green
+ if (snr > 8) return '#4ade80' // good - light green
+ if (snr > 5) return '#f59e0b' // fair - amber
+ if (snr > 3) return '#f97316' // marginal - orange
+ return '#ef4444' // poor - red
+}
+
+const INFRA_ROLES = ['ROUTER', 'ROUTER_LATE', 'REPEATER', 'TRACKER']
+
+export default function TopologyGraph({
+ nodes,
+ edges,
+ selectedNodeId,
+ onSelectNode,
+}: TopologyGraphProps) {
+ const svgRef = useRef(null)
+ const simulationRef = useRef | null>(null)
+ const particlesRef = useRef([])
+ const animationRef = useRef(0)
+ const dragNodeRef = useRef(null)
+
+ const [simNodes, setSimNodes] = useState([])
+ const [simLinks, setSimLinks] = useState([])
+ const [dimensions, setDimensions] = useState({ width: 800, height: 540 })
+
+ // Build region index map
+ const regionIndexMap = useCallback((lat: number | null) => {
+ // Simple region assignment based on latitude bands for Idaho
+ if (lat === null) return 0
+ if (lat > 46) return 0 // Northern
+ if (lat > 44.5) return 1 // Central
+ if (lat > 43) return 2 // SW
+ return 3 // SC
+ }, [])
+
+ // Initialize simulation
+ useEffect(() => {
+ if (!nodes.length) return
+
+ const width = dimensions.width
+ const height = dimensions.height
+
+ // Create simulation nodes
+ const simNodesData: SimNode[] = nodes.map((n) => ({
+ id: n.node_num,
+ shortName: n.short_name,
+ longName: n.long_name,
+ role: n.role,
+ isInfra: INFRA_ROLES.includes(n.role),
+ regionIndex: regionIndexMap(n.latitude),
+ x: width / 2 + (Math.random() - 0.5) * 200,
+ y: height / 2 + (Math.random() - 0.5) * 200,
+ }))
+
+ const nodeMap = new Map(simNodesData.map((n) => [n.id, n]))
+
+ // Create simulation links
+ const simLinksData: SimLink[] = edges
+ .filter((e) => nodeMap.has(e.from_node) && nodeMap.has(e.to_node))
+ .map((e) => ({
+ source: e.from_node,
+ target: e.to_node,
+ snr: e.snr,
+ quality: e.quality,
+ }))
+
+ // Create D3 force simulation
+ const simulation = d3.forceSimulation(simNodesData)
+ .alphaDecay(0.008)
+ .velocityDecay(0.35)
+ .force('charge', d3.forceManyBody()
+ .strength((n) => n.isInfra ? -250 : -120))
+ .force('link', d3.forceLink(simLinksData)
+ .id((d) => d.id)
+ .distance((d) => {
+ const snr = d.snr || 0
+ if (snr > 12) return 80
+ if (snr > 8) return 100
+ if (snr > 5) return 125
+ return 155
+ })
+ .strength(0.7))
+ .force('center', d3.forceCenter(width / 2, height / 2).strength(0.05))
+ .force('collide', d3.forceCollide((d) => d.isInfra ? 28 : 16).strength(0.5))
+
+ simulationRef.current = simulation
+ setSimNodes(simNodesData)
+ setSimLinks(simLinksData as SimLink[])
+
+ // Initialize particles
+ const particles: Particle[] = []
+ simLinksData.forEach((_, i) => {
+ const numParticles = 2 + Math.floor(Math.random() * 2)
+ for (let p = 0; p < numParticles; p++) {
+ particles.push({
+ edgeIndex: i,
+ t: Math.random(),
+ speed: 0.002 + Math.random() * 0.003,
+ size: 1.5 + Math.random() * 1.5,
+ })
+ }
+ })
+ particlesRef.current = particles
+
+ // Animation loop
+ let lastTime = 0
+ const animate = (time: number) => {
+ const dt = Math.min((time - lastTime) / 16.67, 2) // Cap at 2x speed
+ lastTime = time
+
+ // Update particle positions
+ particlesRef.current.forEach((p) => {
+ p.t += p.speed * dt
+ if (p.t > 1) p.t -= 1
+ })
+
+ // Read simulation positions
+ setSimNodes([...simNodesData])
+
+ animationRef.current = requestAnimationFrame(animate)
+ }
+ animationRef.current = requestAnimationFrame(animate)
+
+ return () => {
+ cancelAnimationFrame(animationRef.current)
+ simulation.stop()
+ }
+ }, [nodes, edges, dimensions, regionIndexMap])
+
+ // Handle resize
+ useEffect(() => {
+ const handleResize = () => {
+ if (svgRef.current) {
+ const rect = svgRef.current.parentElement?.getBoundingClientRect()
+ if (rect) {
+ setDimensions({ width: rect.width, height: 540 })
+ }
+ }
+ }
+ handleResize()
+ window.addEventListener('resize', handleResize)
+ return () => window.removeEventListener('resize', handleResize)
+ }, [])
+
+ // Get neighbors of selected node
+ const selectedNeighbors = new Set()
+ if (selectedNodeId !== null) {
+ edges.forEach((e) => {
+ if (e.from_node === selectedNodeId) selectedNeighbors.add(e.to_node)
+ if (e.to_node === selectedNodeId) selectedNeighbors.add(e.from_node)
+ })
+ }
+
+ // Drag handlers
+ const handlePointerDown = useCallback((e: React.PointerEvent, node: SimNode) => {
+ e.preventDefault()
+ e.stopPropagation()
+ const svg = svgRef.current
+ if (!svg || !simulationRef.current) return
+
+ svg.setPointerCapture(e.pointerId)
+ dragNodeRef.current = node
+ node.fx = node.x
+ node.fy = node.y
+ simulationRef.current.alphaTarget(0.3).restart()
+ }, [])
+
+ const handlePointerMove = useCallback((e: React.PointerEvent) => {
+ if (!dragNodeRef.current || !svgRef.current) return
+
+ const svg = svgRef.current
+ const rect = svg.getBoundingClientRect()
+ const x = e.clientX - rect.left
+ const y = e.clientY - rect.top
+
+ dragNodeRef.current.fx = x
+ dragNodeRef.current.fy = y
+ }, [])
+
+ const handlePointerUp = useCallback((e: React.PointerEvent) => {
+ if (!dragNodeRef.current || !simulationRef.current) return
+
+ svgRef.current?.releasePointerCapture(e.pointerId)
+ dragNodeRef.current.fx = null
+ dragNodeRef.current.fy = null
+ dragNodeRef.current = null
+ simulationRef.current.alphaTarget(0)
+ }, [])
+
+ const handleNodeClick = useCallback((nodeId: number) => {
+ onSelectNode(selectedNodeId === nodeId ? null : nodeId)
+ }, [selectedNodeId, onSelectNode])
+
+ const handleBackgroundClick = useCallback(() => {
+ onSelectNode(null)
+ }, [onSelectNode])
+
+ return (
+
+
+
+ )
+}
diff --git a/dashboard-frontend/src/lib/api.ts b/dashboard-frontend/src/lib/api.ts
index d5f63b2..ba5477e 100644
--- a/dashboard-frontend/src/lib/api.ts
+++ b/dashboard-frontend/src/lib/api.ts
@@ -59,6 +59,19 @@ export interface EdgeInfo {
quality: string
}
+export interface RegionInfo {
+ name: string
+ local_name: string
+ node_count: number
+ infra_count: number
+ infra_online: number
+ online_count: number
+ score: number
+ tier: string
+ center_lat: number
+ center_lon: number
+}
+
export interface SourceHealth {
name: string
type: string
@@ -222,6 +235,6 @@ export async function fetchDucting(): Promise {
return fetchJson('/api/env/ducting')
}
-export async function fetchRegions(): Promise {
- return fetchJson('/api/regions')
+export async function fetchRegions(): Promise {
+ return fetchJson('/api/regions')
}
diff --git a/dashboard-frontend/src/pages/Mesh.tsx b/dashboard-frontend/src/pages/Mesh.tsx
index aeae8b2..1404cfb 100644
--- a/dashboard-frontend/src/pages/Mesh.tsx
+++ b/dashboard-frontend/src/pages/Mesh.tsx
@@ -1,15 +1,142 @@
-import { Radio } from 'lucide-react'
+import { useEffect, useState, useCallback, useMemo } from 'react'
+import { Map, Network } from 'lucide-react'
+import {
+ fetchNodes,
+ fetchEdges,
+ fetchRegions,
+ type NodeInfo,
+ type EdgeInfo,
+ type RegionInfo,
+} from '@/lib/api'
+import TopologyGraph from '@/components/TopologyGraph'
+import GeoMap from '@/components/GeoMap'
+import NodeDetail from '@/components/NodeDetail'
+import NodeTable from '@/components/NodeTable'
+
+type ViewMode = 'topo' | 'geo'
export default function Mesh() {
- return (
-
-
-
+ const [nodes, setNodes] = useState
([])
+ const [edges, setEdges] = useState([])
+ const [_regions, setRegions] = useState([])
+ const [selectedNodeId, setSelectedNodeId] = useState(null)
+ const [viewMode, setViewMode] = useState('topo')
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+
+ // Fetch data on mount
+ useEffect(() => {
+ Promise.all([fetchNodes(), fetchEdges(), fetchRegions()])
+ .then(([n, e, r]) => {
+ setNodes(n)
+ setEdges(e)
+ setRegions(r)
+ setLoading(false)
+ })
+ .catch((err) => {
+ setError(err.message)
+ setLoading(false)
+ })
+ }, [])
+
+ // Get selected node
+ const selectedNode = useMemo(
+ () => nodes.find((n) => n.node_num === selectedNodeId) || null,
+ [nodes, selectedNodeId]
+ )
+
+ // Handle node selection
+ const handleSelectNode = useCallback((nodeId: number | null) => {
+ setSelectedNodeId(nodeId)
+ }, [])
+
+ if (loading) {
+ return (
+
- Mesh
-
- Topology graph and geographic map coming in Phase 6
-
+ )
+ }
+
+ if (error) {
+ return (
+
+ )
+ }
+
+ return (
+
+ {/* Header with view toggle */}
+
+
+ {nodes.length} nodes • {edges.length} edges
+
+
+ {/* View toggle */}
+
+
+
+
+
+
+ {/* Main view area */}
+
+ {/* Graph/Map */}
+
+ {viewMode === 'topo' ? (
+
+ ) : (
+
+ )}
+
+
+ {/* Detail panel */}
+
+
+
+ {/* Node table */}
+
)
}