mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-22 07:34:47 +02:00
- 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>
296 lines
10 KiB
TypeScript
296 lines
10 KiB
TypeScript
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>
|
|
)
|
|
}
|