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

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

View file

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

View file

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