mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
24cb6a31df
commit
de1e58aa71
19 changed files with 1870 additions and 219 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
→ MeshCore transport & 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
|
||||
|
|
|
|||
34
work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx
Normal file
34
work/dashboard-frontend/src/pages/MeshCoreCompanion.tsx
Normal 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 & 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 — 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>
|
||||
)
|
||||
}
|
||||
198
work/dashboard-frontend/src/pages/MeshCoreConnection.tsx
Normal file
198
work/dashboard-frontend/src/pages/MeshCoreConnection.tsx
Normal 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"
|
||||
>
|
||||
→ Meshtastic connection
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
34
work/dashboard-frontend/src/pages/MeshCoreContacts.tsx
Normal file
34
work/dashboard-frontend/src/pages/MeshCoreContacts.tsx
Normal 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 — 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>
|
||||
)
|
||||
}
|
||||
275
work/dashboard-frontend/src/pages/MeshCoreRouting.tsx
Normal file
275
work/dashboard-frontend/src/pages/MeshCoreRouting.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
140
work/dashboard-frontend/src/pages/MeshtasticConnection.tsx
Normal file
140
work/dashboard-frontend/src/pages/MeshtasticConnection.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
162
work/dashboard-frontend/src/pages/MeshtasticSources.tsx
Normal file
162
work/dashboard-frontend/src/pages/MeshtasticSources.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 → 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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue