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 <noreply@anthropic.com>
This commit is contained in:
zvx-echo6 2026-05-12 12:14:45 -06:00
commit 8273913c1a
6 changed files with 1384 additions and 11 deletions

View file

@ -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 (
<div className="min-w-[200px]">
<div className="font-semibold text-slate-800">{node.short_name}</div>
<div className="text-xs text-slate-600 mb-2">{node.long_name}</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div className="text-slate-500">Role</div>
<div className="text-slate-700 font-medium">{node.role}</div>
<div className="text-slate-500">Hardware</div>
<div className="text-slate-700">{node.hardware || 'Unknown'}</div>
<div className="text-slate-500">Battery</div>
<div className="text-slate-700">{batteryText}</div>
<div className="text-slate-500">Last Heard</div>
<div className="text-slate-700">{formatLastHeard(node.last_heard)}</div>
</div>
{hasCoords && (
<div className="mt-3 pt-2 border-t border-slate-200 flex gap-2">
<a
href={`https://www.google.com/maps?q=${node.latitude},${node.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
>
<ExternalLink size={10} />
Google Maps
</a>
<a
href={`https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=14`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-600 hover:text-blue-800"
>
<ExternalLink size={10} />
OSM
</a>
</div>
)}
</div>
)
}
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<number>()
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 (
<div className="relative bg-bg-card rounded-lg border border-border overflow-hidden">
<MapContainer
center={defaultCenter}
zoom={7}
style={{ width: '100%', height: '540px' }}
className="z-0"
>
<TileLayer
url="https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png"
attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, &copy; <a href="https://carto.com/attributions">CARTO</a>'
/>
<FitBounds bounds={bounds} />
{/* 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 (
<Polyline
key={i}
positions={[
[fromNode.latitude!, fromNode.longitude!],
[toNode.latitude!, toNode.longitude!],
]}
color={getQualityColor(edge.snr)}
weight={isRelated && selectedNodeId !== null ? 2.5 : 1.5}
opacity={selectedNodeId === null ? 0.3 : (isRelated ? 0.6 : 0.08)}
/>
)
})}
{/* 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 (
<CircleMarker
key={node.node_num}
center={[node.latitude!, node.longitude!]}
radius={isInfra ? 8 : 5}
fillColor={isInfra ? color : '#111827'}
fillOpacity={isRelated ? 0.9 : 0.2}
stroke={true}
color={isSelected ? '#ffffff' : color}
weight={isSelected ? 3 : isInfra ? 0 : 2}
opacity={isRelated ? 1 : 0.3}
eventHandlers={{
click: () => onSelectNode(isSelected ? null : node.node_num),
}}
>
<Tooltip direction="top" offset={[0, -8]}>
<span className="font-mono text-xs">{node.short_name}</span>
</Tooltip>
<Popup>
<NodePopup node={node} />
</Popup>
</CircleMarker>
)
})}
</MapContainer>
{/* Stats overlay */}
<div className="absolute bottom-4 left-4 bg-bg-card/90 backdrop-blur-sm border border-border rounded px-3 py-2 text-xs text-slate-400 flex items-center gap-2">
<MapPin size={12} />
<span>
Showing {geoNodes.length} of {nodes.length} nodes
{nodesWithoutCoords > 0 && (
<span className="text-slate-500"> ({nodesWithoutCoords} without coordinates)</span>
)}
</span>
</div>
</div>
)
}

View file

@ -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 (
<div className="w-[250px] flex-shrink-0 bg-bg-card border-l border-border p-4 flex flex-col items-center justify-center h-[540px]">
<div className="w-12 h-12 rounded-full bg-bg-hover border border-border flex items-center justify-center mb-3">
<Radio size={24} className="text-slate-500" />
</div>
<p className="text-sm text-slate-500 text-center">
Click a node to inspect
</p>
</div>
)
}
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 (
<div className="w-[250px] flex-shrink-0 bg-bg-card border-l border-border flex flex-col h-[540px] overflow-hidden">
{/* Header */}
<div className="p-4 border-b border-border">
{/* Node ID badge */}
<div
className="inline-flex items-center px-2 py-0.5 rounded text-xs font-mono mb-2"
style={{ backgroundColor: `${regionColor}20`, color: regionColor }}
>
{node.node_id_hex}
</div>
{/* Name */}
<div className="font-mono text-lg text-slate-100">{node.short_name}</div>
<div className="text-xs text-slate-500 truncate">{node.long_name}</div>
</div>
{/* Info grid */}
<div className="p-4 border-b border-border grid grid-cols-2 gap-3">
<div>
<div className="text-xs text-slate-500 mb-0.5">Role</div>
<div className={`text-sm font-medium ${isInfra ? 'text-cyan-400' : 'text-slate-300'}`}>
{node.role}
</div>
</div>
<div>
<div className="text-xs text-slate-500 mb-0.5">Region</div>
<div className="text-sm text-slate-300">{getRegionName(regionIndex)}</div>
</div>
<div>
<div className="text-xs text-slate-500 mb-0.5">Battery</div>
<div className="text-sm text-slate-300 flex items-center gap-1">
{isPowered && <Zap size={12} className="text-amber-400" />}
{batteryText}
</div>
</div>
<div>
<div className="text-xs text-slate-500 mb-0.5">Status</div>
<div className="flex items-center gap-1.5">
<div className={`w-2 h-2 rounded-full ${getStatusColor(node.last_heard)}`} />
<span className="text-sm text-slate-300">{formatLastHeard(node.last_heard)}</span>
</div>
</div>
<div className="col-span-2">
<div className="text-xs text-slate-500 mb-0.5">Hardware</div>
<div className="text-sm text-slate-300 font-mono truncate">
{node.hardware || 'Unknown'}
</div>
</div>
</div>
{/* External links */}
{hasCoords && (
<div className="px-4 py-3 border-b border-border flex gap-3">
<a
href={`https://www.google.com/maps?q=${node.latitude},${node.longitude}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
>
<ExternalLink size={10} />
Google Maps
</a>
<a
href={`https://www.openstreetmap.org/?mlat=${node.latitude}&mlon=${node.longitude}&zoom=14`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1 text-xs text-blue-400 hover:text-blue-300"
>
<ExternalLink size={10} />
OSM
</a>
</div>
)}
{/* Neighbors */}
<div className="flex-1 overflow-y-auto">
<div className="px-4 py-2 text-xs text-slate-500 font-medium sticky top-0 bg-bg-card border-b border-border">
Neighbors ({neighbors.length})
</div>
{neighbors.length > 0 ? (
<div className="divide-y divide-border">
{neighbors.map((n) => (
<button
key={n.node.node_num}
onClick={() => onSelectNode(n.node.node_num)}
className="w-full px-4 py-2 text-left hover:bg-bg-hover transition-colors flex items-center gap-2"
style={{ borderLeftWidth: 3, borderLeftColor: getQualityColor(n.snr) }}
>
<div className="flex-1 min-w-0">
<div className="text-sm text-slate-200 font-mono truncate">
{n.node.short_name}
</div>
<div className="text-xs text-slate-500 truncate">
{n.node.long_name}
</div>
</div>
<div className="text-right flex-shrink-0">
<div className="text-xs font-mono" style={{ color: getQualityColor(n.snr) }}>
{n.snr.toFixed(1)} dB
</div>
<div className="text-xs text-slate-500">
{getQualityLabel(n.snr)}
</div>
</div>
</button>
))}
</div>
) : (
<div className="px-4 py-6 text-center text-sm text-slate-500">
No known neighbors
</div>
)}
</div>
</div>
)
}

View file

@ -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<SortField>('short_name')
const [sortDir, setSortDir] = useState<SortDir>('asc')
const [quickFilter, setQuickFilter] = useState<QuickFilter>('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' ? (
<ChevronUp size={14} className="inline ml-1" />
) : (
<ChevronDown size={14} className="inline ml-1" />
)
}
return (
<div className="bg-bg-card border border-border rounded-lg overflow-hidden">
{/* Filter bar */}
<div className="p-3 border-b border-border flex items-center gap-3">
{/* Search */}
<div className="relative flex-1 max-w-xs">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
placeholder="Search nodes..."
value={searchTerm}
onChange={(e) => 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"
/>
</div>
{/* Quick filters */}
<div className="flex items-center gap-1">
<Filter size={14} className="text-slate-500 mr-1" />
{(['all', 'infra', 'online'] as QuickFilter[]).map((filter) => (
<button
key={filter}
onClick={() => setQuickFilter(filter)}
className={`px-2 py-1 text-xs rounded transition-colors ${
quickFilter === filter
? 'bg-accent text-white'
: 'bg-bg-hover text-slate-400 hover:text-slate-200'
}`}
>
{filter === 'all' ? 'All' : filter === 'infra' ? 'Infra' : 'Online'}
</button>
))}
</div>
{/* Count */}
<div className="text-xs text-slate-500 ml-auto">
{filteredNodes.length} of {nodes.length} nodes
</div>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="bg-bg-hover text-slate-400 text-xs">
<th className="w-8 px-3 py-2"></th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-slate-200"
onClick={() => handleSort('short_name')}
>
Name <SortIcon field="short_name" />
</th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-slate-200"
onClick={() => handleSort('role')}
>
Role <SortIcon field="role" />
</th>
<th className="px-3 py-2 text-left">Region</th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-slate-200"
onClick={() => handleSort('battery_level')}
>
Battery <SortIcon field="battery_level" />
</th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-slate-200"
onClick={() => handleSort('last_heard')}
>
Last Heard <SortIcon field="last_heard" />
</th>
<th
className="px-3 py-2 text-left cursor-pointer hover:text-slate-200"
onClick={() => handleSort('hardware')}
>
Hardware <SortIcon field="hardware" />
</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{filteredNodes.slice(0, 100).map((node) => {
const isInfra = INFRA_ROLES.includes(node.role)
const isSelected = node.node_num === selectedNodeId
return (
<tr
key={node.node_num}
onClick={() => onSelectNode(node.node_num)}
className={`cursor-pointer transition-colors ${
isSelected
? 'bg-accent/10'
: 'hover:bg-bg-hover'
}`}
>
<td className="px-3 py-2">
<div className={`w-2 h-2 rounded-full ${getStatusColor(node.last_heard)}`} />
</td>
<td className="px-3 py-2">
<div className="font-mono text-slate-200">{node.short_name}</div>
<div className="text-xs text-slate-500 truncate max-w-[200px]">
{node.long_name}
</div>
</td>
<td className="px-3 py-2">
<span
className={`inline-block px-1.5 py-0.5 rounded text-xs font-medium ${
isInfra
? 'bg-cyan-500/20 text-cyan-400'
: 'bg-slate-500/20 text-slate-400'
}`}
>
{node.role}
</span>
</td>
<td className="px-3 py-2 text-slate-400">
{getRegionName(node.latitude)}
</td>
<td className="px-3 py-2 font-mono text-slate-300">
{formatBattery(node)}
</td>
<td className="px-3 py-2 text-slate-400">
{formatLastHeard(node.last_heard)}
</td>
<td className="px-3 py-2 font-mono text-xs text-slate-400 truncate max-w-[150px]">
{node.hardware || '—'}
</td>
</tr>
)
})}
</tbody>
</table>
{filteredNodes.length > 100 && (
<div className="px-3 py-2 text-xs text-slate-500 text-center border-t border-border">
Showing first 100 of {filteredNodes.length} nodes
</div>
)}
{filteredNodes.length === 0 && (
<div className="px-3 py-8 text-sm text-slate-500 text-center">
No nodes match your filters
</div>
)}
</div>
</div>
)
}

View file

@ -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<SimNode> {
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<SVGSVGElement>(null)
const simulationRef = useRef<d3.Simulation<SimNode, SimLink> | null>(null)
const particlesRef = useRef<Particle[]>([])
const animationRef = useRef<number>(0)
const dragNodeRef = useRef<SimNode | null>(null)
const [simNodes, setSimNodes] = useState<SimNode[]>([])
const [simLinks, setSimLinks] = useState<SimLink[]>([])
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<SimNode>(simNodesData)
.alphaDecay(0.008)
.velocityDecay(0.35)
.force('charge', d3.forceManyBody<SimNode>()
.strength((n) => n.isInfra ? -250 : -120))
.force('link', d3.forceLink<SimNode, SimLink>(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<SimNode>((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<number>()
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 (
<div className="relative bg-bg-card rounded-lg border border-border overflow-hidden">
<svg
ref={svgRef}
width={dimensions.width}
height={dimensions.height}
className="cursor-grab active:cursor-grabbing"
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onPointerLeave={handlePointerUp}
onClick={handleBackgroundClick}
>
{/* Background */}
<rect width="100%" height="100%" fill="#111827" />
{/* Edges */}
<g>
{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 (
<g key={i}>
<line
x1={sx}
y1={sy}
x2={tx}
y2={ty}
stroke={color}
strokeWidth={isRelated && selectedNodeId !== null ? 2 : 1}
opacity={opacity}
/>
{/* SNR label when node selected */}
{selectedNodeId !== null && isRelated && (
<text
x={(sx + tx) / 2}
y={(sy + ty) / 2 - 4}
fill={color}
fontSize="9"
fontFamily="JetBrains Mono, monospace"
textAnchor="middle"
opacity={0.9}
>
{link.snr.toFixed(1)} dB
</text>
)}
</g>
)
})}
</g>
{/* Particles */}
<g>
{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 (
<circle
key={i}
cx={x}
cy={y}
r={particle.size}
fill={color}
opacity={selectedNodeId === null ? 0.7 : (isRelated ? 0.8 : 0.05)}
/>
)
})}
</g>
{/* Nodes */}
<g>
{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 (
<g
key={node.id}
transform={`translate(${node.x}, ${node.y})`}
opacity={opacity}
className="cursor-pointer"
onPointerDown={(e) => handlePointerDown(e, node)}
onClick={(e) => {
e.stopPropagation()
handleNodeClick(node.id)
}}
>
{/* Selection ring */}
{isSelected && (
<circle
r={radius + 4}
fill="none"
stroke="white"
strokeWidth={2}
strokeDasharray="4 2"
opacity={0.8}
/>
)}
{/* Node circle */}
<circle
r={radius}
fill={node.isInfra ? color : '#111827'}
stroke={color}
strokeWidth={node.isInfra ? 0 : 2}
/>
{/* Label */}
<text
y={radius + 12}
textAnchor="middle"
fill="#94a3b8"
fontSize="10"
fontFamily="JetBrains Mono, monospace"
>
{node.shortName}
</text>
</g>
)
})}
</g>
{/* Legend */}
<g transform={`translate(16, ${dimensions.height - 100})`}>
<rect x={-8} y={-8} width={140} height={96} fill="#0a0e17" fillOpacity={0.8} rx={4} />
<text fill="#94a3b8" fontSize="10" fontWeight="500" y={4}>Edge Quality</text>
{[
{ 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) => (
<g key={item.label} transform={`translate(0, ${16 + i * 14})`}>
<line x1={0} y1={0} x2={16} y2={0} stroke={item.color} strokeWidth={2} />
<text x={22} y={3} fill="#64748b" fontSize="9">{item.label}</text>
</g>
))}
</g>
{/* Node type legend */}
<g transform={`translate(${dimensions.width - 130}, ${dimensions.height - 50})`}>
<rect x={-8} y={-8} width={120} height={44} fill="#0a0e17" fillOpacity={0.8} rx={4} />
<g>
<circle cx={8} cy={6} r={6} fill="#3b82f6" />
<text x={20} y={9} fill="#64748b" fontSize="9">Infrastructure</text>
</g>
<g transform="translate(0, 18)">
<circle cx={8} cy={6} r={5} fill="#111827" stroke="#3b82f6" strokeWidth={1.5} />
<text x={20} y={9} fill="#64748b" fontSize="9">Client</text>
</g>
</g>
</svg>
</div>
)
}

