mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-22 07:34:47 +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
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>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue