feat(dashboard): Add dynamic channel and node pickers

- Add GET /api/channels endpoint for live radio channel data
- Create ChannelPicker component (single/multi-select from live channels)
- Create NodePicker component (searchable multi-select from mesh nodes)
- Replace manual inputs in Config with data-driven pickers
- Update Notifications to use pickers for mesh broadcast/DM
- Resolve node names in Alerts subscriptions display

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
K7ZVX 2026-05-13 07:07:05 +00:00
commit 3fa7b9fe5e
10 changed files with 551 additions and 107 deletions

View file

@ -0,0 +1,156 @@
import { useState, useEffect } from 'react'
import { Check } from 'lucide-react'
interface Channel {
index: number
name: string
role: string
enabled: boolean
}
interface ChannelPickerSingleProps {
label: string
value: number
onChange: (value: number) => void
helper?: string
info?: string
mode: 'single'
includeDisabled?: boolean // Include a "Disabled (-1)" option
}
interface ChannelPickerMultiProps {
label: string
value: number[]
onChange: (value: number[]) => void
helper?: string
info?: string
mode: 'multi'
}
type ChannelPickerProps = ChannelPickerSingleProps | ChannelPickerMultiProps
export default function ChannelPicker(props: ChannelPickerProps) {
const [channels, setChannels] = useState<Channel[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
fetch('/api/channels')
.then(res => res.json())
.then(data => {
setChannels(data)
setLoading(false)
})
.catch(() => {
setChannels([])
setLoading(false)
})
}, [])
const formatChannel = (ch: Channel): string => {
const roleLabel = ch.role === 'PRIMARY' ? 'Primary' :
ch.role === 'SECONDARY' ? 'Secondary' : ''
return `${ch.index}: ${ch.name}${roleLabel ? ` (${roleLabel})` : ''}`
}
// Fallback to number input if no channels loaded
if (!loading && channels.length === 0) {
if (props.mode === 'single') {
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
<input
type="number"
value={props.value}
onChange={(e) => props.onChange(Number(e.target.value))}
min={props.includeDisabled ? -1 : 0}
max={7}
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
/>
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
</div>
)
} else {
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{props.label}</label>
<input
type="text"
value={props.value.join(', ')}
onChange={(e) => {
const nums = e.target.value.split(',').map(s => parseInt(s.trim())).filter(n => !isNaN(n))
props.onChange(nums)
}}
placeholder="Enter channel numbers separated by commas"
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
/>
{props.helper && <p className="text-xs text-slate-600">{props.helper}</p>}
</div>
)
}
}
// Single select mode - dropdown
if (props.mode === 'single') {
const { value, onChange, label, helper, includeDisabled } = props
const enabledChannels = channels.filter(ch => ch.enabled)
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
<select
value={value}
onChange={(e) => onChange(Number(e.target.value))}
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
>
{includeDisabled && (
<option value={-1}>Disabled</option>
)}
{enabledChannels.map((ch) => (
<option key={ch.index} value={ch.index}>
{formatChannel(ch)}
</option>
))}
</select>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}
// Multi select mode - checkboxes
const { value, onChange, label, helper } = props
const enabledChannels = channels.filter(ch => ch.enabled)
const toggleChannel = (index: number) => {
if (value.includes(index)) {
onChange(value.filter(v => v !== index))
} else {
onChange([...value, index].sort((a, b) => a - b))
}
}
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
<div className="border border-[#1e2a3a] rounded-lg p-2 space-y-1">
{enabledChannels.map((ch) => (
<label
key={ch.index}
onClick={() => toggleChannel(ch.index)}
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
value.includes(ch.index) ? 'bg-accent border-accent' : 'border-slate-600'
}`}>
{value.includes(ch.index) && <Check size={12} className="text-white" />}
</div>
<span className="text-sm text-slate-200">{formatChannel(ch)}</span>
</label>
))}
{enabledChannels.length === 0 && (
<div className="text-sm text-slate-500 p-2">No channels available</div>
)}
</div>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}

View file

@ -0,0 +1,210 @@
import { useState, useEffect, useMemo } from 'react'
import { Search, X, Check } from 'lucide-react'
interface Node {
node_num: number
node_id_hex: string
short_name: string
long_name: string
role: string
is_infrastructure?: boolean
}
interface NodePickerProps {
label: string
value: string[]
onChange: (value: string[]) => void
helper?: string
info?: string
roleFilter?: string // e.g., "ROUTER" to show only infrastructure
valueType?: 'short_name' | 'node_num' | 'node_id_hex' // What to store in value
}
export default function NodePicker({
label,
value,
onChange,
helper,
info: _info,
roleFilter,
valueType = 'short_name',
}: NodePickerProps) {
const [nodes, setNodes] = useState<Node[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [isOpen, setIsOpen] = useState(false)
useEffect(() => {
fetch('/api/nodes')
.then(res => res.json())
.then(data => {
setNodes(data)
setLoading(false)
})
.catch(() => {
setNodes([])
setLoading(false)
})
}, [])
const filteredNodes = useMemo(() => {
let result = nodes
// Filter by role if specified
if (roleFilter) {
result = result.filter(n => {
if (roleFilter === 'ROUTER' || roleFilter === 'infrastructure') {
return n.is_infrastructure ||
n.role === 'ROUTER' ||
n.role === 'ROUTER_CLIENT' ||
n.role === 'REPEATER'
}
return n.role === roleFilter
})
}
// Filter by search
if (search.trim()) {
const s = search.toLowerCase()
result = result.filter(n =>
n.short_name?.toLowerCase().includes(s) ||
n.long_name?.toLowerCase().includes(s) ||
n.role?.toLowerCase().includes(s) ||
n.node_id_hex?.toLowerCase().includes(s)
)
}
return result.sort((a, b) => (a.short_name || '').localeCompare(b.short_name || ''))
}, [nodes, search, roleFilter])
const getNodeValue = (node: Node): string => {
switch (valueType) {
case 'node_num':
return String(node.node_num)
case 'node_id_hex':
return node.node_id_hex
default:
return node.short_name || String(node.node_num)
}
}
const isSelected = (node: Node): boolean => {
const nodeVal = getNodeValue(node)
return value.includes(nodeVal)
}
const toggleNode = (node: Node) => {
const nodeVal = getNodeValue(node)
if (value.includes(nodeVal)) {
onChange(value.filter(v => v !== nodeVal))
} else {
onChange([...value, nodeVal])
}
}
const formatNodeDisplay = (node: Node): string => {
const parts = [node.short_name]
if (node.long_name && node.long_name !== node.short_name) {
parts.push(`${node.long_name}`)
}
if (node.role) {
parts.push(`(${node.role})`)
}
return parts.join(' ')
}
// Fallback to text input if no nodes loaded
if (!loading && nodes.length === 0) {
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
<input
type="text"
value={value.join(', ')}
onChange={(e) => onChange(e.target.value.split(',').map(s => s.trim()).filter(Boolean))}
placeholder="Enter node IDs separated by commas"
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 font-mono focus:outline-none focus:border-accent"
/>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}
return (
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">{label}</label>
{/* Selected nodes display */}
{value.length > 0 && (
<div className="flex flex-wrap gap-2 mb-2">
{value.map((v) => {
const node = nodes.find(n => getNodeValue(n) === v)
return (
<span
key={v}
className="inline-flex items-center gap-1 px-2 py-1 bg-accent/20 text-accent rounded text-sm"
>
{node ? node.short_name : v}
<button
type="button"
onClick={() => onChange(value.filter(val => val !== v))}
className="hover:text-white"
>
<X size={14} />
</button>
</span>
)
})}
</div>
)}
{/* Search and dropdown */}
<div className="relative">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-slate-500" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
onFocus={() => setIsOpen(true)}
placeholder={loading ? "Loading nodes..." : "Search nodes..."}
className="w-full pl-9 pr-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
/>
</div>
{isOpen && !loading && (
<>
<div className="fixed inset-0 z-40" onClick={() => setIsOpen(false)} />
<div className="absolute left-0 right-0 top-full mt-1 z-50 max-h-64 overflow-y-auto bg-[#0a0e17] border border-[#1e2a3a] rounded-lg shadow-xl">
{filteredNodes.length === 0 ? (
<div className="p-3 text-sm text-slate-500 text-center">
No nodes found
</div>
) : (
filteredNodes.map((node) => (
<button
key={node.node_num}
type="button"
onClick={() => toggleNode(node)}
className={`w-full flex items-center gap-2 px-3 py-2 text-left text-sm hover:bg-[#1e2a3a] ${
isSelected(node) ? 'bg-accent/10' : ''
}`}
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
isSelected(node) ? 'bg-accent border-accent' : 'border-slate-600'
}`}>
{isSelected(node) && <Check size={12} className="text-white" />}
</div>
<span className="text-slate-200">{formatNodeDisplay(node)}</span>
</button>
))
)}
</div>
</>
)}
</div>
{helper && <p className="text-xs text-slate-600">{helper}</p>}
</div>
)
}

View file

@ -26,6 +26,13 @@ import {
type AlertHistoryItem, type AlertHistoryItem,
type Subscription, type Subscription,
} from '@/lib/api' } from '@/lib/api'
interface Node {
node_num: number
node_id_hex: string
short_name: string
long_name: string
}
import { useWebSocket } from '@/hooks/useWebSocket' import { useWebSocket } from '@/hooks/useWebSocket'
// Alert type icons mapping // Alert type icons mapping
@ -308,7 +315,20 @@ function AlertHistoryTable({
} }
// Subscription Card Component // Subscription Card Component
function SubscriptionCard({ subscription }: { subscription: Subscription }) { function SubscriptionCard({ subscription, nodes }: { subscription: Subscription; nodes: Node[] }) {
const resolveNodeName = (userId: string): string => {
const node = nodes.find(n =>
n.node_id_hex === userId ||
String(n.node_num) === userId ||
n.short_name === userId
)
if (node) {
return node.long_name && node.long_name !== node.short_name
? `${node.short_name} (${node.long_name})`
: node.short_name
}
return userId
}
const formatSchedule = () => { const formatSchedule = () => {
if (subscription.sub_type === 'alerts') { if (subscription.sub_type === 'alerts') {
return 'Real-time' return 'Real-time'
@ -356,7 +376,7 @@ function SubscriptionCard({ subscription }: { subscription: Subscription }) {
)} )}
</div> </div>
<div className="text-xs text-slate-500 mt-0.5"> <div className="text-xs text-slate-500 mt-0.5">
{formatSchedule()} Node {subscription.user_id} {formatSchedule()} {resolveNodeName(subscription.user_id)}
</div> </div>
</div> </div>
<div className={`w-2 h-2 rounded-full ${subscription.enabled ? 'bg-green-500' : 'bg-slate-500'}`} /> <div className={`w-2 h-2 rounded-full ${subscription.enabled ? 'bg-green-500' : 'bg-slate-500'}`} />
@ -369,6 +389,7 @@ export default function Alerts() {
const [activeAlerts, setActiveAlerts] = useState<Alert[]>([]) const [activeAlerts, setActiveAlerts] = useState<Alert[]>([])
const [history, setHistory] = useState<AlertHistoryItem[]>([]) const [history, setHistory] = useState<AlertHistoryItem[]>([])
const [subscriptions, setSubscriptions] = useState<Subscription[]>([]) const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
const [nodes, setNodes] = useState<Node[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -395,8 +416,9 @@ export default function Alerts() {
fetchAlerts().catch(() => []), fetchAlerts().catch(() => []),
fetchAlertHistory(pageSize, 0).catch(() => ({ items: [], total: 0 })), fetchAlertHistory(pageSize, 0).catch(() => ({ items: [], total: 0 })),
fetchSubscriptions().catch(() => []), fetchSubscriptions().catch(() => []),
fetch('/api/nodes').then(r => r.json()).catch(() => []),
]) ])
.then(([alerts, historyData, subs]) => { .then(([alerts, historyData, subs, nodeData]) => {
setActiveAlerts(alerts) setActiveAlerts(alerts)
if (Array.isArray(historyData)) { if (Array.isArray(historyData)) {
setHistory(historyData) setHistory(historyData)
@ -406,6 +428,7 @@ export default function Alerts() {
setTotalPages(Math.ceil((historyData.total || 0) / pageSize)) setTotalPages(Math.ceil((historyData.total || 0) / pageSize))
} }
setSubscriptions(subs) setSubscriptions(subs)
setNodes(nodeData)
setLoading(false) setLoading(false)
}) })
.catch((err) => { .catch((err) => {
@ -532,7 +555,7 @@ export default function Alerts() {
{subscriptions.length > 0 ? ( {subscriptions.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{subscriptions.map((sub) => ( {subscriptions.map((sub) => (
<SubscriptionCard key={sub.id} subscription={sub} /> <SubscriptionCard key={sub.id} subscription={sub} nodes={nodes} />
))} ))}
</div> </div>
) : ( ) : (

View file

@ -1,4 +1,6 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import NodePicker from '@/components/NodePicker'
import ChannelPicker from '@/components/ChannelPicker'
import { import {
Settings, Bot, Wifi, MessageSquare, Database, Brain, Eye, Settings, Bot, Wifi, MessageSquare, Database, Brain, Eye,
Terminal, Cpu, Cloud, Radio, BookOpen, Layers, Activity, Terminal, Cpu, Cloud, Radio, BookOpen, Layers, Activity,
@ -882,18 +884,19 @@ function ContextSection({ data, onChange }: { data: ContextConfig; onChange: (d:
/> />
{data.enabled && ( {data.enabled && (
<> <>
<NumberListInput <ChannelPicker
label="Observe Channels" label="Observe Channels"
value={data.observe_channels} value={data.observe_channels}
onChange={(v) => onChange({ ...data, observe_channels: v })} onChange={(v) => onChange({ ...data, observe_channels: v })}
helper="Channel indexes to monitor (empty = all)" helper="Channels to monitor (empty = all)"
info="Meshtastic channel numbers to listen on. Channel 0 is the default primary channel. Leave empty to monitor all channels." info="Meshtastic channels to listen on. Leave empty to monitor all channels."
mode="multi"
/> />
<ListInput <NodePicker
label="Ignore Nodes" label="Ignore Nodes"
value={data.ignore_nodes} value={data.ignore_nodes}
onChange={(v) => onChange({ ...data, ignore_nodes: v })} onChange={(v) => onChange({ ...data, ignore_nodes: v })}
helper="Node IDs to exclude from context" helper="Nodes to exclude from context"
info="Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes." info="Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes."
/> />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
@ -1439,22 +1442,24 @@ function MeshIntelligenceSection({ data, onChange }: { data: MeshIntelligenceCon
/> />
</div> </div>
<ListInput <NodePicker
label="Critical Nodes" label="Critical Nodes"
value={data.critical_nodes} value={data.critical_nodes}
onChange={(v) => onChange({ ...data, critical_nodes: v })} onChange={(v) => onChange({ ...data, critical_nodes: v })}
helper="Short names of critical infrastructure" helper="Critical infrastructure nodes"
info="Nodes that get priority alerting when they go offline. Use the node's short name (e.g., MHR, HPR)." info="Nodes that get priority alerting when they go offline."
roleFilter="infrastructure"
/> />
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<NumberInput <ChannelPicker
label="Alert Channel" label="Alert Channel"
value={data.alert_channel} value={data.alert_channel}
onChange={(v) => onChange({ ...data, alert_channel: v })} onChange={(v) => onChange({ ...data, alert_channel: v })}
min={-1} helper="Channel for broadcast alerts"
helper="-1 = disabled" info="Meshtastic channel for broadcast alerts. Select Disabled to turn off channel broadcasting."
info="Meshtastic channel number for broadcast alerts. Set to -1 to disable channel broadcasting." mode="single"
includeDisabled
/> />
<NumberInput <NumberInput
label="Alert Cooldown (min)" label="Alert Cooldown (min)"

View file

@ -3,6 +3,8 @@ import {
Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight, Save, RotateCcw, RefreshCw, Plus, Trash2, ChevronDown, ChevronRight,
Check, X, Eye as EyeIcon, EyeOff, ExternalLink, Send Check, X, Eye as EyeIcon, EyeOff, ExternalLink, Send
} from 'lucide-react' } from 'lucide-react'
import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker'
// Types // Types
interface NotificationChannelConfig { interface NotificationChannelConfig {
@ -375,24 +377,24 @@ function NotificationChannelCard({
/> />
{channel.type === 'mesh_broadcast' && ( {channel.type === 'mesh_broadcast' && (
<NumberInput <ChannelPicker
label="Channel Index" label="Broadcast Channel"
value={channel.channel_index} value={channel.channel_index}
onChange={(v) => onChange({ ...channel, channel_index: v })} onChange={(v) => onChange({ ...channel, channel_index: v })}
min={0} helper="Channel for broadcast alerts"
max={7} info="The mesh channel to broadcast alerts on."
helper="Mesh channel number (0-7)" mode="single"
info="The mesh channel to broadcast alerts on. Channel 0 is typically the default channel."
/> />
)} )}
{channel.type === 'mesh_dm' && ( {channel.type === 'mesh_dm' && (
<ListInput <NodePicker
label="Node IDs" label="Recipient Nodes"
value={channel.node_ids} value={channel.node_ids}
onChange={(v) => onChange({ ...channel, node_ids: v })} onChange={(v) => onChange({ ...channel, node_ids: v })}
helper="Node IDs to receive DM alerts" helper="Nodes to receive DM alerts"
info="Node IDs that receive direct message alerts. Enter the full node ID (e.g., '!a1b2c3d4') for each recipient." info="Nodes that receive direct message alerts."
valueType="node_id_hex"
/> />
)} )}

View file

@ -354,3 +354,50 @@ async def get_edges(request: Request):
}) })
return edges return edges
@router.get("/channels")
async def get_channels(request: Request):
"""Get radio channels from the connected Meshtastic interface."""
connector = getattr(request.app.state, "connector", None)
if not connector or not connector.connected:
return []
try:
interface = connector._interface
if not interface or not hasattr(interface, "localNode"):
return []
local_node = interface.localNode
if not local_node or not hasattr(local_node, "channels"):
return []
channels = []
for ch in local_node.channels:
if ch is None:
continue
# Get channel settings
settings = getattr(ch, "settings", None)
name = getattr(settings, "name", "") if settings else ""
role_val = getattr(ch, "role", 0)
# Map role enum to string
role_map = {0: "DISABLED", 1: "PRIMARY", 2: "SECONDARY"}
role = role_map.get(role_val, "UNKNOWN")
channels.append({
"index": ch.index,
"name": name or f"Channel {ch.index}",
"role": role,
"enabled": role_val != 0,
})
return channels
except Exception as e:
import logging
logging.getLogger(__name__).warning(f"Failed to get channels: {e}")
return []

View file

@ -113,6 +113,7 @@ async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster:
app.state.env_store = getattr(meshai_instance, "env_store", None) app.state.env_store = getattr(meshai_instance, "env_store", None)
app.state.subscription_manager = meshai_instance.subscription_manager app.state.subscription_manager = meshai_instance.subscription_manager
app.state.notification_router = getattr(meshai_instance, "notification_router", None) app.state.notification_router = getattr(meshai_instance, "notification_router", None)
app.state.connector = meshai_instance.connector
# Create broadcaster and attach to app state # Create broadcaster and attach to app state
broadcaster = DashboardBroadcaster() broadcaster = DashboardBroadcaster()

View file

@ -8,8 +8,8 @@
<link rel="preconnect" href="https://fonts.googleapis.com"> <link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet"> <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600;700&display=swap" rel="stylesheet">
<script type="module" crossorigin src="/assets/index-DGtsDLP7.js"></script> <script type="module" crossorigin src="/assets/index-BNjrbmGz.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D1Pqs_mG.css"> <link rel="stylesheet" crossorigin href="/assets/index-so1NV9Au.css">
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>