From 8273913c1ac57d9efa1283dfb2ac34b682f781b8 Mon Sep 17 00:00:00 2001 From: zvx-echo6 Date: Tue, 12 May 2026 12:14:45 -0600 Subject: [PATCH] feat(dashboard): mesh topology graph + geographic map + node table - D3 force-directed topology graph with flowing particle animations - Leaflet geographic map with CartoDB Dark tiles - Drag-to-reorganize with visible settling (matches Meshview behavior) - SNR-based edge coloring: excellent/good/fair/marginal/poor - Node detail panel with neighbor list and external map links - Sortable/filterable node table - Region-colored nodes, infrastructure vs client distinction - Click-to-select synced across graph, map, table, and detail panel Co-Authored-By: Claude Opus 4.5 --- dashboard-frontend/src/components/GeoMap.tsx | 267 +++++++++++ .../src/components/NodeDetail.tsx | 242 ++++++++++ .../src/components/NodeTable.tsx | 296 ++++++++++++ .../src/components/TopologyGraph.tsx | 428 ++++++++++++++++++ dashboard-frontend/src/lib/api.ts | 17 +- dashboard-frontend/src/pages/Mesh.tsx | 145 +++++- 6 files changed, 1384 insertions(+), 11 deletions(-) create mode 100644 dashboard-frontend/src/components/GeoMap.tsx create mode 100644 dashboard-frontend/src/components/NodeDetail.tsx create mode 100644 dashboard-frontend/src/components/NodeTable.tsx create mode 100644 dashboard-frontend/src/components/TopologyGraph.tsx 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 && ( +
+ + + Google Maps + + + + OSM + +
+ )} +
+ ) +} + +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 */} +
+ + + + + + + + + + + + + + {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' + }`} + > + + + + + + + + + ) + })} + +
handleSort('short_name')} + > + Name + handleSort('role')} + > + Role + Region handleSort('battery_level')} + > + Battery + handleSort('last_heard')} + > + Last Heard + handleSort('hardware')} + > + Hardware +
+
+
+
{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 ( +
+ + {/* Background */} + + + {/* Edges */} + + {simLinks.map((link, i) => { + const source = typeof link.source === 'object' ? link.source : simNodes.find(n => n.id === link.source) + const target = typeof link.target === 'object' ? link.target : simNodes.find(n => n.id === link.target) + if (!source?.x || !source?.y || !target?.x || !target?.y) return null + + const isRelated = selectedNodeId === null || + source.id === selectedNodeId || + target.id === selectedNodeId + + const opacity = selectedNodeId === null ? 0.4 : (isRelated ? 0.6 : 0.04) + const color = getQualityColor(link.snr) + + const sx = source.x + const sy = source.y + const tx = target.x + const ty = target.y + + return ( + + + {/* SNR label when node selected */} + {selectedNodeId !== null && isRelated && ( + + {link.snr.toFixed(1)} dB + + )} + + ) + })} + + + {/* Particles */} + + {particlesRef.current.map((particle, i) => { + const link = simLinks[particle.edgeIndex] + if (!link) return null + + const source = typeof link.source === 'object' ? link.source : simNodes.find(n => n.id === link.source) + const target = typeof link.target === 'object' ? link.target : simNodes.find(n => n.id === link.target) + if (!source?.x || !source?.y || !target?.x || !target?.y) return null + + const isRelated = selectedNodeId === null || + source.id === selectedNodeId || + target.id === selectedNodeId + + const sx = source.x + const sy = source.y + const tx = target.x + const ty = target.y + const x = sx + (tx - sx) * particle.t + const y = sy + (ty - sy) * particle.t + const color = getQualityColor(link.snr) + + return ( + + ) + })} + + + {/* Nodes */} + + {simNodes.map((node) => { + if (node.x === undefined || node.y === undefined) return null + + const isSelected = node.id === selectedNodeId + const isNeighbor = selectedNeighbors.has(node.id) + const isRelated = selectedNodeId === null || isSelected || isNeighbor + const opacity = isRelated ? 1 : 0.1 + const color = REGION_COLORS[node.regionIndex % REGION_COLORS.length] + const radius = node.isInfra ? 14 : 8 + + return ( + handlePointerDown(e, node)} + onClick={(e) => { + e.stopPropagation() + handleNodeClick(node.id) + }} + > + {/* Selection ring */} + {isSelected && ( + + )} + {/* Node circle */} + + {/* Label */} + + {node.shortName} + + + ) + })} + + + {/* Legend */} + + + Edge Quality + {[ + { label: 'Excellent (>12)', color: '#22c55e' }, + { label: 'Good (8-12)', color: '#4ade80' }, + { label: 'Fair (5-8)', color: '#f59e0b' }, + { label: 'Marginal (3-5)', color: '#f97316' }, + { label: 'Poor (<3)', color: '#ef4444' }, + ].map((item, i) => ( + + + {item.label} + + ))} + + + {/* Node type legend */} + + + + + Infrastructure + + + + Client + + + +
+ ) +} 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 ( +
+
Loading mesh data...
-

Mesh

-

- Topology graph and geographic map coming in Phase 6 -

+ ) + } + + if (error) { + return ( +
+
Error: {error}
+
+ ) + } + + return ( +
+ {/* Header with view toggle */} +
+
+ {nodes.length} nodes • {edges.length} edges +
+ + {/* View toggle */} +
+ + +
+
+ + {/* Main view area */} +
+ {/* Graph/Map */} +
+ {viewMode === 'topo' ? ( + + ) : ( + + )} +
+ + {/* Detail panel */} + +
+ + {/* Node table */} +
) }