View file

@ -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<DuctingStatus> {
return fetchJson<DuctingStatus>('/api/env/ducting')
}
export async function fetchRegions(): Promise<unknown[]> {
return fetchJson<unknown[]>('/api/regions')
export async function fetchRegions(): Promise<RegionInfo[]> {
return fetchJson<RegionInfo[]>('/api/regions')
}

View file

@ -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 (
<div className="flex flex-col items-center justify-center h-[60vh] text-center">
<div className="w-16 h-16 rounded-full bg-bg-card border border-border flex items-center justify-center mb-6">
<Radio size={32} className="text-slate-500" />
const [nodes, setNodes] = useState<NodeInfo[]>([])
const [edges, setEdges] = useState<EdgeInfo[]>([])
const [_regions, setRegions] = useState<RegionInfo[]>([])
const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null)
const [viewMode, setViewMode] = useState<ViewMode>('topo')
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(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 (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading mesh data...</div>
</div>
<h2 className="text-xl font-semibold text-slate-300 mb-2">Mesh</h2>
<p className="text-slate-500 max-w-md">
Topology graph and geographic map coming in Phase 6
</p>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Error: {error}</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Header with view toggle */}
<div className="flex items-center justify-between">
<div className="text-sm text-slate-400">
{nodes.length} nodes {edges.length} edges
</div>
{/* View toggle */}
<div className="flex items-center bg-bg-card border border-border rounded-lg p-1">
<button
onClick={() => setViewMode('topo')}
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-colors ${
viewMode === 'topo'
? 'bg-accent text-white'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<Network size={14} />
Topology
</button>
<button
onClick={() => setViewMode('geo')}
className={`flex items-center gap-2 px-3 py-1.5 rounded text-sm transition-colors ${
viewMode === 'geo'
? 'bg-accent text-white'
: 'text-slate-400 hover:text-slate-200'
}`}
>
<Map size={14} />
Geographic
</button>
</div>
</div>
{/* Main view area */}
<div className="flex gap-0">
{/* Graph/Map */}
<div className="flex-1 min-w-0">
{viewMode === 'topo' ? (
<TopologyGraph
nodes={nodes}
edges={edges}
selectedNodeId={selectedNodeId}
onSelectNode={handleSelectNode}
/>
) : (
<GeoMap
nodes={nodes}
edges={edges}
selectedNodeId={selectedNodeId}
onSelectNode={handleSelectNode}
/>
)}
</div>
{/* Detail panel */}
<NodeDetail
node={selectedNode}
edges={edges}
nodes={nodes}
onSelectNode={handleSelectNode}
/>
</div>
{/* Node table */}
<NodeTable
nodes={nodes}
selectedNodeId={selectedNodeId}
onSelectNode={handleSelectNode}
/>
</div>
)
}