mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-05-21 23:24:44 +02:00
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:
parent
10328686e2
commit
3fa7b9fe5e
10 changed files with 551 additions and 107 deletions
156
dashboard-frontend/src/components/ChannelPicker.tsx
Normal file
156
dashboard-frontend/src/components/ChannelPicker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
210
dashboard-frontend/src/components/NodePicker.tsx
Normal file
210
dashboard-frontend/src/components/NodePicker.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -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)"
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 []
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue