mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
7704923b8c
commit
8273913c1a
6 changed files with 1384 additions and 11 deletions
267
dashboard-frontend/src/components/GeoMap.tsx
Normal file
267
dashboard-frontend/src/components/GeoMap.tsx
Normal 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='© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>, © <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>
|
||||
)
|
||||
}
|
||||
242
dashboard-frontend/src/components/NodeDetail.tsx
Normal file
242
dashboard-frontend/src/components/NodeDetail.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
296
dashboard-frontend/src/components/NodeTable.tsx
Normal file
296
dashboard-frontend/src/components/NodeTable.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
428
dashboard-frontend/src/components/TopologyGraph.tsx
Normal file
428
dashboard-frontend/src/components/TopologyGraph.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue