feat(routing): MeshCore as first-class delivery types (meshcore_broadcast/dm) (#11)

* feat(routing): MeshCore as first-class delivery types (meshcore_broadcast/dm)

Replace the composite auto-fan with explicit per-mesh delivery types so
each family independently controls broadcast/DM per severity on Meshtastic
AND MeshCore. mesh_broadcast->Meshtastic only, meshcore_broadcast->MeshCore
(by channel name), mesh_dm/meshcore_dm likewise; routing via the existing
transport hint. Adds meshcore_dm_contacts. Meshtastic-only configs
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(routing): deliver meshcore_broadcast via meshcore_channel through CompositeTransport

The hinted _broadcast path passed the channel NAME on the `channel` kwarg,
which MeshCoreTransport ignores (it reads meshcore_channel), so
meshcore_broadcast silently no-op'd on transport=both configs. Route the
meshcore child via meshcore_channel and the meshtastic child via channel.
Fix the test that asserted the broken kwarg layout. Add the new delivery
types to the remaining enumeration/validation sites (channel-test endpoint,
scheduler digest chunking, danger-zone valid set).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): split Notifications into Meshtastic and MeshCore sections

Delineate per-mesh routing: each family configures Meshtastic delivery
(mesh_broadcast/mesh_dm, channel index, node IDs) and MeshCore delivery
(meshcore_broadcast/meshcore_dm, channel name, contacts) in separate
sections; shared settings (enable/severity/regions/email/webhook) once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): first-class MeshCore nav section + dedicated pages

Group the sidebar into Meshtastic and MeshCore sections. Promote MeshCore
routing and connection to their own pages; move MeshCore routing out of
Notifications (which stays Meshtastic + shared family settings). Add
placeholder Contacts and Companion pages for the follow-on companion data
API. No backend change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(dashboard): parallel MT/MC nav order + symmetric connection links

Order both nav groups Connection/Routing/Mesh(Contacts)/Sources(Companion).
Replace the prominent MeshCore block on the Meshtastic Connection page with
a single subtle cross-link, mirrored on the MeshCore Connection page.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(dashboard): focused Meshtastic Connection/Sources pages for MT/MC parity

Meshtastic Connection and Sources are now their own focused pages
(mirroring MeshCore), instead of deep-linking into the full Config page.
Global settings move to a restored top-level Config item. No duplicate
editors; connection cross-links are mirror-image between the two pages.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Matt Johnson <mj@k7zvx.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
malice 2026-07-02 22:42:14 -06:00 committed by GitHub
commit de1e58aa71
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
19 changed files with 1870 additions and 219 deletions

View file

@ -10,6 +10,12 @@ import Reference from './pages/Reference'
import AdapterConfig from './pages/AdapterConfig'
import GaugeSites from './pages/GaugeSites'
import TownAnchors from './pages/TownAnchors'
import MeshCoreRouting from './pages/MeshCoreRouting'
import MeshCoreConnection from './pages/MeshCoreConnection'
import MeshCoreContacts from './pages/MeshCoreContacts'
import MeshCoreCompanion from './pages/MeshCoreCompanion'
import MeshtasticConnection from './pages/MeshtasticConnection'
import MeshtasticSources from './pages/MeshtasticSources'
import { ToastProvider } from './components/ToastProvider'
function App() {
@ -27,6 +33,12 @@ function App() {
<Route path="/adapter-config" element={<AdapterConfig />} />
<Route path="/gauge-sites" element={<GaugeSites />} />
<Route path="/town-anchors" element={<TownAnchors />} />
<Route path="/meshcore/routing" element={<MeshCoreRouting />} />
<Route path="/meshcore/connection" element={<MeshCoreConnection />} />
<Route path="/meshcore/contacts" element={<MeshCoreContacts />} />
<Route path="/meshcore/companion" element={<MeshCoreCompanion />} />
<Route path="/meshtastic/connection" element={<MeshtasticConnection />} />
<Route path="/meshtastic/sources" element={<MeshtasticSources />} />
</Routes>
</Layout>
</ToastProvider>

View file

@ -4,13 +4,19 @@ import {
LayoutDashboard,
Radio,
Cloud,
Settings,
Bell,
BellRing,
BookOpen,
Sliders,
Droplets,
MapPin,
Wifi,
Layers,
Network,
Users,
Bot,
Settings,
type LucideIcon,
} from 'lucide-react'
import { fetchStatus, type SystemStatus } from '@/lib/api'
import { useWebSocket } from '@/hooks/useWebSocket'
@ -21,19 +27,58 @@ interface LayoutProps {
children: ReactNode
}
const navItems = [
interface NavItem {
path: string
label: string
icon: LucideIcon
}
interface NavGroup {
header: string
items: NavItem[]
}
// Top-level, ungrouped items (no header).
const topNavItems: NavItem[] = [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/mesh', label: 'Mesh', icon: Radio },
{ path: '/environment', label: 'Environment', icon: Cloud },
{ path: '/config', label: 'Config', icon: Settings },
{ path: '/alerts', label: 'Alerts', icon: Bell },
{ path: '/notifications', label: 'Notifications', icon: BellRing },
{ path: '/reference', label: 'Reference', icon: BookOpen },
{ path: '/adapter-config', label: 'Adapter Config', icon: Sliders },
{ path: '/config', label: 'Config', icon: Settings },
{ path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets },
{ path: '/town-anchors', label: 'Town Anchors', icon: MapPin },
]
// Grouped sections with labeled headers. Meshtastic "Connection" and "Sources"
// are focused standalone pages (/meshtastic/connection, /meshtastic/sources).
const navGroups: NavGroup[] = [
{
header: 'Meshtastic',
items: [
{ path: '/meshtastic/connection', label: 'Connection', icon: Wifi },
{ path: '/notifications', label: 'Routing', icon: BellRing },
{ path: '/mesh', label: 'Mesh', icon: Radio },
{ path: '/meshtastic/sources', label: 'Sources', icon: Layers },
],
},
{
header: 'MeshCore',
items: [
{ path: '/meshcore/connection', label: 'Connection', icon: Network },
{ path: '/meshcore/routing', label: 'Routing', icon: BellRing },
{ path: '/meshcore/contacts', label: 'Contacts', icon: Users },
{ path: '/meshcore/companion', label: 'Companion', icon: Bot },
],
},
]
// Flattened view of every nav item (top + all groups) for title lookup.
const allNavItems: NavItem[] = [
...topNavItems,
...navGroups.flatMap((g) => g.items),
]
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
@ -44,9 +89,40 @@ function formatUptime(seconds: number): string {
return `${mins}m`
}
function getPageTitle(pathname: string): string {
const item = navItems.find((i) => i.path === pathname)
return item?.label || 'Dashboard'
// Renders a single nav <Link>. Items whose path carries a ?section= query are
// matched against pathname+search so only the matching deep-link highlights.
function renderNavItem(item: NavItem, pathname: string, search: string) {
const isActive = item.path.includes('?')
? `${pathname}${search}` === item.path
: pathname === item.path
const Icon = item.icon
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${
isActive
? 'text-white bg-transparent'
: 'text-[#777] hover:text-white hover:bg-bg-hover'
}`}
>
{isActive && (
<div className="absolute right-0 top-0 bottom-0 w-[2px] bg-[#f59e0b]" />
)}
<Icon size={16} />
{item.label}
</Link>
)
}
function getPageTitle(fullPath: string): string {
// Exact match first (honors any ?section= query on deep-linked items).
const exact = allNavItems.find((i) => i.path === fullPath)
if (exact) return exact.label
// Fallback: match by pathname only, ignoring query strings.
const base = fullPath.split('?')[0]
const byPath = allNavItems.find((i) => i.path.split('?')[0] === base)
return byPath?.label || 'Dashboard'
}
export default function Layout({ children }: LayoutProps) {
@ -106,27 +182,15 @@ export default function Layout({ children }: LayoutProps) {
{/* Navigation */}
<nav className="flex-1 py-4">
{navItems.map((item) => {
const isActive = location.pathname === item.path
const Icon = item.icon
return (
<Link
key={item.path}
to={item.path}
className={`flex items-center gap-3 px-5 py-3 text-sm font-sans transition-colors relative ${
isActive
? 'text-white bg-transparent'
: 'text-[#777] hover:text-white hover:bg-bg-hover'
}`}
>
{isActive && (
<div className="absolute right-0 top-0 bottom-0 w-[2px] bg-[#f59e0b]" />
)}
<Icon size={16} />
{item.label}
</Link>
)
})}
{topNavItems.map((item) => renderNavItem(item, location.pathname, location.search))}
{navGroups.map((group) => (
<div key={group.header} className="mt-4">
<div className="px-5 pt-2 pb-1 text-[10px] font-sans font-semibold uppercase tracking-wider text-[#555]">
{group.header}
</div>
{group.items.map((item) => renderNavItem(item, location.pathname, location.search))}
</div>
))}
</nav>
{/* Connection status */}
@ -155,7 +219,7 @@ export default function Layout({ children }: LayoutProps) {
{/* Header */}
<header className="h-14 flex-shrink-0 border-b border-border bg-bg-card flex items-center justify-between px-6">
<h1 className="text-lg font-sans font-semibold text-white">
{getPageTitle(location.pathname)}
{getPageTitle(location.pathname + location.search)}
</h1>
<div className="flex items-center gap-6">
{/* Live indicator */}

View file

@ -1,10 +1,11 @@
import { useState, useEffect, useCallback, useRef } from 'react'
import { Link, useSearchParams } from 'react-router-dom'
import { notifyRestartRequired } from '@/components/RestartBanner'
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,
Settings, Bot, MessageSquare, Database, Brain, Eye,
Terminal, Cpu, Cloud, BookOpen, Activity,
LayoutDashboard, Save, RotateCcw, RefreshCw,
Plus, Trash2, ChevronDown, ChevronRight, AlertTriangle,
Check, X, Eye as EyeIcon, EyeOff, ExternalLink
@ -18,7 +19,7 @@ interface BotConfig {
filter_bbs_protocols: boolean
}
interface ConnectionConfig {
export interface ConnectionConfig {
type: string
serial_port: string
tcp_host: string
@ -86,7 +87,7 @@ interface WeatherConfig {
wttr: { url: string }
}
interface MeshMonitorConfig {
export interface MeshMonitorConfig {
enabled: boolean
url: string
inject_into_prompt: boolean
@ -109,7 +110,7 @@ interface KnowledgeConfig {
top_k: number
}
interface MeshSourceConfig {
export interface MeshSourceConfig {
name: string
type: string
url: string
@ -225,7 +226,6 @@ type SectionKey = keyof FullConfig
const SECTIONS: { key: SectionKey; label: string; icon: typeof Settings }[] = [
{ key: 'bot', label: 'Bot', icon: Bot },
{ key: 'connection', label: 'Connection', icon: Wifi },
{ key: 'response', label: 'Response', icon: MessageSquare },
{ key: 'history', label: 'History', icon: Database },
{ key: 'memory', label: 'Memory', icon: Brain },
@ -233,9 +233,7 @@ const SECTIONS: { key: SectionKey; label: string; icon: typeof Settings }[] = [
{ key: 'commands', label: 'Commands', icon: Terminal },
{ key: 'llm', label: 'LLM', icon: Cpu },
{ key: 'weather', label: 'Weather', icon: Cloud },
{ key: 'meshmonitor', label: 'MeshMonitor', icon: Radio },
{ key: 'knowledge', label: 'Knowledge', icon: BookOpen },
{ key: 'mesh_sources', label: 'Mesh Sources', icon: Layers },
{ key: 'mesh_intelligence', label: 'Intelligence', icon: Activity },
{ key: 'dashboard', label: 'Dashboard', icon: LayoutDashboard },
]
@ -709,24 +707,10 @@ function BotSection({ data, onChange }: { data: BotConfig; onChange: (d: BotConf
)
}
function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChange: (d: ConnectionConfig) => void }) {
const transport = data.transport ?? 'meshtastic'
const showMeshCore = transport === 'meshcore' || transport === 'both'
export function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChange: (d: ConnectionConfig) => void }) {
return (
<div className="space-y-4">
<SectionDescription text={SECTION_DESCRIPTIONS.connection} />
<SelectInput
label="Transport Mode"
value={transport}
onChange={(v) => onChange({ ...data, transport: v })}
options={[
{ value: 'meshtastic', label: 'Meshtastic' },
{ value: 'meshcore', label: 'MeshCore' },
{ value: 'both', label: 'Both' },
]}
helper="Which radio transport(s) MeshAI uses"
info="Meshtastic: connect to a Meshtastic radio only. MeshCore: connect to a MeshCore node only. Both: connect to both simultaneously for dual-transport operation."
/>
<SelectInput
label="Connection Type"
value={data.type}
@ -766,29 +750,16 @@ function ConnectionSection({ data, onChange }: { data: ConnectionConfig; onChang
/>
</div>
)}
{showMeshCore && (
<div className="space-y-4 pt-2 border-t border-[#1e2a3a]">
<div className="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div>
<div className="grid grid-cols-2 gap-4">
<TextInput
label="MeshCore Host"
value={data.meshcore_host ?? ''}
onChange={(v) => onChange({ ...data, meshcore_host: v })}
placeholder="192.168.1.100"
helper="IP or hostname of the MeshCore node"
info="Address of the MeshCore node to connect to."
/>
<NumberInput
label="MeshCore Port"
value={data.meshcore_port ?? 5525}
onChange={(v) => onChange({ ...data, meshcore_port: v })}
min={1}
max={65535}
helper="MeshCore TCP port (default 5525)"
/>
</div>
</div>
)}
{/* MeshCore transport + host/port live on their own first-class page
(/meshcore/connection). Subtle cross-link only no editable fields here. */}
<div className="pt-2">
<Link
to="/meshcore/connection"
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-accent transition-colors"
>
&rarr; MeshCore transport &amp; connection
</Link>
</div>
</div>
)
}
@ -1185,7 +1156,7 @@ function WeatherSection({ data, onChange }: { data: WeatherConfig; onChange: (d:
)
}
function MeshMonitorSection({ data, onChange }: { data: MeshMonitorConfig; onChange: (d: MeshMonitorConfig) => void }) {
export function MeshMonitorSection({ data, onChange }: { data: MeshMonitorConfig; onChange: (d: MeshMonitorConfig) => void }) {
return (
<div className="space-y-4">
<SectionDescription text={SECTION_DESCRIPTIONS.meshmonitor} />
@ -1402,7 +1373,7 @@ function MeshSourceCard({ source, onChange, onDelete }: {
)
}
function MeshSourcesSection({ data, onChange }: { data: MeshSourceConfig[]; onChange: (d: MeshSourceConfig[]) => void }) {
export function MeshSourcesSection({ data, onChange }: { data: MeshSourceConfig[]; onChange: (d: MeshSourceConfig[]) => void }) {
const addSource = () => {
onChange([...data, {
name: 'New Source',
@ -1831,6 +1802,16 @@ export default function Config() {
const [config, setConfig] = useState<FullConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<FullConfig | null>(null)
const [activeSection, setActiveSection] = useState<SectionKey>('bot')
const [searchParams] = useSearchParams()
// Deep-link support: nav items like /config?section=connection pre-select a
// section. Runs on mount and whenever the query param changes.
useEffect(() => {
const section = searchParams.get('section')
if (section && SECTIONS.some((s) => s.key === section)) {
setActiveSection(section as SectionKey)
}
}, [searchParams])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -1945,7 +1926,6 @@ export default function Config() {
const renderSection = () => {
switch (activeSection) {
case 'bot': return <BotSection data={config.bot} onChange={(d) => updateSection('bot', d)} />
case 'connection': return <ConnectionSection data={config.connection} onChange={(d) => updateSection('connection', d)} />
case 'response': return <ResponseSection data={config.response} onChange={(d) => updateSection('response', d)} />
case 'history': return <HistorySection data={config.history} onChange={(d) => updateSection('history', d)} />
case 'memory': return <MemorySection data={config.memory} onChange={(d) => updateSection('memory', d)} />
@ -1953,9 +1933,7 @@ export default function Config() {
case 'commands': return <CommandsSection data={config.commands} onChange={(d) => updateSection('commands', d)} />
case 'llm': return <LLMSection data={config.llm} onChange={(d) => updateSection('llm', d)} />
case 'weather': return <WeatherSection data={config.weather} onChange={(d) => updateSection('weather', d)} />
case 'meshmonitor': return <MeshMonitorSection data={config.meshmonitor} onChange={(d) => updateSection('meshmonitor', d)} />
case 'knowledge': return <KnowledgeSection data={config.knowledge} onChange={(d) => updateSection('knowledge', d)} />
case 'mesh_sources': return <MeshSourcesSection data={config.mesh_sources} onChange={(d) => updateSection('mesh_sources', d)} />
case 'mesh_intelligence': return <MeshIntelligenceSection data={config.mesh_intelligence} onChange={(d) => updateSection('mesh_intelligence', d)} />
case 'dashboard': return <DashboardSection data={config.dashboard} onChange={(d) => updateSection('dashboard', d)} />
default: return null

View file

@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { Bot } from 'lucide-react'
export default function MeshCoreCompanion() {
useEffect(() => {
document.title = 'Companion & Channels - MeshAI'
}, [])
return (
<div className="max-w-3xl mx-auto">
<div className="bg-bg-card border border-border p-8">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
<Bot size={24} className="text-accent" />
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-slate-100">Companion &amp; Channels</h2>
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
Coming soon
</span>
</div>
<p className="text-sm text-slate-400 leading-relaxed max-w-prose">
This page will show live status for the AIDA MeshCore companion &mdash; its connection
health and the list of channels it is currently joined to. Once the companion status
API is available, you'll be able to monitor the companion here and see which channels
are reachable for broadcast delivery.
</p>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,198 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { TextInput, NumberInput, SelectInput } from './Config'
import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
// Only the fields this page edits are typed explicitly; the rest of the
// connection config (Meshtastic type / serial / tcp) is preserved untouched on
// save via object spread.
interface ConnectionConfig {
type?: string
serial_port?: string
tcp_host?: string
tcp_port?: number
transport?: string
meshcore_host?: string
meshcore_port?: number
[key: string]: unknown
}
export default function MeshCoreConnection() {
const [config, setConfig] = useState<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
const fetchConfig = useCallback(async () => {
setLoading(true)
try {
const data = (await apiFetchConfig('connection')) as ConnectionConfig
setConfig(data)
setOriginalConfig(JSON.parse(JSON.stringify(data)))
setHasChanges(false)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
document.title = 'MeshCore Connection - MeshAI'
fetchConfig()
}, [fetchConfig])
useEffect(() => {
if (config && originalConfig) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
}
}, [config, originalConfig])
const upd = (patch: Partial<ConnectionConfig>) =>
setConfig((c) => (c ? { ...c, ...patch } : c))
const saveConfig = async () => {
if (!config) return
setSaving(true)
setError(null)
setSuccess(null)
try {
// PUT the whole connection object so Meshtastic fields are preserved.
const result = await apiUpdateConfig('connection', config)
setOriginalConfig(JSON.parse(JSON.stringify(config)))
setHasChanges(false)
setSuccess('MeshCore connection saved successfully')
if (result.restart_required) {
notifyRestartRequired([])
}
setTimeout(() => setSuccess(null), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
const discardChanges = () => {
if (originalConfig) {
setConfig(JSON.parse(JSON.stringify(originalConfig)))
setHasChanges(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading MeshCore connection...</div>
</div>
)
}
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Failed to load connection config</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
Transport mode and MeshCore node connection.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchConfig}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
<button
onClick={discardChanges}
disabled={!hasChanges}
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={16} />
Discard
</button>
<button
onClick={saveConfig}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Status messages */}
{error && (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
)}
{success && (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />
{success}
</div>
)}
{/* Form */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<SelectInput
label="Transport Mode"
value={config.transport ?? 'meshtastic'}
onChange={(v) => upd({ transport: v })}
options={[
{ value: 'meshtastic', label: 'Meshtastic' },
{ value: 'meshcore', label: 'MeshCore' },
{ value: 'both', label: 'Both' },
]}
helper="Which radio transport(s) MeshAI uses"
info="Meshtastic: connect to a Meshtastic radio only. MeshCore: connect to a MeshCore node only. Both: connect to both simultaneously for dual-transport operation."
/>
<div className="pt-2 border-t border-[#1e2a3a] space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">MeshCore Connection</div>
<div className="grid grid-cols-2 gap-4">
<TextInput
label="MeshCore Host"
value={config.meshcore_host ?? ''}
onChange={(v) => upd({ meshcore_host: v })}
placeholder="192.168.1.100"
helper="IP or hostname of the MeshCore node"
info="Address of the MeshCore node to connect to."
/>
<NumberInput
label="MeshCore Port"
value={config.meshcore_port ?? 5525}
onChange={(v) => upd({ meshcore_port: v })}
min={1}
max={65535}
helper="MeshCore TCP port (default 5525)"
/>
</div>
</div>
<div className="pt-2">
<Link
to="/meshtastic/connection"
className="inline-flex items-center gap-1 text-xs text-slate-500 hover:text-accent transition-colors"
>
&rarr; Meshtastic connection
</Link>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,34 @@
import { useEffect } from 'react'
import { Users } from 'lucide-react'
export default function MeshCoreContacts() {
useEffect(() => {
document.title = 'MeshCore Contacts - MeshAI'
}, [])
return (
<div className="max-w-3xl mx-auto">
<div className="bg-bg-card border border-border p-8">
<div className="flex items-start gap-4">
<div className="flex-shrink-0 w-12 h-12 rounded-lg bg-[#0a0e17] border border-[#1e2a3a] flex items-center justify-center">
<Users size={24} className="text-accent" />
</div>
<div className="space-y-3">
<div className="flex items-center gap-3">
<h2 className="text-xl font-semibold text-slate-100">MeshCore Contacts</h2>
<span className="px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
Coming soon
</span>
</div>
<p className="text-sm text-slate-400 leading-relaxed max-w-prose">
This page will show the MeshCore companion's contact roster &mdash; the names, public
keys, last-heard timestamps, and positions of the nodes your companion knows about.
It becomes available once the companion data API is wired up, at which point contacts
can be browsed here and referenced directly when configuring MeshCore DM delivery.
</p>
</div>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,275 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
import { Save, RotateCcw, RefreshCw, Check, MessageSquare, ExternalLink } from 'lucide-react'
import {
SeverityChannelMatrix,
ListInput,
InfoButton,
TOGGLE_FAMILY_META,
MC_CHANNELS,
type NotificationToggle,
type NotificationsConfig,
} from './Notifications'
// Merge only the MeshCore-owned fields of `mine` into `fresh`, preserving every
// other (Meshtastic / Other-channels / general) field on the family. The
// severity matrix stores all channels in one dict per severity, so we keep the
// non-meshcore_* entries from the freshly-fetched config and overlay only the
// meshcore_* entries edited on this page.
function mergeMeshcoreFields(
fresh: NotificationToggle | undefined,
mine: NotificationToggle,
key: string,
): NotificationToggle {
const base: NotificationToggle = fresh ? { ...fresh } : { ...mine, name: key }
base.name = base.name || key
const freshSC = fresh?.severity_channels || {}
const mineSC = mine.severity_channels || {}
const severities = new Set([...Object.keys(freshSC), ...Object.keys(mineSC)])
const mergedSC: Record<string, string[]> = {}
severities.forEach((sev) => {
const nonMeshcore = (freshSC[sev] || []).filter((c) => !c.startsWith('meshcore_'))
const meshcore = (mineSC[sev] || []).filter((c) => c.startsWith('meshcore_'))
mergedSC[sev] = [...nonMeshcore, ...meshcore]
})
base.severity_channels = mergedSC
base.meshcore_channel = mine.meshcore_channel ?? null
base.meshcore_dm_contacts = mine.meshcore_dm_contacts || []
return base
}
export default function MeshCoreRouting() {
const [config, setConfig] = useState<NotificationsConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<NotificationsConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
const fetchConfig = useCallback(async () => {
try {
const res = await fetch('/api/config/notifications')
if (!res.ok) throw new Error('Failed to fetch notifications config')
const data: NotificationsConfig = await res.json()
setConfig(data)
setOriginalConfig(JSON.parse(JSON.stringify(data)))
setHasChanges(false)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
document.title = 'MeshCore Routing - MeshAI'
fetchConfig()
}, [fetchConfig])
useEffect(() => {
if (config && originalConfig) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
}
}, [config, originalConfig])
const upd = (fam: string, patch: Partial<NotificationToggle>) => {
if (!config) return
const toggles = config.toggles || {}
setConfig({
...config,
toggles: {
...toggles,
[fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle,
},
})
}
const saveConfig = async () => {
if (!config) return
setSaving(true)
setError(null)
setSuccess(null)
try {
// Re-fetch the live config and merge ONLY the MeshCore fields so we never
// clobber concurrent edits made on the Meshtastic Routing page.
const freshRes = await fetch('/api/config/notifications')
if (!freshRes.ok) throw new Error('Failed to re-fetch notifications config')
const fresh: NotificationsConfig = await freshRes.json()
const merged: NotificationsConfig = { ...fresh, toggles: { ...(fresh.toggles || {}) } }
const myToggles = config.toggles || {}
for (const { key } of TOGGLE_FAMILY_META) {
const mine = myToggles[key]
if (!mine) continue
merged.toggles![key] = mergeMeshcoreFields((fresh.toggles || {})[key], mine, key)
}
const res = await fetch('/api/config/notifications', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(merged),
})
const result = await res.json()
if (!res.ok) throw new Error(result.detail || 'Save failed')
setConfig(merged)
setOriginalConfig(JSON.parse(JSON.stringify(merged)))
setHasChanges(false)
setSuccess('MeshCore routing saved successfully')
setTimeout(() => setSuccess(null), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
const discardChanges = () => {
if (originalConfig) {
setConfig(JSON.parse(JSON.stringify(originalConfig)))
setHasChanges(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading MeshCore routing...</div>
</div>
)
}
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Failed to load notifications config</div>
</div>
)
}
const toggles = config.toggles || {}
return (
<div className="max-w-4xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
Per-family MeshCore delivery. Choose which channels fire at each severity, the
MeshCore channel name, and DM contacts.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchConfig}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
<button
onClick={discardChanges}
disabled={!hasChanges}
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={16} />
Discard
</button>
<button
onClick={saveConfig}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Cross-link note: shared family settings live on the Meshtastic Routing page */}
<div className="flex items-start gap-2 p-3 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-400">
<ExternalLink size={16} className="text-accent mt-0.5 flex-shrink-0" />
<div>
Shared per-family settings (enable, severity threshold, regions, freshness/cooldown, and
Meshtastic / email / webhook delivery) live on the{' '}
<Link to="/notifications" className="text-accent hover:underline">
Meshtastic Routing
</Link>{' '}
page. This page edits only the MeshCore delivery for each family.
</div>
</div>
{/* Status messages */}
{error && (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
)}
{success && (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />
{success}
</div>
)}
{/* Per-family MeshCore delivery */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
MeshCore Delivery
<InfoButton info="For each notification family, choose which MeshCore channels fire at each severity, the MeshCore channel name to broadcast on, and the DM contacts to unicast to. Enabling a family and its severity threshold are set on the Meshtastic Routing page." />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
const t = toggles[key] || ({} as NotificationToggle)
return (
<div key={key} className="border border-[#1e2a3a] p-3 space-y-3">
<div className="flex items-center gap-2 text-sm text-slate-200">
<Icon size={15} /> {label}
</div>
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
<MessageSquare size={13} />
MeshCore
</div>
<SeverityChannelMatrix
channels={MC_CHANNELS}
severityChannels={t.severity_channels || {}}
onChange={(sc) => upd(key, { severity_channels: sc })}
/>
<div className="space-y-1">
<label className="text-xs text-slate-500 uppercase tracking-wide">
MeshCore channel name
</label>
<input
type="text"
value={t.meshcore_channel != null ? t.meshcore_channel : ''}
onChange={(e) =>
upd(key, { meshcore_channel: e.target.value === '' ? null : e.target.value })
}
placeholder="AIDA"
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"
/>
<p className="text-xs text-slate-600">
Channel name on your MeshCore companion (e.g. AIDA). Blank = not broadcast on
MeshCore.
</p>
</div>
<ListInput
label="MeshCore DM contacts"
value={t.meshcore_dm_contacts || []}
onChange={(v) => upd(key, { meshcore_dm_contacts: v })}
placeholder="contact name or pubkey"
helper="MeshCore DM recipients (names or pubkeys)"
info="Contact names or pubkeys on the MeshCore companion. Used when meshcore_dm is enabled for a severity."
/>
</div>
</div>
)
})}
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,140 @@
import { useState, useEffect, useCallback } from 'react'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { ConnectionSection, type ConnectionConfig } from './Config'
import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
export default function MeshtasticConnection() {
const [config, setConfig] = useState<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
const fetchConfig = useCallback(async () => {
setLoading(true)
try {
const data = (await apiFetchConfig('connection')) as ConnectionConfig
setConfig(data)
setOriginalConfig(JSON.parse(JSON.stringify(data)))
setHasChanges(false)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
document.title = 'Meshtastic Connection - MeshAI'
fetchConfig()
}, [fetchConfig])
useEffect(() => {
if (config && originalConfig) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
}
}, [config, originalConfig])
const saveConfig = async () => {
if (!config) return
setSaving(true)
setError(null)
setSuccess(null)
try {
// PUT the whole connection object so MeshCore fields aren't clobbered.
const result = await apiUpdateConfig('connection', config)
setOriginalConfig(JSON.parse(JSON.stringify(config)))
setHasChanges(false)
setSuccess('Meshtastic connection saved successfully')
if (result.restart_required) {
notifyRestartRequired([])
}
setTimeout(() => setSuccess(null), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
const discardChanges = () => {
if (originalConfig) {
setConfig(JSON.parse(JSON.stringify(originalConfig)))
setHasChanges(false)
}
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading Meshtastic connection...</div>
</div>
)
}
if (!config) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Failed to load connection config</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
Connection to your Meshtastic radio (serial or TCP).
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchConfig}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
<button
onClick={discardChanges}
disabled={!hasChanges}
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={16} />
Discard
</button>
<button
onClick={saveConfig}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Status messages */}
{error && (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
)}
{success && (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />
{success}
</div>
)}
{/* Form */}
<div className="bg-bg-card border border-border p-6">
<ConnectionSection data={config} onChange={setConfig} />
</div>
</div>
)
}

View file

@ -0,0 +1,162 @@
import { useState, useEffect, useCallback } from 'react'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import {
MeshMonitorSection,
MeshSourcesSection,
type MeshMonitorConfig,
type MeshSourceConfig,
} from './Config'
import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
export default function MeshtasticSources() {
const [meshmonitor, setMeshmonitor] = useState<MeshMonitorConfig | null>(null)
const [originalMeshmonitor, setOriginalMeshmonitor] = useState<MeshMonitorConfig | null>(null)
const [meshSources, setMeshSources] = useState<MeshSourceConfig[] | null>(null)
const [originalMeshSources, setOriginalMeshSources] = useState<MeshSourceConfig[] | null>(null)
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
const fetchData = useCallback(async () => {
setLoading(true)
try {
const [mm, ms] = await Promise.all([
apiFetchConfig('meshmonitor') as Promise<MeshMonitorConfig>,
apiFetchConfig('mesh_sources') as Promise<MeshSourceConfig[]>,
])
setMeshmonitor(mm)
setOriginalMeshmonitor(JSON.parse(JSON.stringify(mm)))
setMeshSources(ms)
setOriginalMeshSources(JSON.parse(JSON.stringify(ms)))
setHasChanges(false)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
document.title = 'Meshtastic Sources - MeshAI'
fetchData()
}, [fetchData])
useEffect(() => {
if (meshmonitor && originalMeshmonitor && meshSources && originalMeshSources) {
const mmChanged = JSON.stringify(meshmonitor) !== JSON.stringify(originalMeshmonitor)
const msChanged = JSON.stringify(meshSources) !== JSON.stringify(originalMeshSources)
setHasChanges(mmChanged || msChanged)
}
}, [meshmonitor, originalMeshmonitor, meshSources, originalMeshSources])
const saveConfig = async () => {
if (!meshmonitor || !meshSources) return
setSaving(true)
setError(null)
setSuccess(null)
try {
const [mmResult, msResult] = await Promise.all([
apiUpdateConfig('meshmonitor', meshmonitor),
apiUpdateConfig('mesh_sources', meshSources),
])
setOriginalMeshmonitor(JSON.parse(JSON.stringify(meshmonitor)))
setOriginalMeshSources(JSON.parse(JSON.stringify(meshSources)))
setHasChanges(false)
setSuccess('Meshtastic sources saved successfully')
if (mmResult.restart_required || msResult.restart_required) {
notifyRestartRequired([])
}
setTimeout(() => setSuccess(null), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
const discardChanges = () => {
if (originalMeshmonitor) setMeshmonitor(JSON.parse(JSON.stringify(originalMeshmonitor)))
if (originalMeshSources) setMeshSources(JSON.parse(JSON.stringify(originalMeshSources)))
setHasChanges(false)
}
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading Meshtastic sources...</div>
</div>
)
}
if (!meshmonitor || !meshSources) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Failed to load sources config</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
MeshMonitor integration and mesh awareness data sources.
</p>
</div>
<div className="flex items-center gap-2">
<button
onClick={fetchData}
className="p-2 text-slate-400 hover:text-slate-200 hover:bg-bg-hover rounded transition-colors"
title="Refresh"
>
<RefreshCw size={18} />
</button>
<button
onClick={discardChanges}
disabled={!hasChanges}
className="flex items-center gap-2 px-3 py-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<RotateCcw size={16} />
Discard
</button>
<button
onClick={saveConfig}
disabled={saving || !hasChanges}
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
>
<Save size={16} />
{saving ? 'Saving...' : 'Save'}
</button>
</div>
</div>
{/* Status messages */}
{error && (
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
)}
{success && (
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
<Check size={14} className="inline mr-2" />
{success}
</div>
)}
{/* MeshMonitor card */}
<div className="bg-bg-card border border-border p-6">
<MeshMonitorSection data={meshmonitor} onChange={setMeshmonitor} />
</div>
{/* Mesh Sources card */}
<div className="bg-bg-card border border-border p-6">
<MeshSourcesSection data={meshSources} onChange={setMeshSources} />
</div>
</div>
)
}

View file

@ -39,15 +39,18 @@ interface NotificationRuleConfig {
region_scope: string[]
}
interface NotificationToggle {
export interface NotificationToggle {
name: string
enabled: boolean
min_severity: string
regions: string[]
severity_channels: Record<string, string[]>
freshness_seconds?: number
cooldown_seconds?: number
broadcast_channel: number | null
meshcore_channel?: string | null
node_ids: string[]
meshcore_dm_contacts: string[]
smtp_host: string
smtp_port: number
smtp_user: string
@ -59,7 +62,7 @@ interface NotificationToggle {
webhook_headers: Record<string, string>
}
interface NotificationsConfig {
export interface NotificationsConfig {
enabled: boolean
cold_start_grace_seconds?: number
band_conditions_enabled?: boolean
@ -335,7 +338,7 @@ function formatRelativeTime(timestamp: number | null): string {
}
// InfoButton component
function InfoButton({ info }: { info: string }) {
export function InfoButton({ info }: { info: string }) {
const [open, setOpen] = useState(false)
return (
@ -489,7 +492,7 @@ function TimeInput({ label, value, onChange, helper = '', info = '' }: {
)
}
function ListInput({ label, value, onChange, placeholder = 'Add item...', helper = '', info = '' }: {
export function ListInput({ label, value, onChange, placeholder = 'Add item...', helper = '', info = '' }: {
label: string
value: string[]
onChange: (v: string[]) => void
@ -1417,7 +1420,7 @@ function NotificationRuleCard({
}
// Main Notifications Page Component
const TOGGLE_FAMILY_META: { key: string; label: string; Icon: typeof Activity }[] = [
export const TOGGLE_FAMILY_META: { key: string; label: string; Icon: typeof Activity }[] = [
{ key: 'mesh_health', label: 'Mesh Health', Icon: Activity },
{ key: 'weather', label: 'Weather', Icon: Cloud },
{ key: 'fire', label: 'Fire', Icon: Flame },
@ -1541,9 +1544,70 @@ function GroupedCategoryPicker({
</div>
)
}
const TOGGLE_CHANNELS = ['digest', 'mesh_broadcast', 'mesh_dm', 'email', 'webhook']
// Per-mesh channel groups for the split severity matrix
const MT_CHANNELS = ['mesh_broadcast', 'mesh_dm'] as const
export const MC_CHANNELS = ['meshcore_broadcast', 'meshcore_dm'] as const
const OTHER_CHANNELS = ['digest', 'email', 'webhook'] as const
const TOGGLE_SEVERITIES = ['routine', 'priority', 'immediate']
// Reusable severity × channel matrix.
// Toggling a single channel only touches that exact string in the severity row —
// all other channels (including those owned by the other mesh section) are preserved.
// This makes every checkbox merge-safe: Meshtastic toggles never clobber MeshCore
// entries and vice-versa.
export function SeverityChannelMatrix({
channels,
severityChannels,
onChange,
}: {
channels: readonly string[]
severityChannels: Record<string, string[]>
onChange: (updated: Record<string, string[]>) => void
}) {
const colLabel = (c: string) =>
c.replace('meshcore_', 'mc_').replace('mesh_', '').replace(/_/g, ' ')
return (
<table className="text-xs w-full">
<thead>
<tr>
<th className="text-left text-slate-600 font-normal w-20">severity</th>
{channels.map((c) => (
<th key={c} className="text-slate-500 font-normal px-1 whitespace-nowrap">{colLabel(c)}</th>
))}
</tr>
</thead>
<tbody>
{TOGGLE_SEVERITIES.map((sev) => (
<tr key={sev}>
<td className="text-slate-400 pr-2 whitespace-nowrap">{sev}</td>
{channels.map((ch) => {
const on = (severityChannels[sev] || []).includes(ch)
return (
<td key={ch} className="text-center">
<input
type="checkbox"
checked={on}
onChange={(e) => {
// Shallow-copy the dict so we don't mutate state, then
// only modify the specific channel being toggled.
const cur: Record<string, string[]> = { ...severityChannels }
const arr = new Set(cur[sev] || [])
if (e.target.checked) arr.add(ch)
else arr.delete(ch)
cur[sev] = Array.from(arr)
onChange(cur)
}}
/>
</td>
)
})}
</tr>
))}
</tbody>
</table>
)
}
function MasterToggles({ toggles, onChange }: {
toggles: Record<string, NotificationToggle>
onChange: (t: Record<string, NotificationToggle>) => void
@ -1581,53 +1645,111 @@ function MasterToggles({ toggles, onChange }: {
</div>
)}
{isOpen && (
<div className={`mt-3 space-y-3 ${t.enabled ? '' : 'opacity-40 pointer-events-none select-none'}`}>
<SeveritySelector value={t.min_severity || 'priority'} onChange={(v) => upd(key, { min_severity: v })} />
<div className="text-xs text-slate-500">Severity &rarr; channels</div>
<table className="text-xs w-full">
<thead>
<tr><th></th>{TOGGLE_CHANNELS.map((c) => <th key={c} className="text-slate-500 font-normal px-1">{c.replace('_', ' ')}</th>)}</tr>
</thead>
<tbody>
{TOGGLE_SEVERITIES.map((sev) => (
<tr key={sev}>
<td className="text-slate-400 pr-2">{sev}</td>
{TOGGLE_CHANNELS.map((ch) => {
const on = (t.severity_channels?.[sev] || []).includes(ch)
return (
<td key={ch} className="text-center">
<input type="checkbox" checked={on} onChange={(e) => {
const cur: Record<string, string[]> = { ...(t.severity_channels || {}) }
const arr = new Set(cur[sev] || [])
if (e.target.checked) arr.add(ch); else arr.delete(ch)
cur[sev] = Array.from(arr)
upd(key, { severity_channels: cur })
}} />
</td>
)
})}
</tr>
))}
</tbody>
</table>
<ListInput label="Regions (empty = all)" value={t.regions || []} onChange={(v) => upd(key, { regions: v })} placeholder="Add region..." /> <div className="text-xs text-slate-500 pt-1">Channel config</div>
<NumberInput label="Broadcast channel" value={t.broadcast_channel ?? 0} onChange={(v) => upd(key, { broadcast_channel: v })} />
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">MeshCore channel</label>
<input
type="text"
value={t.meshcore_channel != null ? t.meshcore_channel : ''}
onChange={(e) => upd(key, { meshcore_channel: e.target.value === '' ? null : e.target.value })}
placeholder=""
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"
<div className={`mt-3 space-y-4 ${t.enabled ? '' : 'opacity-40 pointer-events-none select-none'}`}>
{/* ── General ──────────────────────────────────────────────── */}
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="text-xs text-slate-500 uppercase tracking-wide">General</div>
<SeveritySelector value={t.min_severity || 'priority'} onChange={(v) => upd(key, { min_severity: v })} />
<ListInput
label="Regions (empty = all)"
value={t.regions || []}
onChange={(v) => upd(key, { regions: v })}
placeholder="Add region..."
/>
<p className="text-xs text-slate-600">MeshCore channel name on your companion (e.g. AIDA); blank = not broadcast on MeshCore.</p>
<div className="grid grid-cols-2 gap-3">
<NumberInput
label="Freshness (sec)"
value={t.freshness_seconds ?? 600}
onChange={(v) => upd(key, { freshness_seconds: v })}
min={0}
helper="Drop events older than this"
info="Events older than this window (seconds) are discarded at dispatcher entrance. 600 = 10 min."
/>
<NumberInput
label="Cooldown (sec)"
value={t.cooldown_seconds ?? 0}
onChange={(v) => upd(key, { cooldown_seconds: v })}
min={0}
helper="0 = no throttle"
info="Per (family, category, region) throttle window. Prevents repeat sends within this window."
/>
</div>
</div>
<ListInput label="DM node IDs" value={t.node_ids || []} onChange={(v) => upd(key, { node_ids: v })} placeholder="!nodeid" />
<ListInput label="Email recipients" value={t.recipients || []} onChange={(v) => upd(key, { recipients: v })} placeholder="ops@example.com" />
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
<TextInput label="Webhook URL" value={t.webhook_url || ''} onChange={(v) => upd(key, { webhook_url: v })} placeholder="https://..." />
{/* ── Meshtastic ───────────────────────────────────────────── */}
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
<Radio size={13} />
Meshtastic
</div>
<SeverityChannelMatrix
channels={MT_CHANNELS}
severityChannels={t.severity_channels || {}}
onChange={(sc) => upd(key, { severity_channels: sc })}
/>
<NumberInput
label="Broadcast channel"
value={t.broadcast_channel ?? 0}
onChange={(v) => upd(key, { broadcast_channel: v })}
min={0}
helper="Meshtastic channel index (0 = LongFast primary)"
info="The Meshtastic channel index used for mesh_broadcast delivery. 0 = primary channel."
/>
<ListInput
label="DM node IDs"
value={t.node_ids || []}
onChange={(v) => upd(key, { node_ids: v })}
placeholder="!hex_id"
helper="Meshtastic DM recipients (hex node IDs)"
info="Hex node IDs for mesh_dm delivery (e.g. !a1b2c3d4). Used when mesh_dm is enabled for a severity."
/>
</div>
{/* MeshCore delivery controls moved to the dedicated
MeshCore -> Routing page (/meshcore/routing). Shared
family settings (enable/severity/regions) stay here. */}
{/* ── Other channels ───────────────────────────────────────── */}
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
<Mail size={13} />
Other channels
</div>
<SeverityChannelMatrix
channels={OTHER_CHANNELS}
severityChannels={t.severity_channels || {}}
onChange={(sc) => upd(key, { severity_channels: sc })}
/>
<ListInput
label="Email recipients"
value={t.recipients || []}
onChange={(v) => upd(key, { recipients: v })}
placeholder="ops@example.com"
/>
<details className="group">
<summary className="flex items-center gap-2 cursor-pointer text-xs text-slate-400 hover:text-slate-200">
<ChevronRight size={12} className="group-open:rotate-90 transition-transform" />
SMTP settings
</summary>
<div className="mt-2 space-y-2 pl-4 border-l border-[#1e2a3a]">
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
<TextInput label="Username" value={t.smtp_user || ''} onChange={(v) => upd(key, { smtp_user: v })} />
<TextInput label="Password" value={t.smtp_password || ''} onChange={(v) => upd(key, { smtp_password: v })} type="password" />
<Toggle label="Use TLS" checked={t.smtp_tls ?? true} onChange={(v) => upd(key, { smtp_tls: v })} />
<TextInput label="From address" value={t.from_address || ''} onChange={(v) => upd(key, { from_address: v })} placeholder="alerts@example.com" />
</div>
</details>
<TextInput
label="Webhook URL"
value={t.webhook_url || ''}
onChange={(v) => upd(key, { webhook_url: v })}
placeholder="https://..."
helper="POST alert as JSON"
/>
</div>
</div>
)}
</div>