Phase A — 4-section nav; move Scheduled Broadcasts + Danger Zones off Routing (#15)

* feat(dashboard): Phase A — 4-section nav; move Scheduled Broadcasts + Danger Zones off Routing

Regroup nav into GENERAL/MESHTASTIC/MESHCORE/DOCUMENTATION (<=5 pages each,
MT & MC mirror). Consolidate via tabs (Places, Nodes & Health, Contacts &
Companion) reusing existing components. Move Band Conditions, cold-start,
and fire digest to per-mesh Scheduled Broadcasts pages; move Danger Zones
to its own page. Routing keeps its sending rules unchanged.

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

* feat(dashboard): Phase B — clean identical Routing grids; relocate per-family gating to Data Feeds

Meshtastic Routing becomes an always-visible pure-delivery grid matching
MeshCore (no master-toggle expand/collapse). Per-family gating (enable/
severity/freshness/cooldown) moves to a Family Settings section on Data
Feeds. Sending rules + Notification Rules unchanged.

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

* feat(dashboard): Phase C — MeshCore bot-behavior parity (observe channels / ignore contacts / DMs)

Add meshcore context (observe channels by name, ignore contacts, DM policy)
and wire the MeshCore inbound path to honor it, mirroring Meshtastic's
observe/ignore filtering. Symmetric "Bot behavior" sections on both
Connection pages. Meshtastic path unchanged.

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

* feat(dashboard): Phase D — dedupe Environment/Adapter Config into one Data Feeds surface

Curated family panels are the single home for the shared adapter keys;
Adapter Config becomes an Advanced/raw escape hatch (owned keys no longer
double-editable). Surface include_in_llm_context per adapter. Fix the
adapter-config array-vs-object parsing (fire digest values now load).

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

* feat(dashboard): Phase E — Activity Log (per-mesh broadcast feed); remove subscription backend

Replace Alerts with an Activity Log fed by per-mesh broadcast logging
(transport+channel+success on mesh_broadcasts_out, additive migration).
Remove the entire subscription backend (commands, DM dispatch, storage,
API) and its UI.

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-03 14:57:24 -06:00 committed by GitHub
commit 0460462485
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 2502 additions and 2342 deletions

View file

@ -4,7 +4,7 @@ import Dashboard from './pages/Dashboard'
import Mesh from './pages/Mesh' import Mesh from './pages/Mesh'
import Environment from './pages/Environment' import Environment from './pages/Environment'
import Config from './pages/Config' import Config from './pages/Config'
import Alerts from './pages/Alerts' import ActivityLog from './pages/ActivityLog'
import Notifications from './pages/Notifications' import Notifications from './pages/Notifications'
import Reference from './pages/Reference' import Reference from './pages/Reference'
import AdapterConfig from './pages/AdapterConfig' import AdapterConfig from './pages/AdapterConfig'
@ -12,10 +12,15 @@ import GaugeSites from './pages/GaugeSites'
import TownAnchors from './pages/TownAnchors' import TownAnchors from './pages/TownAnchors'
import MeshCoreRouting from './pages/MeshCoreRouting' import MeshCoreRouting from './pages/MeshCoreRouting'
import MeshCoreConnection from './pages/MeshCoreConnection' import MeshCoreConnection from './pages/MeshCoreConnection'
import MeshCoreContacts from './pages/MeshCoreContacts'
import MeshCoreCompanion from './pages/MeshCoreCompanion' import MeshCoreCompanion from './pages/MeshCoreCompanion'
import MeshtasticConnection from './pages/MeshtasticConnection' import MeshtasticConnection from './pages/MeshtasticConnection'
import MeshtasticSources from './pages/MeshtasticSources' import MeshtasticSources from './pages/MeshtasticSources'
import Places from './pages/Places'
import MeshtasticNodes from './pages/MeshtasticNodes'
import MeshCoreContactsCompanion from './pages/MeshCoreContactsCompanion'
import ScheduledBroadcasts from './pages/ScheduledBroadcasts'
import MeshtasticDangerZones from './pages/MeshtasticDangerZones'
import MeshCoreDangerZones from './pages/MeshCoreDangerZones'
import { ToastProvider } from './components/ToastProvider' import { ToastProvider } from './components/ToastProvider'
import { DirtyProvider } from './context/DirtyContext' import { DirtyProvider } from './context/DirtyContext'
@ -25,22 +30,38 @@ function App() {
<ToastProvider> <ToastProvider>
<Layout> <Layout>
<Routes> <Routes>
{/* Core routes */}
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/mesh" element={<Mesh />} />
<Route path="/environment" element={<Environment />} /> <Route path="/environment" element={<Environment />} />
<Route path="/config" element={<Config />} /> <Route path="/config" element={<Config />} />
<Route path="/alerts" element={<Alerts />} /> <Route path="/alerts" element={<ActivityLog />} />
<Route path="/activity" element={<ActivityLog />} />
<Route path="/notifications" element={<Notifications />} /> <Route path="/notifications" element={<Notifications />} />
<Route path="/reference" element={<Reference />} /> <Route path="/reference" element={<Reference />} />
<Route path="/adapter-config" element={<AdapterConfig />} /> <Route path="/adapter-config" element={<AdapterConfig />} />
{/* New aggregated pages */}
<Route path="/places" element={<Places />} />
{/* De-navved routes still work */}
<Route path="/gauge-sites" element={<GaugeSites />} /> <Route path="/gauge-sites" element={<GaugeSites />} />
<Route path="/town-anchors" element={<TownAnchors />} /> <Route path="/town-anchors" element={<TownAnchors />} />
<Route path="/meshcore/routing" element={<MeshCoreRouting />} /> <Route path="/mesh" element={<Mesh />} />
<Route path="/meshcore/connection" element={<MeshCoreConnection />} />
<Route path="/meshcore/contacts" element={<MeshCoreContacts />} /> {/* Meshtastic routes */}
<Route path="/meshcore/companion" element={<MeshCoreCompanion />} />
<Route path="/meshtastic/connection" element={<MeshtasticConnection />} /> <Route path="/meshtastic/connection" element={<MeshtasticConnection />} />
<Route path="/meshtastic/sources" element={<MeshtasticSources />} /> <Route path="/meshtastic/sources" element={<MeshtasticSources />} />
<Route path="/meshtastic/scheduled" element={<ScheduledBroadcasts family="meshtastic" />} />
<Route path="/meshtastic/nodes" element={<MeshtasticNodes />} />
<Route path="/meshtastic/danger-zones" element={<MeshtasticDangerZones />} />
{/* MeshCore routes */}
<Route path="/meshcore/connection" element={<MeshCoreConnection />} />
<Route path="/meshcore/routing" element={<MeshCoreRouting />} />
<Route path="/meshcore/scheduled" element={<ScheduledBroadcasts family="meshcore" />} />
<Route path="/meshcore/contacts" element={<MeshCoreContactsCompanion />} />
<Route path="/meshcore/companion" element={<MeshCoreCompanion />} />
<Route path="/meshcore/danger-zones" element={<MeshCoreDangerZones />} />
</Routes> </Routes>
</Layout> </Layout>
</ToastProvider> </ToastProvider>

View file

@ -0,0 +1,449 @@
// DangerZonesPanel — fully isolated, additive feature.
// Loads/saves the standalone `danger_zones` config section via the generic
// /api/config helpers. Has its OWN state, fetch (on mount), and save. It is
// intentionally decoupled from the notifications config so it can never be
// tangled with the existing save logic.
// Extracted from Notifications.tsx — do NOT re-entangle.
import { useState, useEffect, useCallback } from 'react'
import {
ChevronDown, ChevronRight, AlertTriangle, AlertCircle, Save, Check, Send,
Activity, Cloud, Flame, Snowflake, Mountain,
} from 'lucide-react'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
import { Toggle, NumberInput, TextInput, InfoButton } from '@/pages/Notifications'
import NodePicker from '@/components/NodePicker'
import ChannelPicker from '@/components/ChannelPicker'
const DZ_MONITOR_ROLES = ['CLIENT_BASE', 'ROUTER', 'ROUTER_LATE'] as const
const DZ_DELIVERY_OPTIONS = [
{ value: 'mesh_dm', label: 'Mesh DM (unicast to nodes)' },
{ value: 'mesh_broadcast', label: 'Mesh Broadcast (channel)' },
{ value: 'email', label: 'Email' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'none', label: '(None / log only)' },
]
// Per-family rows. snow is a sub-gate of weather; flood a sub-gate of seismic.
const DZ_FAMILIES: {
key: string
label: string
description: string
Icon: typeof Activity
showAcres?: boolean
tabled?: boolean
}[] = [
{ key: 'fire', label: 'Fire', description: 'Active wildfires (radius from fire perimeter).', Icon: Flame, showAcres: true },
{ key: 'weather', label: 'Weather', description: 'Severe weather warnings near a node.', Icon: Cloud },
{ key: 'snow', label: 'Snow (sub-gate of Weather)', description: 'Snow-category weather events.', Icon: Snowflake, tabled: true },
{ key: 'flood', label: 'Flood (sub-gate of Seismic)', description: 'Stream/flood gauge events.', Icon: Activity },
{ key: 'avalanche', label: 'Avalanche', description: 'Avalanche advisories near a node.', Icon: Mountain },
{ key: 'seismic', label: 'Seismic', description: 'Earthquakes and seismic events near a node.', Icon: Mountain },
]
interface DangerZoneHazardConfig {
enabled: boolean
buffer_mi: number
min_acres: number
}
interface DangerZonesConfig {
enabled: boolean
dry_run: boolean
monitor_roles: string[]
default_buffer_mi: number
cooldown_minutes: number
fire: DangerZoneHazardConfig
weather: DangerZoneHazardConfig
snow: DangerZoneHazardConfig
flood: DangerZoneHazardConfig
avalanche: DangerZoneHazardConfig
seismic: DangerZoneHazardConfig
delivery_type: string
node_ids: string[]
broadcast_channel: number | null
webhook_url: string
webhook_headers: Record<string, string>
}
function dzDefaultHazard(): DangerZoneHazardConfig {
return { enabled: false, buffer_mi: 5.0, min_acres: 0 }
}
// New-object default. NOTE: delivery_type defaults to mesh_dm (do NOT copy the
// page's mesh_broadcast new-rule default).
function dzDefault(): DangerZonesConfig {
return {
enabled: false,
dry_run: true,
monitor_roles: ['ROUTER', 'ROUTER_LATE', 'CLIENT_BASE'],
default_buffer_mi: 5.0,
cooldown_minutes: 360,
fire: dzDefaultHazard(),
weather: dzDefaultHazard(),
snow: dzDefaultHazard(),
flood: dzDefaultHazard(),
avalanche: dzDefaultHazard(),
seismic: dzDefaultHazard(),
delivery_type: 'mesh_dm',
node_ids: [],
broadcast_channel: null,
webhook_url: '',
webhook_headers: {},
}
}
// Minimal local select — SelectInput lives in Config.tsx (which we must not
// touch/entangle), so this panel keeps its own tiny equivalent.
function DZSelect({ label, value, onChange, options, info = '' }: {
label: string
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
info?: string
}) {
return (
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
{label}
{info && <InfoButton info={info} />}
</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
)
}
// Per-family row — mirrors the AlertRuleToggle (toggle + thresholds) pattern.
// AlertRuleToggle itself lives in Config.tsx and is NOT exported, so this is a
// local equivalent purpose-built for the per-family hazard config.
function DZFamilyRow({ meta, cfg, onChange }: {
meta: { key: string; label: string; description: string; Icon: typeof Activity; showAcres?: boolean; tabled?: boolean }
cfg: DangerZoneHazardConfig
onChange: (c: DangerZoneHazardConfig) => void
}) {
const { Icon } = meta
return (
<div className={`border border-[#1e2a3a] p-3 space-y-2 ${meta.tabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between">
<div className="flex items-start gap-2 flex-1">
<Icon size={15} className="text-slate-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<span className="text-sm text-slate-300">{meta.label}</span>
<p className="text-xs text-slate-600">{meta.description}</p>
{meta.tabled && (
<span className="inline-block mt-1 px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
Tabled needs snowfall + elevation pipeline
</span>
)}
</div>
</div>
<button
type="button"
disabled={meta.tabled}
onClick={() => { if (!meta.tabled) onChange({ ...cfg, enabled: !cfg.enabled }) }}
className={`relative w-11 h-6 rounded-full transition-colors flex-shrink-0 ml-3 ${
cfg.enabled ? 'bg-accent' : 'bg-[#1e2a3a]'
} ${meta.tabled ? 'cursor-not-allowed' : ''}`}
>
<span
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
cfg.enabled ? 'translate-x-5' : ''
}`}
/>
</button>
</div>
{cfg.enabled && !meta.tabled && (
<div className={`grid gap-3 pt-2 border-t border-[#1e2a3a] ${meta.showAcres ? 'grid-cols-2' : 'grid-cols-1'}`}>
<NumberInput
label="Buffer (mi)"
value={cfg.buffer_mi ?? 0}
onChange={(v) => onChange({ ...cfg, buffer_mi: v })}
min={0}
step={0.5}
/>
{meta.showAcres && (
<NumberInput
label="Min Acres"
value={cfg.min_acres ?? 0}
onChange={(v) => onChange({ ...cfg, min_acres: v })}
min={0}
step={1}
/>
)}
</div>
)}
</div>
)
}
export default function DangerZonesPanel() {
const [expanded, setExpanded] = useState(false)
const [cfg, setCfg] = useState<DangerZonesConfig | 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 load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const raw = (await apiFetchConfig('danger_zones')) as Partial<DangerZonesConfig>
// Merge over defaults so missing/new fields are always present.
const d = dzDefault()
setCfg({
...d,
...raw,
fire: { ...d.fire, ...(raw.fire || {}) },
weather: { ...d.weather, ...(raw.weather || {}) },
snow: { ...d.snow, ...(raw.snow || {}) },
flood: { ...d.flood, ...(raw.flood || {}) },
avalanche: { ...d.avalanche, ...(raw.avalanche || {}) },
seismic: { ...d.seismic, ...(raw.seismic || {}) },
monitor_roles: raw.monitor_roles ?? d.monitor_roles,
node_ids: raw.node_ids ?? d.node_ids,
webhook_headers: raw.webhook_headers ?? d.webhook_headers,
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load danger zones config')
setCfg(dzDefault())
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const save = async () => {
if (!cfg) return
setSaving(true)
setError(null)
setSuccess(null)
try {
await apiUpdateConfig('danger_zones', cfg)
setSuccess('Danger Zones config saved')
setTimeout(() => setSuccess(null), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
const upd = (patch: Partial<DangerZonesConfig>) => setCfg(c => (c ? { ...c, ...patch } : c))
const toggleRole = (role: string) => {
if (!cfg) return
const cur = cfg.monitor_roles || []
upd({ monitor_roles: cur.includes(role) ? cur.filter(r => r !== role) : [...cur, role] })
}
return (
<div className="bg-bg-card border border-border">
{/* Collapsible header */}
<button
type="button"
onClick={() => setExpanded(e => !e)}
className="w-full flex items-center justify-between p-4 text-left"
>
<div className="flex items-center gap-3">
<AlertTriangle size={18} className="text-amber-400" />
<div>
<div className="text-sm font-medium text-slate-200">Danger Zones</div>
<div className="text-xs text-slate-500">
Alert when monitored infrastructure nodes are in/near a hazard
</div>
</div>
</div>
<div className="flex items-center gap-2">
{cfg && (
<span className={`text-xs px-2 py-0.5 rounded ${
cfg.enabled
? (cfg.dry_run ? 'bg-yellow-500/10 text-yellow-400' : 'bg-green-500/10 text-green-400')
: 'bg-slate-800 text-slate-500'
}`}>
{cfg.enabled ? (cfg.dry_run ? 'Dry-run' : 'Live') : 'Disabled'}
</span>
)}
{expanded ? <ChevronDown size={18} className="text-slate-500" /> : <ChevronRight size={18} className="text-slate-500" />}
</div>
</button>
{expanded && (
<div className="p-6 pt-0 space-y-6">
{/* Safety copy */}
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/20">
<AlertCircle size={16} className="text-amber-400 mt-0.5 flex-shrink-0" />
<div className="text-xs text-amber-200/90 leading-relaxed">
Ships disabled; when enabled, defaults to dry-run / log-only no mesh traffic
until you turn dry-run off. Requires <span className="font-medium">Enable Notifications</span> (above)
and environmental feeds to be on, since hazard events only flow when those are active.
</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>
)}
{loading || !cfg ? (
<div className="text-sm text-slate-500">Loading danger zones config...</div>
) : (
<>
<Toggle
label="Enable Danger Zones"
checked={cfg.enabled}
onChange={(v) => upd({ enabled: v })}
helper="Master switch for the infrastructure danger-zone correlator"
/>
<Toggle
label="Dry-run (log only)"
checked={cfg.dry_run}
onChange={(v) => upd({ dry_run: v })}
helper="When on, matches are logged but nothing is sent to the mesh. Turn off only after verifying dry-run output."
/>
{/* Monitored roles */}
<div className="space-y-2">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Monitored Roles
<InfoButton info="Which Meshtastic node roles to correlate against hazards. Only nodes that have a GPS position are scanned." />
</label>
<div className="flex flex-wrap gap-2">
{DZ_MONITOR_ROLES.map(role => {
const on = (cfg.monitor_roles || []).includes(role)
return (
<button
key={role}
type="button"
onClick={() => toggleRole(role)}
className={`px-3 py-1.5 rounded text-sm transition-colors ${
on ? 'bg-accent text-white' : 'bg-[#1e2a3a] text-slate-400 hover:text-slate-200'
}`}
>
{role}
</button>
)
})}
</div>
</div>
{/* Global numeric settings */}
<div className="grid grid-cols-2 gap-4">
<NumberInput
label="Default Buffer (mi)"
value={cfg.default_buffer_mi}
onChange={(v) => upd({ default_buffer_mi: v })}
min={0}
step={0.5}
helper="Buffer used when a family has none set"
/>
<NumberInput
label="Cooldown (min)"
value={cfg.cooldown_minutes}
onChange={(v) => upd({ cooldown_minutes: v })}
min={0}
helper="Min time between repeat alerts per node+family"
/>
</div>
{/* Per-family hazard config */}
<div className="space-y-3">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Hazard Families
<InfoButton info="Enable each hazard family to monitor, with its own buffer distance and severity threshold. Snow is a sub-gate of Weather; Flood a sub-gate of Seismic." />
</label>
{DZ_FAMILIES.map(meta => (
<DZFamilyRow
key={meta.key}
meta={meta}
cfg={cfg[meta.key as keyof DangerZonesConfig] as DangerZoneHazardConfig}
onChange={(c) => upd({ [meta.key]: c } as Partial<DangerZonesConfig>)}
/>
))}
</div>
{/* Delivery */}
<div className="space-y-4 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-sm font-medium text-slate-300">
<Send size={14} />
DELIVERY
</div>
<DZSelect
label="Delivery Method"
value={cfg.delivery_type || 'mesh_dm'}
onChange={(v) => upd({ delivery_type: v })}
options={DZ_DELIVERY_OPTIONS}
info="Where danger-zone alerts get delivered. Mesh DM unicasts to specific nodes; broadcast sends to a channel. Has no effect while dry-run is on."
/>
{cfg.delivery_type === 'mesh_dm' && (
<NodePicker
label="Recipient Nodes"
value={cfg.node_ids || []}
onChange={(v) => upd({ node_ids: v })}
helper="Nodes that receive direct messages"
valueType="node_id_hex"
/>
)}
{cfg.delivery_type === 'mesh_broadcast' && (
<ChannelPicker
label="Broadcast Channel"
value={cfg.broadcast_channel ?? 0}
onChange={(v) => upd({ broadcast_channel: v })}
helper="Select the mesh radio channel"
mode="single"
/>
)}
{cfg.delivery_type === 'webhook' && (
<TextInput
label="Webhook URL"
value={cfg.webhook_url || ''}
onChange={(v) => upd({ webhook_url: v })}
placeholder="https://discord.com/api/webhooks/..."
helper="POST alert as JSON"
/>
)}
{cfg.delivery_type === 'email' && (
<p className="text-xs text-slate-600">
Email delivery uses the SMTP settings configured for notification rules.
</p>
)}
</div>
{/* Save */}
<div className="flex justify-end">
<button
type="button"
onClick={save}
disabled={saving}
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 Danger Zones'}
</button>
</div>
</>
)}
</div>
)}
</div>
)
}

View file

@ -5,7 +5,6 @@ import {
LayoutDashboard, LayoutDashboard,
Radio, Radio,
Cloud, Cloud,
Bell,
BellRing, BellRing,
BookOpen, BookOpen,
Sliders, Sliders,
@ -17,6 +16,9 @@ import {
Users, Users,
Bot, Bot,
Settings, Settings,
Calendar,
Activity,
AlertTriangle,
type LucideIcon, type LucideIcon,
} from 'lucide-react' } from 'lucide-react'
import { fetchStatus, type SystemStatus } from '@/lib/api' import { fetchStatus, type SystemStatus } from '@/lib/api'
@ -39,28 +41,26 @@ interface NavGroup {
items: NavItem[] items: NavItem[]
} }
// Top-level, ungrouped items (no header). // All nav items are grouped — no ungrouped topNavItems.
const topNavItems: NavItem[] = [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/environment', label: 'Environment', icon: Cloud },
{ path: '/alerts', label: 'Alerts', icon: Bell },
{ 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[] = [ const navGroups: NavGroup[] = [
{
header: 'General',
items: [
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
{ path: '/config', label: 'Settings', icon: Settings },
{ path: '/environment', label: 'Data Feeds', icon: Cloud },
{ path: '/activity', label: 'Activity Log', icon: Activity },
{ path: '/places', label: 'Places', icon: MapPin },
],
},
{ {
header: 'Meshtastic', header: 'Meshtastic',
items: [ items: [
{ path: '/meshtastic/connection', label: 'Connection', icon: Wifi }, { path: '/meshtastic/connection', label: 'Connection', icon: Wifi },
{ path: '/notifications', label: 'Routing', icon: BellRing }, { path: '/notifications', label: 'Routing', icon: BellRing },
{ path: '/mesh', label: 'Mesh', icon: Radio }, { path: '/meshtastic/scheduled', label: 'Scheduled Broadcasts', icon: Calendar },
{ path: '/meshtastic/sources', label: 'Sources', icon: Layers }, { path: '/meshtastic/nodes', label: 'Nodes & Health', icon: Activity },
{ path: '/meshtastic/danger-zones', label: 'Danger Zones', icon: AlertTriangle },
], ],
}, },
{ {
@ -68,18 +68,47 @@ const navGroups: NavGroup[] = [
items: [ items: [
{ path: '/meshcore/connection', label: 'Connection', icon: Network }, { path: '/meshcore/connection', label: 'Connection', icon: Network },
{ path: '/meshcore/routing', label: 'Routing', icon: BellRing }, { path: '/meshcore/routing', label: 'Routing', icon: BellRing },
{ path: '/meshcore/contacts', label: 'Contacts', icon: Users }, { path: '/meshcore/scheduled', label: 'Scheduled Broadcasts', icon: Calendar },
{ path: '/meshcore/companion', label: 'Companion', icon: Bot }, { path: '/meshcore/contacts', label: 'Contacts & Companion', icon: Users },
{ path: '/meshcore/danger-zones', label: 'Danger Zones', icon: AlertTriangle },
],
},
{
header: 'Documentation',
items: [
{ path: '/reference', label: 'Reference', icon: BookOpen },
], ],
}, },
] ]
// Flattened view of every nav item (top + all groups) for title lookup. // Flattened view of every nav item for title lookup.
const allNavItems: NavItem[] = [ // Also include de-navved routes so they still resolve a title.
...topNavItems, const extraTitleItems: NavItem[] = [
...navGroups.flatMap((g) => g.items), { path: '/adapter-config', label: 'Adapter Config', icon: Sliders },
{ path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets },
{ path: '/town-anchors', label: 'Town Anchors', icon: MapPin },
{ path: '/mesh', label: 'Mesh', icon: Radio },
{ path: '/meshtastic/sources', label: 'Sources', icon: Layers },
{ path: '/meshcore/companion', label: 'Companion', icon: Bot },
] ]
const allNavItems: NavItem[] = [
...navGroups.flatMap((g) => g.items),
...extraTitleItems,
]
// Titles for new pages not reachable via explicit nav items.
const pathTitles: Record<string, string> = {
'/places': 'Places',
'/meshtastic/scheduled': 'Scheduled Broadcasts',
'/meshcore/scheduled': 'Scheduled Broadcasts',
'/meshtastic/nodes': 'Nodes & Health',
'/meshtastic/danger-zones': 'Danger Zones',
'/meshcore/danger-zones': 'Danger Zones',
'/meshcore/contacts': 'Contacts & Companion',
'/meshcore/companion': 'Contacts & Companion',
}
function formatUptime(seconds: number): string { function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400) const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600) const hours = Math.floor((seconds % 86400) / 3600)
@ -125,11 +154,13 @@ function renderNavItem(
} }
function getPageTitle(fullPath: string): string { function getPageTitle(fullPath: string): string {
// Exact match first (honors any ?section= query on deep-linked items). // Check explicit path-title map first (covers new pages).
const base = fullPath.split('?')[0]
if (pathTitles[base]) return pathTitles[base]
// Exact match (honors ?section= deep-links).
const exact = allNavItems.find((i) => i.path === fullPath) const exact = allNavItems.find((i) => i.path === fullPath)
if (exact) return exact.label if (exact) return exact.label
// Fallback: match by pathname only, ignoring query strings. // Fallback: match by pathname only, ignoring query strings.
const base = fullPath.split('?')[0]
const byPath = allNavItems.find((i) => i.path.split('?')[0] === base) const byPath = allNavItems.find((i) => i.path.split('?')[0] === base)
return byPath?.label || 'Dashboard' return byPath?.label || 'Dashboard'
} }
@ -203,7 +234,6 @@ export default function Layout({ children }: LayoutProps) {
{/* Navigation */} {/* Navigation */}
<nav className="flex-1 py-4"> <nav className="flex-1 py-4">
{topNavItems.map((item) => renderNavItem(item, location.pathname, location.search, handleNavClick))}
{navGroups.map((group) => ( {navGroups.map((group) => (
<div key={group.header} className="mt-4"> <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]"> <div className="px-5 pt-2 pb-1 text-[10px] font-sans font-semibold uppercase tracking-wider text-[#555]">

View file

@ -111,15 +111,18 @@ export interface AlertHistoryResponse {
total: number total: number
} }
export interface Subscription { export interface ActivityEntry {
id: number id: number
user_id: string sent_at: number | string | null // epoch seconds (int) on new rows
sub_type: string recipient: string | null
schedule_time?: string channel: string | number | null
schedule_day?: string text: string | null
scope_type: string source_event_table: string | null
scope_value?: string source_event_pk: string | number | null
enabled: boolean bytes_sent: number | null
ack_received: number | null
transport: string | null // 'meshtastic' | 'meshcore' | null (legacy)
success: number | null // 1 sent, 0 skip/fail, null legacy
} }
export interface EnvStatus { export interface EnvStatus {
@ -294,8 +297,8 @@ export async function fetchAlertHistory(
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`) return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
} }
export async function fetchSubscriptions(): Promise<Subscription[]> { export async function fetchActivity(limit = 100): Promise<ActivityEntry[]> {
return fetchJson<Subscription[]>('/api/subscriptions') return fetchJson<ActivityEntry[]>(`/api/activity?limit=${limit}`)
} }
export async function fetchEnvStatus(): Promise<EnvStatus> { export async function fetchEnvStatus(): Promise<EnvStatus> {

View file

@ -0,0 +1,180 @@
import { useEffect, useState } from 'react'
import { Activity, Clock, CheckCircle, MinusCircle, Radio } from 'lucide-react'
import { fetchActivity, type ActivityEntry } from '@/lib/api'
// --- helpers ---------------------------------------------------------------
// sent_at is stored as int(time.time()) epoch SECONDS on new rows. Guard null
// and detect seconds-vs-ms so we render correct local time either way.
function formatSentAt(sent_at: number | string | null): string {
if (sent_at === null || sent_at === undefined || sent_at === '') return '—'
let d: Date
if (typeof sent_at === 'number') {
// epoch seconds -> ms (values below ~1e12 are seconds)
d = new Date(sent_at < 1e12 ? sent_at * 1000 : sent_at)
} else {
const asNum = Number(sent_at)
d = Number.isFinite(asNum) && sent_at.trim() !== ''
? new Date(asNum < 1e12 ? asNum * 1000 : asNum)
: new Date(sent_at)
}
return isNaN(d.getTime()) ? '—' : d.toLocaleString()
}
// Mesh badge styling per transport family.
function transportBadge(transport: string | null) {
switch (transport) {
case 'meshtastic':
return { label: 'Meshtastic', cls: 'bg-blue-500/15 text-blue-400 border border-blue-500/30' }
case 'meshcore':
return { label: 'MeshCore', cls: 'bg-green-500/15 text-green-400 border border-green-500/30' }
default:
return { label: 'Legacy', cls: 'bg-slate-500/15 text-slate-400 border border-slate-600/40' }
}
}
// Channel label: Meshtastic index (#n) or MeshCore name; '—' when null.
function channelLabel(channel: string | number | null): string {
if (channel === null || channel === undefined || channel === '') return '—'
if (typeof channel === 'number') return `ch ${channel}`
return channel.startsWith('#') ? channel : `#${channel}`
}
// Type/family tag derived from source_event_table (e.g. 'fires' -> 'fire').
function familyLabel(table: string | null): string {
if (!table) return 'broadcast'
const t = table.replace(/_/g, ' ').trim()
return t.endsWith('s') ? t.slice(0, -1) : t
}
// --- component -------------------------------------------------------------
export default function ActivityLog() {
const [entries, setEntries] = useState<ActivityEntry[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
document.title = 'Activity Log — MeshAI'
}, [])
useEffect(() => {
let alive = true
const load = () => {
fetchActivity()
.then((data) => {
if (!alive) return
setEntries(data)
setError(null)
setLoading(false)
})
.catch((err) => {
if (!alive) return
setError(err.message)
setLoading(false)
})
}
load()
const interval = setInterval(load, 5000)
return () => {
alive = false
clearInterval(interval)
}
}, [])
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading activity</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Error: {error}</div>
</div>
)
}
return (
<div className="space-y-4">
<div className="bg-bg-card border border-border">
<div className="p-4 border-b border-border flex items-center gap-2">
<Activity size={14} className="text-[#f59e0b]" />
<h2 className="text-sm font-medium text-slate-300">
Activity Log
</h2>
<span className="text-xs text-slate-500 ml-auto">
{entries.length} recent broadcast{entries.length === 1 ? '' : 's'} · newest first
</span>
</div>
{entries.length === 0 ? (
<div className="flex items-center gap-2 text-slate-500 p-8">
<Radio size={18} />
<span>No outbound broadcasts recorded yet.</span>
</div>
) : (
<ul className="divide-y divide-border">
{entries.map((e) => {
const badge = transportBadge(e.transport)
return (
<li key={e.id} className="p-4 hover:bg-bg-hover transition-colors">
<div className="flex items-start gap-3">
{/* status indicator */}
<div className="pt-0.5">
{e.success === 1 ? (
<CheckCircle size={16} className="text-green-500" />
) : e.success === 0 ? (
<MinusCircle size={16} className="text-amber-500" />
) : (
<MinusCircle size={16} className="text-slate-600" />
)}
</div>
<div className="flex-1 min-w-0">
{/* meta row: mesh badge, channel, family, status */}
<div className="flex items-center flex-wrap gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${badge.cls}`}>
{badge.label}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-bg-hover text-slate-400 border border-border">
{channelLabel(e.channel)}
</span>
<span className="text-xs px-2 py-0.5 rounded-full bg-[#f59e0b]/10 text-[#f59e0b]">
{familyLabel(e.source_event_table)}
</span>
{e.success === 1 && (
<span className="text-xs text-green-500">Sent</span>
)}
{e.success === 0 && (
<span className="text-xs text-amber-500">Skip</span>
)}
{(e.success === null || e.success === undefined) && (
<span className="text-xs text-slate-500"></span>
)}
</div>
{/* message text */}
<div className="text-sm text-slate-200 break-words whitespace-pre-wrap">
{e.text || <span className="text-slate-500 italic">(no text)</span>}
</div>
{/* timestamp */}
<div className="flex items-center gap-1 mt-1.5 text-xs text-slate-500 font-mono">
<Clock size={12} />
{formatSentAt(e.sent_at)}
</div>
</div>
</div>
</li>
)
})}
</ul>
)}
</div>
</div>
)
}

View file

@ -7,6 +7,10 @@
// Auto-saves on blur (text/number inputs) or change (bool toggle + select). // Auto-saves on blur (text/number inputs) or change (bool toggle + select).
// Cache invalidation is server-side -- every PUT triggers it. The handler // Cache invalidation is server-side -- every PUT triggers it. The handler
// reads via the in-process accessor on its next call. // reads via the in-process accessor on its next call.
//
// Phase D: can be embedded in Advanced tab of Data Feeds. Pass excludeKeys
// to hide curated keys (owned by the curated panels), and hideLlmToggle=true
// to hide the include_in_llm_context checkbox (surfaced in curated panels).
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { import {
@ -14,6 +18,20 @@ import {
Sliders, Sliders,
} from 'lucide-react' } from 'lucide-react'
// Curated keys — owned by Environment.tsx curated panels. Filtered out of the
// Advanced (raw) view to avoid double-editing.
export const CURATED_KEYS: Record<string, string[]> = {
wfigs: ['allowed_incident_types', 'freshness_seconds', 'cooldown_seconds', 'broadcast_on_acres', 'broadcast_on_contained'],
fires: ['digest_enabled', 'digest_schedule', 'digest_timezone'],
tomtom_incidents: ['min_magnitude', 'drop_non_present', 'drop_zero_magnitude'],
itd_511: ['min_severity', 'enabled_categories', 'enabled_sub_types'],
wzdx: ['broadcast', 'min_severity', 'sub_types'],
nws: ['broadcast_severities', 'duplicate_allowed_after_seconds'],
avalanche: ['min_danger_level'],
swpc: ['geomag_kp_floor', 'flare_class_floor', 'proton_pfu_floor'],
satpass: ['enabled', 'observers', 'min_elevation', 'norad_ids', 'max_broadcasts_per_hour', 'dry_run'],
}
interface ConfigRow { interface ConfigRow {
adapter: string adapter: string
key: string key: string
@ -38,7 +56,14 @@ type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
// Brief animation after a successful save. // Brief animation after a successful save.
const SAVED_BADGE_MS = 1500 const SAVED_BADGE_MS = 1500
export default function AdapterConfig() { interface AdapterConfigProps {
/** Keys to hide per adapter (curated keys owned by Data Feeds panels). */
excludeKeys?: Record<string, string[]>
/** When true, hides the include_in_llm_context checkbox (surfaced in curated panels). */
hideLlmToggle?: boolean
}
export default function AdapterConfig({ excludeKeys, hideLlmToggle }: AdapterConfigProps = {}) {
const [config, setConfig] = useState<GroupedConfig>({}) const [config, setConfig] = useState<GroupedConfig>({})
const [meta, setMeta] = useState<MetaMap>({}) const [meta, setMeta] = useState<MetaMap>({})
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
@ -183,7 +208,7 @@ export default function AdapterConfig() {
<Sliders className="w-5 h-5" /> <Sliders className="w-5 h-5" />
<h1 className="text-xl font-semibold">Adapter Config</h1> <h1 className="text-xl font-semibold">Adapter Config</h1>
<span className="text-xs text-[#666] ml-2"> <span className="text-xs text-[#666] ml-2">
{Object.values(config).reduce((n, l) => n + l.length, 0)} settings across {allAdapters.length} adapters {Object.entries(config).reduce((n, [adp, l]) => n + (excludeKeys?.[adp] ? l.filter((r) => !excludeKeys[adp].includes(r.key)).length : l.length), 0)} settings across {allAdapters.length} adapters
</span> </span>
</div> </div>
<p className="text-xs text-[#777] max-w-3xl"> <p className="text-xs text-[#777] max-w-3xl">
@ -198,7 +223,10 @@ export default function AdapterConfig() {
include_in_llm_context: true, include_in_llm_context: true,
description: '', description: '',
} }
const rows = config[adapter] || [] const rawRows = config[adapter] || []
const rows = excludeKeys?.[adapter]
? rawRows.filter((r) => !excludeKeys[adapter].includes(r.key))
: rawRows
const isExpanded = expanded[adapter] ?? false const isExpanded = expanded[adapter] ?? false
const metaId = `meta:${adapter}` const metaId = `meta:${adapter}`
const metaStatus = saveStatus[metaId] || 'idle' const metaStatus = saveStatus[metaId] || 'idle'
@ -220,10 +248,10 @@ export default function AdapterConfig() {
<h2 className="text-base font-semibold text-white">{m.display_name}</h2> <h2 className="text-base font-semibold text-white">{m.display_name}</h2>
<code className="text-xs text-[#666]">{adapter}</code> <code className="text-xs text-[#666]">{adapter}</code>
{rows.length > 0 && ( {rows.length > 0 && (
<span className="text-xs text-[#777] ml-1">({rows.length} settings)</span> <span className="text-xs text-[#777] ml-1">({rows.length} settings{excludeKeys?.[adapter]?.length ? `, ${excludeKeys[adapter].length} curated` : ''})</span>
)} )}
{rows.length === 0 && ( {rows.length === 0 && (
<span className="text-xs text-[#666] ml-1 italic">(meta only)</span> <span className="text-xs text-[#666] ml-1 italic">{rawRows.length > 0 ? '(all curated)' : '(meta only)'}</span>
)} )}
</div> </div>
{m.description && ( {m.description && (
@ -231,17 +259,19 @@ export default function AdapterConfig() {
)} )}
</div> </div>
{/* include_in_llm_context toggle */} {/* include_in_llm_context toggle — hidden when surfaced in curated panels */}
<label className="flex items-center gap-2 text-xs text-[#e0e0e0] select-none"> {!hideLlmToggle && (
<input <label className="flex items-center gap-2 text-xs text-[#e0e0e0] select-none">
type="checkbox" <input
checked={m.include_in_llm_context} type="checkbox"
onChange={(e) => putMeta(adapter, { include_in_llm_context: e.target.checked })} checked={m.include_in_llm_context}
className="w-4 h-4 accent-[#f59e0b]" onChange={(e) => putMeta(adapter, { include_in_llm_context: e.target.checked })}
/> className="w-4 h-4 accent-[#f59e0b]"
LLM context />
<SaveBadge status={metaStatus} error={saveError[metaId]} /> LLM context
</label> <SaveBadge status={metaStatus} error={saveError[metaId]} />
</label>
)}
</div> </div>
{/* Expanded body */} {/* Expanded body */}

View file

@ -1,563 +0,0 @@
import { useEffect, useState, useCallback } from 'react'
import {
Bell,
AlertTriangle,
AlertCircle,
CheckCircle,
Clock,
Filter,
ChevronLeft,
ChevronRight,
Radio,
Zap,
Cloud,
Wifi,
WifiOff,
Battery,
Users,
} from 'lucide-react'
import {
fetchAlerts,
fetchAlertHistory,
fetchSubscriptions,
type Alert,
type AlertHistoryItem,
type Subscription,
} from '@/lib/api'
interface Node {
node_num: number
node_id_hex: string
short_name: string
long_name: string
}
import { useWebSocket } from '@/hooks/useWebSocket'
// Alert type icons mapping
const alertTypeIcons: Record<string, typeof Bell> = {
infra_offline: WifiOff,
infra_recovery: Wifi,
battery_warning: Battery,
battery_critical: Battery,
battery_emergency: Battery,
hf_blackout: Zap,
uhf_ducting: Radio,
weather_warning: Cloud,
weather_watch: Cloud,
new_router: Radio,
packet_flood: AlertTriangle,
sustained_high_util: AlertTriangle,
region_blackout: AlertCircle,
default: Bell,
}
function getAlertIcon(type: string) {
return alertTypeIcons[type] || alertTypeIcons.default
}
function getSeverityStyles(severity: string) {
switch (severity?.toLowerCase()) {
case 'immediate':
return {
bg: 'bg-red-500/10',
border: 'border-red-500',
badge: 'bg-red-500/20 text-red-400',
iconColor: 'text-red-500',
}
case 'priority':
return {
bg: 'bg-amber-500/10',
border: 'border-amber-500',
badge: 'bg-amber-500/20 text-amber-400',
iconColor: 'text-amber-500',
}
case 'routine':
default:
return {
bg: 'bg-[#f59e0b]/10',
border: 'border-[#f59e0b]',
badge: 'bg-[#f59e0b]/20 text-[#f59e0b]',
iconColor: 'text-[#f59e0b]',
}
}
}
function formatTimeAgo(timestamp: string | number): string {
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
const now = new Date()
const diffMs = now.getTime() - date.getTime()
const diffSec = Math.floor(diffMs / 1000)
const diffMin = Math.floor(diffSec / 60)
const diffHour = Math.floor(diffMin / 60)
const diffDay = Math.floor(diffHour / 24)
if (diffSec < 60) return 'Just now'
if (diffMin < 60) return `${diffMin}m ago`
if (diffHour < 24) return `${diffHour}h ago`
return `${diffDay}d ago`
}
function formatDateTime(timestamp: string | number): string {
const date = typeof timestamp === 'number' ? new Date(timestamp * 1000) : new Date(timestamp)
return date.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
})
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m`
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`
return `${Math.floor(seconds / 86400)}d`
}
// Active Alert Card Component
function ActiveAlertCard({
alert,
onAcknowledge,
}: {
alert: Alert
onAcknowledge: (alert: Alert) => void
}) {
const styles = getSeverityStyles(alert.severity)
const Icon = getAlertIcon(alert.type)
return (
<div className={`p-4 ${styles.bg} border-l-4 ${styles.border}`}>
<div className="flex items-start gap-3">
<Icon size={20} className={styles.iconColor} />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
{alert.severity?.toUpperCase()}
</span>
<span className="text-xs text-slate-500">{alert.type}</span>
</div>
<div className="text-sm text-slate-200">{alert.message}</div>
<div className="flex items-center gap-4 mt-2 text-xs text-slate-500">
<span className="flex items-center gap-1">
<Clock size={12} />
{alert.timestamp ? formatTimeAgo(alert.timestamp) : 'Just now'}
</span>
{alert.scope_value && (
<span>{alert.scope_type}: {alert.scope_value}</span>
)}
</div>
</div>
<button
onClick={() => onAcknowledge(alert)}
className="px-3 py-1 text-xs text-slate-400 hover:text-slate-200 border border-border rounded hover:bg-bg-hover transition-colors"
>
Acknowledge
</button>
</div>
</div>
)
}
// Alert History Table Component
function AlertHistoryTable({
history,
typeFilter,
severityFilter,
onTypeFilterChange,
onSeverityFilterChange,
page,
totalPages,
onPageChange,
}: {
history: AlertHistoryItem[]
typeFilter: string
severityFilter: string
onTypeFilterChange: (v: string) => void
onSeverityFilterChange: (v: string) => void
page: number
totalPages: number
onPageChange: (p: number) => void
}) {
const alertTypes = [
'all',
'infra_offline',
'infra_recovery',
'battery_warning',
'battery_critical',
'hf_blackout',
'uhf_ducting',
'weather_warning',
'new_router',
'packet_flood',
]
const severities = ["all", "immediate", "priority", "routine"]
return (
<div className="bg-bg-card border border-border">
{/* Filters */}
<div className="p-4 border-b border-border flex items-center gap-4">
<div className="flex items-center gap-2">
<Filter size={14} className="text-slate-400" />
<span className="text-sm text-slate-400">Filter:</span>
</div>
<select
value={typeFilter}
onChange={(e) => onTypeFilterChange(e.target.value)}
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-[#f59e0b]"
>
{alertTypes.map((t) => (
<option key={t} value={t}>
{t === 'all' ? 'All Types' : t.replace(/_/g, ' ')}
</option>
))}
</select>
<select
value={severityFilter}
onChange={(e) => onSeverityFilterChange(e.target.value)}
className="bg-bg border border-border rounded px-3 py-1.5 text-sm text-slate-200 focus:outline-none focus:border-[#f59e0b]"
>
{severities.map((s) => (
<option key={s} value={s}>
{s === 'all' ? 'All Severities' : s.charAt(0).toUpperCase() + s.slice(1)}
</option>
))}
</select>
</div>
{/* Table */}
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border">
<th className="text-left text-xs font-medium text-slate-400 p-4">Time</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Type</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Severity</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Message</th>
<th className="text-left text-xs font-medium text-slate-400 p-4">Duration</th>
</tr>
</thead>
<tbody>
{history.length > 0 ? (
history.map((item, i) => {
const styles = getSeverityStyles(item.severity)
return (
<tr key={item.id || i} className="border-b border-border hover:bg-bg-hover">
<td className="p-4 text-sm text-slate-400 font-mono whitespace-nowrap">
{formatDateTime(item.timestamp)}
</td>
<td className="p-4 text-sm text-slate-300">
{item.type.replace(/_/g, ' ')}
</td>
<td className="p-4">
<span className={`text-xs px-2 py-0.5 rounded-full ${styles.badge}`}>
{item.severity}
</span>
</td>
<td className="p-4 text-sm text-slate-200 max-w-md truncate">
{item.message}
</td>
<td className="p-4 text-sm text-slate-400 font-mono">
{item.duration ? formatDuration(item.duration) : '-'}
</td>
</tr>
)
})
) : (
<tr>
<td colSpan={5} className="p-8 text-center text-slate-500">
No alert history available
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="p-4 border-t border-border flex items-center justify-between">
<span className="text-sm text-slate-400">
Page {page} of {totalPages}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => onPageChange(page - 1)}
disabled={page <= 1}
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronLeft size={16} />
</button>
<button
onClick={() => onPageChange(page + 1)}
disabled={page >= totalPages}
className="p-2 text-slate-400 hover:text-slate-200 disabled:opacity-50 disabled:cursor-not-allowed"
>
<ChevronRight size={16} />
</button>
</div>
</div>
)}
</div>
)
}
// Subscription Card Component
function SubscriptionCard({ subscription, nodes }: { subscription: Subscription; nodes: Node[] }) {
const resolveNodeName = (userId: string): string => {
const node = nodes.find(n =>
n.node_id_hex === userId ||
String(n.node_num) === userId ||
n.short_name === userId
)
if (node) {
return node.long_name && node.long_name !== node.short_name
? `${node.short_name} (${node.long_name})`
: node.short_name
}
return userId
}
const formatSchedule = () => {
if (subscription.sub_type === 'alerts') {
return 'Real-time'
}
const time = subscription.schedule_time || '0000'
const hours = parseInt(time.slice(0, 2))
const minutes = time.slice(2)
const period = hours >= 12 ? 'PM' : 'AM'
const displayHour = hours % 12 || 12
let schedule = `${displayHour}:${minutes} ${period}`
if (subscription.sub_type === 'weekly' && subscription.schedule_day) {
schedule += ` ${subscription.schedule_day.charAt(0).toUpperCase()}${subscription.schedule_day.slice(1)}`
}
return schedule
}
const getTypeIcon = () => {
switch (subscription.sub_type) {
case 'alerts':
return Bell
case 'daily':
return Clock
case 'weekly':
return Clock
default:
return Bell
}
}
const Icon = getTypeIcon()
return (
<div className="p-4 bg-bg-hover border border-border">
<div className="flex items-center gap-3">
<div className="w-10 h-10 bg-[#f59e0b]/10 flex items-center justify-center">
<Icon size={18} className="text-[#f59e0b]" />
</div>
<div className="flex-1">
<div className="text-sm text-slate-200 font-medium">
{subscription.sub_type.charAt(0).toUpperCase() + subscription.sub_type.slice(1)}
{subscription.scope_type !== 'mesh' && subscription.scope_value && (
<span className="text-slate-400 font-normal ml-2">
({subscription.scope_type}: {subscription.scope_value})
</span>
)}
</div>
<div className="text-xs text-slate-500 mt-0.5">
{formatSchedule()} {resolveNodeName(subscription.user_id)}
</div>
</div>
<div className={`w-2 h-2 rounded-full ${subscription.enabled ? 'bg-green-500' : 'bg-slate-500'}`} />
</div>
</div>
)
}
export default function Alerts() {
const [activeAlerts, setActiveAlerts] = useState<Alert[]>([])
const [history, setHistory] = useState<AlertHistoryItem[]>([])
const [subscriptions, setSubscriptions] = useState<Subscription[]>([])
const [nodes, setNodes] = useState<Node[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
// Filters and pagination
const [typeFilter, setTypeFilter] = useState('all')
const [severityFilter, setSeverityFilter] = useState('all')
const [page, setPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const pageSize = 20
// Acknowledged alerts (local state only)
const [acknowledged, setAcknowledged] = useState<Set<string>>(new Set())
const { lastAlert } = useWebSocket()
// Set page title
useEffect(() => {
document.title = 'Alerts — MeshAI'
}, [])
// Load data
useEffect(() => {
Promise.all([
fetchAlerts().catch(() => []),
fetchAlertHistory(pageSize, 0).catch(() => ({ items: [], total: 0 })),
fetchSubscriptions().catch(() => []),
fetch('/api/nodes').then(r => r.json()).catch(() => []),
])
.then(([alerts, historyData, subs, nodeData]) => {
setActiveAlerts(alerts)
if (Array.isArray(historyData)) {
setHistory(historyData)
setTotalPages(1)
} else {
setHistory(historyData.items || [])
setTotalPages(Math.ceil((historyData.total || 0) / pageSize))
}
setSubscriptions(subs)
setNodes(nodeData)
setLoading(false)
})
.catch((err) => {
setError(err.message)
setLoading(false)
})
}, [])
// Handle new alerts from WebSocket
useEffect(() => {
if (lastAlert) {
setActiveAlerts((prev) => {
// Avoid duplicates
const exists = prev.some(
(a) => a.type === lastAlert.type && a.message === lastAlert.message
)
if (exists) return prev
return [lastAlert, ...prev]
})
}
}, [lastAlert])
// Reload history when filters or page change
useEffect(() => {
const offset = (page - 1) * pageSize
fetchAlertHistory(pageSize, offset, typeFilter, severityFilter)
.then((data) => {
if (Array.isArray(data)) {
setHistory(data)
setTotalPages(1)
} else {
setHistory(data.items || [])
setTotalPages(Math.ceil((data.total || 0) / pageSize))
}
})
.catch(() => {
// Keep current data on error
})
}, [page, typeFilter, severityFilter])
const handleAcknowledge = useCallback((alert: Alert) => {
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
setAcknowledged((prev) => new Set([...prev, key]))
}, [])
// Filter out acknowledged alerts
const visibleAlerts = activeAlerts.filter((alert) => {
const key = `${alert.type}-${alert.message}-${alert.timestamp}`
return !acknowledged.has(key)
})
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading alerts...</div>
</div>
)
}
if (error) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Error: {error}</div>
</div>
)
}
return (
<div className="space-y-6">
{/* Active Alerts */}
<div className="bg-bg-card border border-border p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<AlertTriangle size={14} />
Active Alerts ({visibleAlerts.length})
</h2>
{visibleAlerts.length > 0 ? (
<div className="space-y-3">
{visibleAlerts.map((alert, i) => (
<ActiveAlertCard
key={`${alert.type}-${alert.timestamp}-${i}`}
alert={alert}
onAcknowledge={handleAcknowledge}
/>
))}
</div>
) : (
<div className="flex items-center gap-2 text-slate-500 py-8">
<CheckCircle size={20} className="text-green-500" />
<span>No active alerts all systems nominal</span>
</div>
)}
</div>
{/* Alert History */}
<div>
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Clock size={14} />
Alert History
</h2>
<AlertHistoryTable
history={history}
typeFilter={typeFilter}
severityFilter={severityFilter}
onTypeFilterChange={(v) => {
setTypeFilter(v)
setPage(1)
}}
onSeverityFilterChange={(v) => {
setSeverityFilter(v)
setPage(1)
}}
page={page}
totalPages={totalPages}
onPageChange={setPage}
/>
</div>
{/* Subscriptions */}
<div className="bg-bg-card border border-border p-6">
<h2 className="text-sm font-medium text-slate-400 mb-4 flex items-center gap-2">
<Users size={14} />
Mesh Subscriptions ({subscriptions.length})
</h2>
{subscriptions.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{subscriptions.map((sub) => (
<SubscriptionCard key={sub.id} subscription={sub} nodes={nodes} />
))}
</div>
) : (
<div className="text-slate-500 py-4">
<p>No active subscriptions.</p>
<p className="text-xs mt-2">
Manage subscriptions via <code className="text-[#f59e0b]">!subscribe</code> on mesh. Broadcasts arrive with one of three prefixes <strong>New:</strong> (first sight), <strong>Update:</strong> (material change), or <strong>Active:</strong> (clock-driven reminder while the event is still live). See <a href="/reference#broadcast-types" className="text-[#f59e0b] hover:underline">Broadcast Types</a> and <a href="/reference#reminders" className="text-[#f59e0b] hover:underline">Reminder System</a> in Reference.
</p>
</div>
)}
</div>
</div>
)
}

View file

@ -163,7 +163,7 @@ interface AlertRulesConfig {
region_score_threshold: number region_score_threshold: number
} }
interface MeshIntelligenceConfig { export interface MeshIntelligenceConfig {
enabled: boolean enabled: boolean
regions: RegionAnchor[] regions: RegionAnchor[]
locality_radius_miles: number locality_radius_miles: number
@ -267,9 +267,6 @@ const AVAILABLE_COMMANDS = [
{ name: 'ping', description: 'Test bot responsiveness' }, { name: 'ping', description: 'Test bot responsiveness' },
{ name: 'clear', description: 'Clear your conversation history' }, { name: 'clear', description: 'Clear your conversation history' },
{ name: 'reset', description: 'Reset conversation context' }, { name: 'reset', description: 'Reset conversation context' },
{ name: 'sub', description: 'Subscribe to scheduled reports or alerts' },
{ name: 'unsub', description: 'Remove a subscription' },
{ name: 'mysubs', description: 'List your active subscriptions' },
{ name: 'alerts', description: 'Active NWS weather alerts for mesh area' }, { name: 'alerts', description: 'Active NWS weather alerts for mesh area' },
{ name: 'solar', description: 'Space weather and HF propagation conditions' }, { name: 'solar', description: 'Space weather and HF propagation conditions' },
{ name: 'hf', description: 'HF radio propagation (alias for !solar)' }, { name: 'hf', description: 'HF radio propagation (alias for !solar)' },
@ -916,21 +913,9 @@ function ContextSection({ data, onChange }: { data: ContextConfig; onChange: (d:
/> />
{data.enabled && ( {data.enabled && (
<> <>
<ChannelPicker {/* Observe Channels + Ignore Nodes moved to the Meshtastic Connection
label="Observe Channels" page ("Bot behavior" section). max_age / max_context_items remain
value={data.observe_channels} here as general context knobs. */}
onChange={(v) => onChange({ ...data, observe_channels: v })}
helper="Channels to monitor (empty = all)"
info="Meshtastic channels to listen on. Leave empty to monitor all channels."
mode="multi"
/>
<NodePicker
label="Ignore Nodes"
value={data.ignore_nodes}
onChange={(v) => onChange({ ...data, ignore_nodes: v })}
helper="Nodes to exclude from context"
info="Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes."
/>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<NumberInput <NumberInput
label="Max Age (sec)" label="Max Age (sec)"
@ -1421,7 +1406,7 @@ export function MeshSourcesSection({ data, onChange }: { data: MeshSourceConfig[
) )
} }
function MeshIntelligenceSection({ data, onChange }: { data: MeshIntelligenceConfig; onChange: (d: MeshIntelligenceConfig) => void }) { export function MeshIntelligenceSection({ data, onChange }: { data: MeshIntelligenceConfig; onChange: (d: MeshIntelligenceConfig) => void }) {
const [expandedRegion, setExpandedRegion] = useState<number | null>(null) const [expandedRegion, setExpandedRegion] = useState<number | null>(null)
return ( return (

View file

@ -1,7 +1,8 @@
import { useEffect, useState, type ReactNode } from 'react' import { useEffect, useState, type ReactNode } from 'react'
import { import {
Cloud, Flame, Radio, Car, Mountain, Satellite, Activity, Server, Cloud, Flame, Radio, Car, Mountain, Satellite, Activity, Server,
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info, Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info, Bell,
Sliders,
} from 'lucide-react' } from 'lucide-react'
import { import {
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput, Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
@ -11,6 +12,8 @@ import {
fetchEnvStatus, fetchEnvActive, fetchEnvStatus, fetchEnvActive,
type EnvStatus, type EnvEvent, type EnvStatus, type EnvEvent,
} from '@/lib/api' } from '@/lib/api'
import { TOGGLE_FAMILY_META, type NotificationToggle, type NotificationsConfig } from './Notifications'
import AdapterConfig, { CURATED_KEYS } from './AdapterConfig'
type FeedSource = 'native' | 'central' type FeedSource = 'native' | 'central'
@ -170,12 +173,16 @@ function FeedSourceToggle({ value, onChange, disabled, centralDisabled }: {
} }
// ---------------------------------------------------------------- adapter panel // ---------------------------------------------------------------- adapter panel
function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedSource, hasCentral, nativeOnly, hasKey, health, events, children }: { function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedSource, hasCentral, nativeOnly, hasKey, health, events, children, llmContext, onLlmContext }: {
title: string; subtitle?: string title: string; subtitle?: string
enabled: boolean; onEnabled: (v: boolean) => void enabled: boolean; onEnabled: (v: boolean) => void
feedSource: FeedSource; onFeedSource: (v: FeedSource) => void feedSource: FeedSource; onFeedSource: (v: FeedSource) => void
hasCentral: boolean; nativeOnly: boolean; hasKey: boolean hasCentral: boolean; nativeOnly: boolean; hasKey: boolean
health?: FeedHealth; events?: EnvEvent[]; children?: ReactNode health?: FeedHealth; events?: EnvEvent[]; children?: ReactNode
/** Current value of include_in_llm_context; undefined = not applicable */
llmContext?: boolean
/** Called when user toggles include_in_llm_context */
onLlmContext?: (v: boolean) => void
}) { }) {
const centralDisabled = nativeOnly || !hasCentral const centralDisabled = nativeOnly || !hasCentral
return ( return (
@ -186,6 +193,17 @@ function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedS
{subtitle && <p className="text-xs text-[#666]">{subtitle}</p>} {subtitle && <p className="text-xs text-[#666]">{subtitle}</p>}
</div> </div>
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
{onLlmContext !== undefined && (
<label className="flex items-center gap-1.5 cursor-pointer select-none" title="Include this adapter's data in LLM (bot) context">
<input
type="checkbox"
checked={llmContext ?? true}
onChange={(e) => onLlmContext(e.target.checked)}
className="w-3.5 h-3.5 accent-[#f59e0b]"
/>
<span className="text-[10px] uppercase tracking-wide text-[#666]">LLM</span>
</label>
)}
<div className="flex items-center gap-1"> <div className="flex items-center gap-1">
<span className="text-[10px] uppercase tracking-wide text-[#666]">source</span> <span className="text-[10px] uppercase tracking-wide text-[#666]">source</span>
<FeedSourceToggle value={feedSource} onChange={onFeedSource} disabled={!enabled} centralDisabled={centralDisabled} /> <FeedSourceToggle value={feedSource} onChange={onFeedSource} disabled={!enabled} centralDisabled={centralDisabled} />
@ -248,6 +266,7 @@ const FAMILIES: { key: string; label: string; icon: typeof Cloud; adapters: Adap
{ key: 'geohazards', label: 'Geohazards', icon: Mountain, adapters: ['usgs_quake', 'usgs', 'avalanche'] }, { key: 'geohazards', label: 'Geohazards', icon: Mountain, adapters: ['usgs_quake', 'usgs', 'avalanche'] },
{ key: 'tracking', label: 'Tracking', icon: Satellite, adapters: ['satpass'] }, { key: 'tracking', label: 'Tracking', icon: Satellite, adapters: ['satpass'] },
{ key: 'mesh', label: 'Mesh Health', icon: Activity, adapters: [] }, { key: 'mesh', label: 'Mesh Health', icon: Activity, adapters: [] },
{ key: 'family_settings', label: 'Family Settings', icon: Bell, adapters: [] },
] ]
// ---------------------------------------------------------------- main page // ---------------------------------------------------------------- main page
@ -264,6 +283,12 @@ export default function Environment() {
const [family, setFamily] = useState('weather') const [family, setFamily] = useState('weather')
const [adapter, setAdapter] = useState<AdapterKey | null>('nws') const [adapter, setAdapter] = useState<AdapterKey | null>('nws')
// Top-level tab: 'curated' = existing panels, 'advanced' = raw key/value editor
const [pageTab, setPageTab] = useState<'curated' | 'advanced'>('curated')
// include_in_llm_context per backend adapter name — fetched from /api/adapter-meta
const [llmMeta, setLlmMeta] = useState<Record<string, boolean>>({})
// WFIGS/fires adapter config state // WFIGS/fires adapter config state
const [wfigsConfig, setWfigsConfig] = useState<WfigsConfig>({ const [wfigsConfig, setWfigsConfig] = useState<WfigsConfig>({
allowed_incident_types: ['WF'], allowed_incident_types: ['WF'],
@ -322,6 +347,13 @@ export default function Environment() {
}) })
const [satpassOriginal, setSatpassOriginal] = useState<string>("") const [satpassOriginal, setSatpassOriginal] = useState<string>("")
// ── Notification family gating state ──────────────────────────────────────
const [notifConfig, setNotifConfig] = useState<NotificationsConfig | null>(null)
const [notifOriginal, setNotifOriginal] = useState<string>('')
const [notifSaving, setNotifSaving] = useState(false)
const [notifError, setNotifError] = useState<string | null>(null)
const [notifSuccess, setNotifSuccess] = useState<string | null>(null)
useEffect(() => { useEffect(() => {
document.title = 'Environment — MeshAI' document.title = 'Environment — MeshAI'
@ -332,125 +364,151 @@ export default function Environment() {
setEnv(data) setEnv(data)
setOriginal(JSON.stringify(data)) setOriginal(JSON.stringify(data))
// Load adapter-config for wfigs // Helper: normalize GET /api/adapter-config/{adapter} response.
// The API returns an ARRAY [{key, value}, ...]. Convert to {key: {value}} map
// so callers can read data.field?.value just like an object response.
const toMap = (arr: unknown): Record<string, { value: unknown }> => {
const result: Record<string, { value: unknown }> = {}
if (Array.isArray(arr)) {
for (const r of arr as Array<{ key: string; value: unknown }>) {
result[r.key] = { value: r.value }
}
}
return result
}
// Load adapter-config for wfigs (array → object fix: line ~350)
try { try {
const wfigsRes = await fetch("/api/adapter-config/wfigs") const wfigsRes = await fetch("/api/adapter-config/wfigs")
if (wfigsRes.ok) { if (wfigsRes.ok) {
const wfigsData = await wfigsRes.json() const wfigsData = toMap(await wfigsRes.json())
const cfg: WfigsConfig = { const cfg: WfigsConfig = {
allowed_incident_types: wfigsData.allowed_incident_types?.value ?? ['WF'], allowed_incident_types: (wfigsData.allowed_incident_types?.value as string[]) ?? ['WF'],
freshness_seconds: wfigsData.freshness_seconds?.value ?? 0, freshness_seconds: (wfigsData.freshness_seconds?.value as number) ?? 0,
cooldown_seconds: wfigsData.cooldown_seconds?.value ?? 28800, cooldown_seconds: (wfigsData.cooldown_seconds?.value as number) ?? 28800,
broadcast_on_acres: wfigsData.broadcast_on_acres?.value ?? true, broadcast_on_acres: (wfigsData.broadcast_on_acres?.value as boolean) ?? true,
broadcast_on_contained: wfigsData.broadcast_on_contained?.value ?? true, broadcast_on_contained: (wfigsData.broadcast_on_contained?.value as boolean) ?? true,
} }
setWfigsConfig(cfg) setWfigsConfig(cfg)
setWfigsOriginal(JSON.stringify(cfg)) setWfigsOriginal(JSON.stringify(cfg))
} }
} catch { /* adapter-config optional */ } } catch { /* adapter-config optional */ }
// Load adapter-config for fires (digest settings) // Load adapter-config for fires/digest (array → object fix: line ~367)
try { try {
const firesRes = await fetch("/api/adapter-config/fires") const firesRes = await fetch("/api/adapter-config/fires")
if (firesRes.ok) { if (firesRes.ok) {
const firesData = await firesRes.json() const firesData = toMap(await firesRes.json())
const cfg: FiresConfig = { const cfg: FiresConfig = {
digest_enabled: firesData.digest_enabled?.value ?? true, digest_enabled: (firesData.digest_enabled?.value as boolean) ?? true,
digest_schedule: firesData.digest_schedule?.value ?? ["06:00", "18:00"], digest_schedule: (firesData.digest_schedule?.value as string[]) ?? ["06:00", "18:00"],
digest_timezone: firesData.digest_timezone?.value ?? "America/Boise", digest_timezone: (firesData.digest_timezone?.value as string) ?? "America/Boise",
} }
setFiresConfig(cfg) setFiresConfig(cfg)
setFiresOriginal(JSON.stringify(cfg)) setFiresOriginal(JSON.stringify(cfg))
} }
} catch { /* adapter-config optional */ } } catch { /* adapter-config optional */ }
// Load adapter-config for tomtom_incidents // Load adapter-config for tomtom_incidents (array → object fix: line ~382)
try { try {
const ttRes = await fetch("/api/adapter-config/tomtom_incidents") const ttRes = await fetch("/api/adapter-config/tomtom_incidents")
if (ttRes.ok) { if (ttRes.ok) {
const ttData = await ttRes.json() const ttData = toMap(await ttRes.json())
const cfg: TomtomConfig = { const cfg: TomtomConfig = {
min_magnitude: ttData.min_magnitude?.value ?? 4, min_magnitude: (ttData.min_magnitude?.value as number) ?? 4,
drop_non_present: ttData.drop_non_present?.value ?? true, drop_non_present: (ttData.drop_non_present?.value as boolean) ?? true,
drop_zero_magnitude: ttData.drop_zero_magnitude?.value ?? true, drop_zero_magnitude: (ttData.drop_zero_magnitude?.value as boolean) ?? true,
} }
setTomtomConfig(cfg) setTomtomConfig(cfg)
setTomtomOriginal(JSON.stringify(cfg)) setTomtomOriginal(JSON.stringify(cfg))
} }
} catch { /* adapter-config optional */ } } catch { /* adapter-config optional */ }
// Load adapter-config for itd_511 // Load adapter-config for itd_511 (array → object fix: line ~398)
try { try {
const r511Res = await fetch("/api/adapter-config/itd_511") const r511Res = await fetch("/api/adapter-config/itd_511")
if (r511Res.ok) { if (r511Res.ok) {
const r511Data = await r511Res.json() const r511Data = toMap(await r511Res.json())
const cfg: Roads511Config = { const cfg: Roads511Config = {
min_severity: r511Data.min_severity?.value ?? "None", min_severity: (r511Data.min_severity?.value as string) ?? "None",
enabled_categories: r511Data.enabled_categories?.value ?? ["incident", "closure"], enabled_categories: (r511Data.enabled_categories?.value as string[]) ?? ["incident", "closure"],
enabled_sub_types: r511Data.enabled_sub_types?.value ?? ["accident", "road_closed", "closure", "lane_closed", "vehicle_on_fire", "flooding", "debris"], enabled_sub_types: (r511Data.enabled_sub_types?.value as string[]) ?? ["accident", "road_closed", "closure", "lane_closed", "vehicle_on_fire", "flooding", "debris"],
} }
setRoads511Config(cfg) setRoads511Config(cfg)
setRoads511Original(JSON.stringify(cfg)) setRoads511Original(JSON.stringify(cfg))
} }
} catch { /* adapter-config optional */ } } catch { /* adapter-config optional */ }
// Load adapter-config for wzdx // Load adapter-config for wzdx (array → object fix: line ~413)
try { try {
const wzdxRes = await fetch("/api/adapter-config/wzdx") const wzdxRes = await fetch("/api/adapter-config/wzdx")
if (wzdxRes.ok) { if (wzdxRes.ok) {
const wzdxData = await wzdxRes.json() const wzdxData = toMap(await wzdxRes.json())
const cfg: WzdxConfig = { const cfg: WzdxConfig = {
broadcast: wzdxData.broadcast?.value ?? false, broadcast: (wzdxData.broadcast?.value as boolean) ?? false,
min_severity: wzdxData.min_severity?.value ?? "Minor", min_severity: (wzdxData.min_severity?.value as string) ?? "Minor",
sub_types: wzdxData.sub_types?.value ?? ["road_works", "lane_closed", "road_closed"], sub_types: (wzdxData.sub_types?.value as string[]) ?? ["road_works", "lane_closed", "road_closed"],
} }
setWzdxConfig(cfg) setWzdxConfig(cfg)
setWzdxOriginal(JSON.stringify(cfg)) setWzdxOriginal(JSON.stringify(cfg))
} }
} catch { /* adapter-config optional */ } } catch { /* adapter-config optional */ }
// Load adapter-config for nws // Load adapter-config for nws (array → object fix: line ~427)
try { try {
const nwsRes = await fetch("/api/adapter-config/nws") const nwsRes = await fetch("/api/adapter-config/nws")
if (nwsRes.ok) { if (nwsRes.ok) {
const nwsData = await nwsRes.json() const nwsData = toMap(await nwsRes.json())
const cfg: NwsConfig = { const cfg: NwsConfig = {
broadcast_severities: nwsData.broadcast_severities?.value ?? ["Extreme", "Severe"], broadcast_severities: (nwsData.broadcast_severities?.value as string[]) ?? ["Extreme", "Severe"],
duplicate_allowed_after_seconds: nwsData.duplicate_allowed_after_seconds?.value ?? 3600, duplicate_allowed_after_seconds: (nwsData.duplicate_allowed_after_seconds?.value as number) ?? 3600,
} }
setNwsConfig(cfg) setNwsConfig(cfg)
setNwsOriginal(JSON.stringify(cfg)) setNwsOriginal(JSON.stringify(cfg))
} }
} catch { /* adapter-config optional */ } } catch { /* adapter-config optional */ }
// Load adapter-config for avalanche // Load adapter-config for avalanche (array → object fix: line ~441)
try { try {
const avyRes = await fetch("/api/adapter-config/avalanche") const avyRes = await fetch("/api/adapter-config/avalanche")
if (avyRes.ok) { if (avyRes.ok) {
const avyData = await avyRes.json() const avyData = toMap(await avyRes.json())
const cfg: AvalancheConfig = { const cfg: AvalancheConfig = {
min_danger_level: avyData.min_danger_level?.value ?? 3, min_danger_level: (avyData.min_danger_level?.value as number) ?? 3,
} }
setAvalancheConfig(cfg) setAvalancheConfig(cfg)
setAvalancheOriginal(JSON.stringify(cfg)) setAvalancheOriginal(JSON.stringify(cfg))
} }
} catch { /* adapter-config optional */ } } catch { /* adapter-config optional */ }
// Load adapter-config for swpc // Load adapter-config for swpc (array → object fix: line ~453)
try { try {
const swpcRes = await fetch("/api/adapter-config/swpc") const swpcRes = await fetch("/api/adapter-config/swpc")
if (swpcRes.ok) { if (swpcRes.ok) {
const swpcData = await swpcRes.json() const swpcData = toMap(await swpcRes.json())
const cfg: SwpcConfig = { const cfg: SwpcConfig = {
geomag_kp_floor: swpcData.geomag_kp_floor?.value ?? 7.0, geomag_kp_floor: (swpcData.geomag_kp_floor?.value as number) ?? 7.0,
flare_class_floor: swpcData.flare_class_floor?.value ?? "X1", flare_class_floor: (swpcData.flare_class_floor?.value as string) ?? "X1",
proton_pfu_floor: swpcData.proton_pfu_floor?.value ?? 10.0, proton_pfu_floor: (swpcData.proton_pfu_floor?.value as number) ?? 10.0,
} }
setSwpcConfig(cfg) setSwpcConfig(cfg)
setSwpcOriginal(JSON.stringify(cfg)) setSwpcOriginal(JSON.stringify(cfg))
} }
} catch { /* adapter-config optional */ } } catch { /* adapter-config optional */ }
// Load adapter-meta for include_in_llm_context per adapter
try {
const metaRes = await fetch('/api/adapter-meta')
if (metaRes.ok) {
const metaData = await metaRes.json() as Record<string, { include_in_llm_context?: boolean }>
const llmMap: Record<string, boolean> = {}
for (const [k, v] of Object.entries(metaData)) {
llmMap[k] = v.include_in_llm_context ?? true
}
setLlmMeta(llmMap)
}
} catch { /* best-effort */ }
// Load adapter-config for satpass // Load adapter-config for satpass
try { try {
const satpassRes = await fetch("/api/adapter-config/satpass") const satpassRes = await fetch("/api/adapter-config/satpass")
@ -479,6 +537,20 @@ export default function Environment() {
})() })()
}, []) }, [])
// Fetch notification family gating config separately (best-effort)
useEffect(() => {
;(async () => {
try {
const res = await fetch('/api/config/notifications')
if (res.ok) {
const data: NotificationsConfig = await res.json()
setNotifConfig(data)
setNotifOriginal(JSON.stringify(data))
}
} catch { /* best-effort */ }
})()
}, [])
useEffect(() => { useEffect(() => {
const load = async () => { const load = async () => {
try { try {
@ -516,6 +588,18 @@ export default function Environment() {
} }
} }
// Auto-save include_in_llm_context toggle via /api/adapter-meta/{adapter}
const saveLlmContext = async (adapterName: string, val: boolean) => {
setLlmMeta((prev) => ({ ...prev, [adapterName]: val }))
try {
await fetch(`/api/adapter-meta/${adapterName}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ include_in_llm_context: val }),
})
} catch { /* best-effort */ }
}
const save = async () => { const save = async () => {
if (!env) return if (!env) return
setSaving(true); setError(null); setSuccess(null) setSaving(true); setError(null); setSuccess(null)
@ -702,6 +786,86 @@ const save = async () => {
const up = (patch: Partial<EnvConfig>) => env && setEnv({ ...env, ...patch }) const up = (patch: Partial<EnvConfig>) => env && setEnv({ ...env, ...patch })
// Maps from Environment.tsx adapter key → backend adapter-meta name.
// Used to read/write include_in_llm_context per adapter panel.
const PANEL_META_KEY: Partial<Record<AdapterKey, string>> = {
nws: 'nws',
fires: 'wfigs',
firms: 'firms',
swpc: 'swpc',
ducting: 'ducting',
traffic: 'tomtom_incidents',
roads511: 'itd_511',
wzdx: 'wzdx',
usgs: 'usgs',
usgs_quake: 'usgs_quake',
avalanche: 'avalanche',
satpass: 'satpass',
}
// ── Notification family gating helpers ────────────────────────────────────
const notifToggles: Record<string, NotificationToggle> = notifConfig?.toggles || {}
const notifHasChanges = notifConfig !== null && JSON.stringify(notifConfig) !== notifOriginal
const updNotif = (fam: string, patch: Partial<NotificationToggle>) => {
if (!notifConfig) return
const t = notifConfig.toggles || {}
setNotifConfig({
...notifConfig,
toggles: {
...t,
[fam]: { ...(t[fam] || {}), name: fam, ...patch } as NotificationToggle,
},
})
}
const saveNotif = async () => {
if (!notifConfig) return
setNotifSaving(true)
setNotifError(null)
setNotifSuccess(null)
try {
// Re-fetch and merge only gating fields, preserving all delivery fields.
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 = notifConfig.toggles || {}
for (const { key } of TOGGLE_FAMILY_META) {
const mine = myToggles[key]
if (!mine) continue
const freshT = (fresh.toggles || {})[key] || {}
merged.toggles![key] = {
...freshT,
name: (freshT as NotificationToggle).name || key,
enabled: mine.enabled,
min_severity: mine.min_severity,
freshness_seconds: mine.freshness_seconds ?? (freshT as NotificationToggle).freshness_seconds ?? 600,
cooldown_seconds: mine.cooldown_seconds ?? (freshT as NotificationToggle).cooldown_seconds ?? 0,
} as NotificationToggle
}
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')
setNotifConfig(merged)
setNotifOriginal(JSON.stringify(merged))
setNotifSuccess('Family settings saved')
setTimeout(() => setNotifSuccess(null), 3000)
} catch (e) {
setNotifError(e instanceof Error ? e.message : 'Save failed')
} finally {
setNotifSaving(false)
}
}
const discardNotif = () => {
if (notifOriginal) setNotifConfig(JSON.parse(notifOriginal))
}
if (loading) return <div className="flex items-center justify-center h-64 text-[#777]">Loading environmental config</div> if (loading) return <div className="flex items-center justify-center h-64 text-[#777]">Loading environmental config</div>
if (!env) return <div className="flex items-center justify-center h-64 text-red-400">{error || 'No config'}</div> if (!env) return <div className="flex items-center justify-center h-64 text-red-400">{error || 'No config'}</div>
@ -836,6 +1000,32 @@ const save = async () => {
<NumberInput label="Update Cooldown (hours)" value={Math.round(wfigsConfig.cooldown_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, cooldown_seconds: v * 3600 })} min={0} helper="Minimum hours between updates for the same fire" /> <NumberInput label="Update Cooldown (hours)" value={Math.round(wfigsConfig.cooldown_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, cooldown_seconds: v * 3600 })} min={0} helper="Minimum hours between updates for the same fire" />
<NumberInput label="Freshness Window (hours)" value={Math.round(wfigsConfig.freshness_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, freshness_seconds: v * 3600 })} min={0} helper="0 = always broadcast regardless of event age" /> <NumberInput label="Freshness Window (hours)" value={Math.round(wfigsConfig.freshness_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, freshness_seconds: v * 3600 })} min={0} helper="0 = always broadcast regardless of event age" />
</div> </div>
<div className="border-t border-border pt-4 mt-2">
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Fire Digest</div>
<label className="flex items-center justify-between">
<span className="text-sm font-sans text-[#e0e0e0]">Enable daily digest</span>
<input type="checkbox" checked={firesConfig.digest_enabled}
onChange={(e) => setFiresConfig({ ...firesConfig, digest_enabled: e.target.checked })}
className="w-4 h-4 accent-[#f59e0b]" />
</label>
{firesConfig.digest_enabled && (
<div className="mt-3 space-y-3">
<ListInput label="Schedule (HH:MM)" value={firesConfig.digest_schedule}
onChange={(v) => setFiresConfig({ ...firesConfig, digest_schedule: v })}
helper="Digest times in HH:MM format, e.g. 06:00 and 18:00" />
<SelectInput label="Timezone" value={firesConfig.digest_timezone}
onChange={(v) => setFiresConfig({ ...firesConfig, digest_timezone: v })}
options={[
{ value: 'America/Boise', label: 'Mountain — America/Boise' },
{ value: 'America/Los_Angeles', label: 'Pacific — America/Los_Angeles' },
{ value: 'America/Denver', label: 'Mountain — America/Denver' },
{ value: 'America/Chicago', label: 'Central — America/Chicago' },
{ value: 'America/New_York', label: 'Eastern — America/New_York' },
{ value: 'UTC', label: 'UTC' },
]} />
</div>
)}
</div>
</div> </div>
) )
case 'avalanche': return ( case 'avalanche': return (
@ -1168,17 +1358,21 @@ const save = async () => {
<div className="space-y-6"> <div className="space-y-6">
{/* Header + master enable + save bar */} {/* Header + master enable + save bar */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-xl font-semibold text-white">Environment</h1> <h1 className="text-xl font-semibold text-white">Data Feeds</h1>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Toggle label="Feeds Enabled" checked={env.enabled} onChange={(v) => up({ enabled: v })} /> {pageTab === 'curated' && (
{hasChanges && (
<> <>
<button onClick={discard} className="flex items-center gap-1 px-3 py-1.5 text-sm text-[#777] hover:text-white border border-border"> <Toggle label="Feeds Enabled" checked={env.enabled} onChange={(v) => up({ enabled: v })} />
<RotateCcw size={14} /> Discard {hasChanges && (
</button> <>
<button onClick={save} disabled={saving} className="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white disabled:opacity-50"> <button onClick={discard} className="flex items-center gap-1 px-3 py-1.5 text-sm text-[#777] hover:text-white border border-border">
<Save size={14} /> {saving ? 'Saving…' : 'Save'} <RotateCcw size={14} /> Discard
</button> </button>
<button onClick={save} disabled={saving} className="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white disabled:opacity-50">
<Save size={14} /> {saving ? 'Saving…' : 'Save'}
</button>
</>
)}
</> </>
)} )}
</div> </div>
@ -1193,6 +1387,33 @@ const save = async () => {
</div> </div>
)} )}
{/* Top-level tab bar: Data Feeds (curated) | Advanced (raw key/value editor) */}
<div className="flex gap-1 border-b border-border">
<button
onClick={() => setPageTab('curated')}
className={`flex items-center gap-2 px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${pageTab === 'curated' ? 'border-accent text-accent' : 'border-transparent text-[#777] hover:text-white'}`}>
<Cloud size={15} /> Data Feeds
</button>
<button
onClick={() => setPageTab('advanced')}
className={`flex items-center gap-2 px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${pageTab === 'advanced' ? 'border-accent text-accent' : 'border-transparent text-[#777] hover:text-white'}`}>
<Sliders size={15} /> Advanced (raw)
</button>
</div>
{/* Advanced tab: raw key/value editor, curated keys filtered out */}
{pageTab === 'advanced' && (
<div className="-mx-6">
<div className="px-6 pb-2 text-xs text-[#777]">
Curated keys (owned by the Data Feeds panels above) are hidden here.
Future or unknown keys from adapters will appear in this view.
</div>
<AdapterConfig excludeKeys={CURATED_KEYS} hideLlmToggle />
</div>
)}
{/* Curated panels — only shown in curated tab */}
{pageTab === 'curated' && <>
{/* Family tabs */} {/* Family tabs */}
<div className="flex gap-1 border-b border-border overflow-x-auto"> <div className="flex gap-1 border-b border-border overflow-x-auto">
@ -1248,6 +1469,87 @@ const save = async () => {
</div> </div>
)} )}
{/* ── Family Settings — notification gating per family ──────────────── */}
{family === 'family_settings' && (
<div className="space-y-4">
<p className="text-xs text-[#777]">
Per-family gating: enable/disable each notification family, set its minimum severity threshold,
freshness window, and cooldown. Delivery routing (which mesh channels, email, webhook) is
configured on the <a href="/notifications" className="text-accent hover:underline">Meshtastic Routing</a> and{' '}
<a href="/meshcore/routing" className="text-accent hover:underline">MeshCore Routing</a> pages.
Regions filter is dormant and intentionally hidden.
</p>
{notifError && <div className="text-sm text-red-400 bg-red-500/10 p-3">{notifError}</div>}
{notifSuccess && <div className="text-sm text-green-400 bg-green-500/10 p-3">{notifSuccess}</div>}
{notifConfig === null ? (
<div className="text-xs text-[#666] italic">Loading family settings</div>
) : (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
const t: NotificationToggle = notifToggles[key] || ({} as NotificationToggle)
return (
<div key={key} className="border border-border p-3 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-[#e0e0e0]">
<Icon size={15} /> {label}
</div>
<Toggle label="" checked={!!t.enabled} onChange={(v) => updNotif(key, { enabled: v })} />
</div>
<div className={t.enabled ? 'space-y-3' : 'space-y-3 opacity-40 pointer-events-none select-none'}>
<SelectInput
label="Min Severity"
value={t.min_severity || 'priority'}
onChange={(v) => updNotif(key, { min_severity: v })}
options={[
{ value: 'routine', label: 'Routine — informational' },
{ value: 'priority', label: 'Priority — needs attention' },
{ value: 'immediate', label: 'Immediate — act now' },
]}
/>
<div className="grid grid-cols-2 gap-3">
<NumberInput
label="Freshness (sec)"
value={t.freshness_seconds ?? 600}
onChange={(v) => updNotif(key, { freshness_seconds: v })}
min={0}
helper="Drop events older than this"
/>
<NumberInput
label="Cooldown (sec)"
value={t.cooldown_seconds ?? 0}
onChange={(v) => updNotif(key, { cooldown_seconds: v })}
min={0}
helper="0 = no throttle"
/>
</div>
</div>
</div>
)
})}
</div>
{notifHasChanges && (
<div className="flex justify-end gap-2 pt-2">
<button
onClick={discardNotif}
className="flex items-center gap-1 px-3 py-1.5 text-sm text-[#777] hover:text-white border border-border"
>
<RotateCcw size={14} /> Discard
</button>
<button
onClick={saveNotif}
disabled={notifSaving}
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white disabled:opacity-50"
>
<Save size={14} /> {notifSaving ? 'Saving…' : 'Save'}
</button>
</div>
)}
</>
)}
</div>
)}
{/* Adapter sub-tabs + panel */} {/* Adapter sub-tabs + panel */}
{fam.adapters.length > 0 && activeAdapter && ( {fam.adapters.length > 0 && activeAdapter && (
<> <>
@ -1273,11 +1575,15 @@ const save = async () => {
hasKey={META[activeAdapter].hasKey} hasKey={META[activeAdapter].hasKey}
health={healthFor(activeAdapter)} health={healthFor(activeAdapter)}
events={eventsFor(activeAdapter)} events={eventsFor(activeAdapter)}
llmContext={PANEL_META_KEY[activeAdapter] !== undefined ? (llmMeta[PANEL_META_KEY[activeAdapter]!] ?? true) : undefined}
onLlmContext={PANEL_META_KEY[activeAdapter] !== undefined ? (v) => saveLlmContext(PANEL_META_KEY[activeAdapter]!, v) : undefined}
> >
{renderSettings(activeAdapter)} {renderSettings(activeAdapter)}
</AdapterPanel> </AdapterPanel>
</> </>
)} )}
</> /* end curated tab */}
</div> </div>
) )
} }

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { TextInput, NumberInput } from './Config' import { TextInput, NumberInput, Toggle, ListInput } from './Config'
import { notifyRestartRequired } from '@/components/RestartBanner' import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api' import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext' import { useDirty } from '@/context/DirtyContext'
@ -19,10 +19,24 @@ interface ConnectionConfig {
[key: string]: unknown [key: string]: unknown
} }
// MeshCore-native "Bot behavior" config (section `meshcore_context`).
// observe_channels are channel NAMES; ignore_contacts are contact names or
// pubkey prefixes. Unknown fields are preserved on save via object spread.
interface MeshcoreContextCfg {
enable_passive_context?: boolean
observe_channels?: string[]
ignore_contacts?: string[]
respond_to_dms?: boolean
[key: string]: unknown
}
export default function MeshCoreConnection() { export default function MeshCoreConnection() {
const { setDirty } = useDirty() const { setDirty } = useDirty()
const [config, setConfig] = useState<ConnectionConfig | null>(null) const [config, setConfig] = useState<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null) const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
// Bot behavior (section `meshcore_context`)
const [mcContext, setMcContext] = useState<MeshcoreContextCfg | null>(null)
const [originalMcContext, setOriginalMcContext] = useState<MeshcoreContextCfg | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -40,9 +54,14 @@ export default function MeshCoreConnection() {
const fetchConfig = useCallback(async () => { const fetchConfig = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const data = (await apiFetchConfig('connection')) as ConnectionConfig const [data, mcCtx] = await Promise.all([
apiFetchConfig('connection') as Promise<ConnectionConfig>,
apiFetchConfig('meshcore_context') as Promise<MeshcoreContextCfg>,
])
setConfig(data) setConfig(data)
setOriginalConfig(JSON.parse(JSON.stringify(data))) setOriginalConfig(JSON.parse(JSON.stringify(data)))
setMcContext(mcCtx)
setOriginalMcContext(JSON.parse(JSON.stringify(mcCtx)))
setHasChanges(false) setHasChanges(false)
setError(null) setError(null)
} catch (err) { } catch (err) {
@ -87,10 +106,13 @@ export default function MeshCoreConnection() {
} }
useEffect(() => { useEffect(() => {
if (config && originalConfig) { if (config && originalConfig && mcContext && originalMcContext) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) const changed =
JSON.stringify(config) !== JSON.stringify(originalConfig) ||
JSON.stringify(mcContext) !== JSON.stringify(originalMcContext)
setHasChanges(changed)
} }
}, [config, originalConfig]) }, [config, originalConfig, mcContext, originalMcContext])
useEffect(() => { useEffect(() => {
setDirty(hasChanges) setDirty(hasChanges)
@ -101,18 +123,23 @@ export default function MeshCoreConnection() {
setConfig((c) => (c ? { ...c, ...patch } : c)) setConfig((c) => (c ? { ...c, ...patch } : c))
const saveConfig = async () => { const saveConfig = async () => {
if (!config) return if (!config || !mcContext) return
setSaving(true) setSaving(true)
setError(null) setError(null)
setSuccess(null) setSuccess(null)
try { try {
// PUT the whole connection object so Meshtastic fields are preserved. // PUT the whole objects so sibling fields (Meshtastic connection fields,
const result = await apiUpdateConfig('connection', config) // any other meshcore_context keys) are preserved.
const results = await Promise.all([
apiUpdateConfig('connection', config),
apiUpdateConfig('meshcore_context', mcContext),
])
setOriginalConfig(JSON.parse(JSON.stringify(config))) setOriginalConfig(JSON.parse(JSON.stringify(config)))
setOriginalMcContext(JSON.parse(JSON.stringify(mcContext)))
setHasChanges(false) setHasChanges(false)
setDirty(false) setDirty(false)
setSuccess('MeshCore connection saved successfully') setSuccess('MeshCore connection saved successfully')
if (result.restart_required) { if (results.some((r) => r.restart_required)) {
notifyRestartRequired([]) notifyRestartRequired([])
} }
setTimeout(() => setSuccess(null), 3000) setTimeout(() => setSuccess(null), 3000)
@ -124,10 +151,20 @@ export default function MeshCoreConnection() {
} }
const discardChanges = () => { const discardChanges = () => {
if (originalConfig) { if (originalConfig) setConfig(JSON.parse(JSON.stringify(originalConfig)))
setConfig(JSON.parse(JSON.stringify(originalConfig))) if (originalMcContext) setMcContext(JSON.parse(JSON.stringify(originalMcContext)))
setHasChanges(false) setHasChanges(false)
} }
const toggleObserveChannel = (name: string) => {
setMcContext((c) => {
if (!c) return c
const current = c.observe_channels ?? []
const next = current.includes(name)
? current.filter((n) => n !== name)
: [...current, name]
return { ...c, observe_channels: next }
})
} }
if (loading) { if (loading) {
@ -228,6 +265,63 @@ export default function MeshCoreConnection() {
</div> </div>
</div> </div>
{/* Bot behavior card — mirrors the Meshtastic Connection page, MeshCore-native */}
{mcContext && (
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">Bot behavior</div>
<Toggle
label="Enable Passive Context"
checked={!!mcContext.enable_passive_context}
onChange={(v) => setMcContext({ ...mcContext, enable_passive_context: v })}
helper="Listen to MeshCore channel traffic for context"
info="When enabled, the bot monitors MeshCore channels and includes recent messages in its context so it can reference what others said."
/>
{/* Observe MeshCore channels — multi-select of channel NAMES (empty = observe all) */}
<div className="space-y-1">
<label className="block text-xs text-slate-500 uppercase tracking-wide">Observe MeshCore Channels</label>
<div className="border border-[#1e2a3a] p-2 space-y-1">
{channels.map((ch) => {
const selected = (mcContext.observe_channels ?? []).includes(ch)
return (
<label
key={ch}
onClick={() => toggleObserveChannel(ch)}
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
>
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
selected ? 'bg-accent border-accent' : 'border-slate-600'
}`}>
{selected && <Check size={12} className="text-white" />}
</div>
<span className="text-sm text-slate-200">{ch}</span>
</label>
)
})}
{channels.length === 0 && (
<div className="text-sm text-slate-500 p-2">
No channels available{!channelsActive ? ' (MeshCore not connected)' : ''}
</div>
)}
</div>
<p className="text-xs text-slate-600">Channels to monitor (none selected = observe all)</p>
</div>
<ListInput
label="Ignore MeshCore Contacts"
value={mcContext.ignore_contacts ?? []}
onChange={(v) => setMcContext({ ...mcContext, ignore_contacts: v })}
helper="Contact names or pubkey prefixes to exclude from context (comma-separated)"
info="Messages from these MeshCore contacts won't be included in passive context. Enter contact names or public-key prefixes."
/>
<Toggle
label="Respond to DMs"
checked={!!mcContext.respond_to_dms}
onChange={(v) => setMcContext({ ...mcContext, respond_to_dms: v })}
helper="Reply when someone sends a MeshCore direct message"
info="When enabled, the bot responds to MeshCore direct messages. When disabled, it only responds to channel messages that mention its name."
/>
</div>
)}
{/* Send test message card */} {/* Send test message card */}
<div className={`bg-bg-card border border-border p-6 space-y-4${!channelsActive ? ' opacity-60' : ''}`}> <div className={`bg-bg-card border border-border p-6 space-y-4${!channelsActive ? ' opacity-60' : ''}`}>
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div> <div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>

View file

@ -0,0 +1,43 @@
import { useState, useEffect } from 'react'
import MeshCoreContacts from './MeshCoreContacts'
import MeshCoreCompanion from './MeshCoreCompanion'
const TABS = [
{ key: 'contacts', label: 'Contacts' },
{ key: 'companion', label: 'Companion' },
] as const
type TabKey = typeof TABS[number]['key']
export default function MeshCoreContactsCompanion() {
const [activeTab, setActiveTab] = useState<TabKey>('contacts')
useEffect(() => {
document.title = 'Contacts & Companion - MeshAI'
}, [])
return (
<div className="space-y-4">
{/* Tabs */}
<div className="flex gap-1 border-b border-border">
{TABS.map(({ key, label }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`px-4 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${
activeTab === key
? 'border-accent text-accent'
: 'border-transparent text-[#777] hover:text-white'
}`}
>
{label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === 'contacts' && <MeshCoreContacts />}
{activeTab === 'companion' && <MeshCoreCompanion />}
</div>
)
}

View file

@ -0,0 +1,33 @@
import { useEffect } from 'react'
import { AlertTriangle } from 'lucide-react'
export default function MeshCoreDangerZones() {
useEffect(() => {
document.title = 'Danger Zones - 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">
<AlertTriangle 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 Danger Zones</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">
MeshCore danger zone alerting will correlate infrastructure node positions with active
hazards and deliver targeted DMs via the MeshCore companion. This becomes available
once the MeshCore delivery pipeline supports infrastructure-targeted messaging.
</p>
</div>
</div>
</div>
</div>
)
}

View file

@ -197,16 +197,19 @@ export default function MeshCoreRouting() {
</div> </div>
</div> </div>
{/* Cross-link note: shared family settings live on the Meshtastic Routing page */} {/* Cross-link note: gating on Data Feeds, MT+Other delivery on Meshtastic Routing */}
<div className="flex items-start gap-2 p-3 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-400"> <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" /> <ExternalLink size={16} className="text-accent mt-0.5 flex-shrink-0" />
<div> <div>
Shared per-family settings (enable, severity threshold, regions, freshness/cooldown, and Family gating (enable, severity threshold, freshness/cooldown) is on{' '}
Meshtastic / email / webhook delivery) live on the{' '} <Link to="/environment" className="text-accent hover:underline">
Data Feeds
</Link>
. Meshtastic and email/webhook/digest delivery is on{' '}
<Link to="/notifications" className="text-accent hover:underline"> <Link to="/notifications" className="text-accent hover:underline">
Meshtastic Routing Meshtastic Routing
</Link>{' '} </Link>
page. This page edits only the MeshCore delivery for each family. . This page edits only the MeshCore delivery for each family.
</div> </div>
</div> </div>
@ -225,7 +228,7 @@ export default function MeshCoreRouting() {
<div className="bg-bg-card border border-border p-6 space-y-4"> <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"> <div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
MeshCore Delivery 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." /> <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 configured on the Data Feeds page." />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => { {TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {

View file

@ -1,14 +1,36 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback } from 'react'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react' import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { ConnectionSection, TextInput, NumberInput, type ConnectionConfig } from './Config' import { ConnectionSection, TextInput, NumberInput, Toggle, type ConnectionConfig } from './Config'
import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker'
import { notifyRestartRequired } from '@/components/RestartBanner' import { notifyRestartRequired } from '@/components/RestartBanner'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api' import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext' import { useDirty } from '@/context/DirtyContext'
// Only the fields the "Bot behavior" section edits are typed explicitly; the
// rest of each section (max_age, max_context_items, MQTT fields, bot name/owner,
// etc.) is preserved untouched on save via object spread.
interface ContextCfg {
enabled?: boolean
observe_channels?: number[]
ignore_nodes?: string[]
[key: string]: unknown
}
interface BotCfg {
respond_to_dms?: boolean
[key: string]: unknown
}
export default function MeshtasticConnection() { export default function MeshtasticConnection() {
const { setDirty } = useDirty() const { setDirty } = useDirty()
const [config, setConfig] = useState<ConnectionConfig | null>(null) const [config, setConfig] = useState<ConnectionConfig | null>(null)
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null) const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
// Bot behavior: passive-context (section `context`) + respond-to-DMs (section `bot`)
const [context, setContext] = useState<ContextCfg | null>(null)
const [originalContext, setOriginalContext] = useState<ContextCfg | null>(null)
const [bot, setBot] = useState<BotCfg | null>(null)
const [originalBot, setOriginalBot] = useState<BotCfg | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null) const [error, setError] = useState<string | null>(null)
@ -41,9 +63,17 @@ export default function MeshtasticConnection() {
const fetchConfig = useCallback(async () => { const fetchConfig = useCallback(async () => {
setLoading(true) setLoading(true)
try { try {
const data = (await apiFetchConfig('connection')) as ConnectionConfig const [conn, ctx, botData] = await Promise.all([
setConfig(data) apiFetchConfig('connection') as Promise<ConnectionConfig>,
setOriginalConfig(JSON.parse(JSON.stringify(data))) apiFetchConfig('context') as Promise<ContextCfg>,
apiFetchConfig('bot') as Promise<BotCfg>,
])
setConfig(conn)
setOriginalConfig(JSON.parse(JSON.stringify(conn)))
setContext(ctx)
setOriginalContext(JSON.parse(JSON.stringify(ctx)))
setBot(botData)
setOriginalBot(JSON.parse(JSON.stringify(botData)))
setHasChanges(false) setHasChanges(false)
setError(null) setError(null)
} catch (err) { } catch (err) {
@ -59,10 +89,14 @@ export default function MeshtasticConnection() {
}, [fetchConfig]) }, [fetchConfig])
useEffect(() => { useEffect(() => {
if (config && originalConfig) { if (config && originalConfig && context && originalContext && bot && originalBot) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig)) const changed =
JSON.stringify(config) !== JSON.stringify(originalConfig) ||
JSON.stringify(context) !== JSON.stringify(originalContext) ||
JSON.stringify(bot) !== JSON.stringify(originalBot)
setHasChanges(changed)
} }
}, [config, originalConfig]) }, [config, originalConfig, context, originalContext, bot, originalBot])
useEffect(() => { useEffect(() => {
setDirty(hasChanges) setDirty(hasChanges)
@ -70,18 +104,25 @@ export default function MeshtasticConnection() {
}, [hasChanges, setDirty]) }, [hasChanges, setDirty])
const saveConfig = async () => { const saveConfig = async () => {
if (!config) return if (!config || !context || !bot) return
setSaving(true) setSaving(true)
setError(null) setError(null)
setSuccess(null) setSuccess(null)
try { try {
// PUT the whole connection object so MeshCore fields aren't clobbered. // PUT the whole objects so sibling fields (MeshCore connection fields,
const result = await apiUpdateConfig('connection', config) // context max_age/max_context_items, bot name/owner) aren't clobbered.
const results = await Promise.all([
apiUpdateConfig('connection', config),
apiUpdateConfig('context', context),
apiUpdateConfig('bot', bot),
])
setOriginalConfig(JSON.parse(JSON.stringify(config))) setOriginalConfig(JSON.parse(JSON.stringify(config)))
setOriginalContext(JSON.parse(JSON.stringify(context)))
setOriginalBot(JSON.parse(JSON.stringify(bot)))
setHasChanges(false) setHasChanges(false)
setDirty(false) setDirty(false)
setSuccess('Meshtastic connection saved successfully') setSuccess('Meshtastic connection saved successfully')
if (result.restart_required) { if (results.some((r) => r.restart_required)) {
notifyRestartRequired([]) notifyRestartRequired([])
} }
setTimeout(() => setSuccess(null), 3000) setTimeout(() => setSuccess(null), 3000)
@ -93,10 +134,10 @@ export default function MeshtasticConnection() {
} }
const discardChanges = () => { const discardChanges = () => {
if (originalConfig) { if (originalConfig) setConfig(JSON.parse(JSON.stringify(originalConfig)))
setConfig(JSON.parse(JSON.stringify(originalConfig))) if (originalContext) setContext(JSON.parse(JSON.stringify(originalContext)))
setHasChanges(false) if (originalBot) setBot(JSON.parse(JSON.stringify(originalBot)))
} setHasChanges(false)
} }
if (loading) { if (loading) {
@ -167,6 +208,42 @@ export default function MeshtasticConnection() {
<ConnectionSection data={config} onChange={setConfig} /> <ConnectionSection data={config} onChange={setConfig} />
</div> </div>
{/* Bot behavior card */}
{context && bot && (
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">Bot behavior</div>
<Toggle
label="Enable Passive Context"
checked={!!context.enabled}
onChange={(v) => setContext({ ...context, enabled: v })}
helper="Listen to channel traffic for context"
info="When enabled, the bot monitors mesh channels and includes recent messages in its context. This lets the bot reference things other people said on the channel."
/>
<ChannelPicker
label="Observe Channels"
value={context.observe_channels ?? []}
onChange={(v) => setContext({ ...context, observe_channels: v })}
helper="Channels to monitor (empty = all)"
info="Meshtastic channels to listen on. Leave empty to monitor all channels."
mode="multi"
/>
<NodePicker
label="Ignore Nodes"
value={context.ignore_nodes ?? []}
onChange={(v) => setContext({ ...context, ignore_nodes: v })}
helper="Nodes to exclude from context"
info="Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes."
/>
<Toggle
label="Respond to DMs"
checked={!!bot.respond_to_dms}
onChange={(v) => setBot({ ...bot, respond_to_dms: v })}
helper="Reply when someone sends a direct message"
info="When enabled, the bot responds to direct messages from any node. When disabled, the bot only responds to channel messages that mention its name."
/>
</div>
)}
{/* Send test message card */} {/* Send test message card */}
<div className="bg-bg-card border border-border p-6 space-y-4"> <div className="bg-bg-card border border-border p-6 space-y-4">
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div> <div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>

View file

@ -0,0 +1,17 @@
import { useEffect } from 'react'
import DangerZonesPanel from '@/components/DangerZonesPanel'
export default function MeshtasticDangerZones() {
useEffect(() => {
document.title = 'Danger Zones - MeshAI'
}, [])
return (
<div className="max-w-2xl mx-auto space-y-6">
<p className="text-sm text-slate-500">
Alert infrastructure nodes when they are within a configurable buffer distance of an active hazard.
</p>
<DangerZonesPanel />
</div>
)
}

View file

@ -0,0 +1,178 @@
import { useState, useEffect, useCallback } from 'react'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import Mesh from './Mesh'
import MeshtasticSources from './MeshtasticSources'
import { MeshIntelligenceSection, type MeshIntelligenceConfig } from './Config'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
import { notifyRestartRequired } from '@/components/RestartBanner'
const TABS = [
{ key: 'nodes', label: 'Nodes' },
{ key: 'sources', label: 'Sources' },
{ key: 'health', label: 'Health' },
] as const
type TabKey = typeof TABS[number]['key']
export default function MeshtasticNodes() {
const [activeTab, setActiveTab] = useState<TabKey>('nodes')
// Health tab state
const { setDirty } = useDirty()
const [intelligence, setIntelligence] = useState<MeshIntelligenceConfig | null>(null)
const [originalIntelligence, setOriginalIntelligence] = useState<MeshIntelligenceConfig | null>(null)
const [loading, setLoading] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
const [success, setSuccess] = useState<string | null>(null)
const [hasChanges, setHasChanges] = useState(false)
useEffect(() => {
document.title = 'Nodes & Health - MeshAI'
}, [])
const fetchData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = (await apiFetchConfig('mesh_intelligence')) as MeshIntelligenceConfig
setIntelligence(data)
setOriginalIntelligence(JSON.parse(JSON.stringify(data)))
setHasChanges(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load mesh intelligence config')
} finally {
setLoading(false)
}
}, [])
// Load when Health tab becomes active
useEffect(() => {
if (activeTab === 'health' && intelligence === null && !loading) {
fetchData()
}
}, [activeTab, intelligence, loading, fetchData])
useEffect(() => {
if (intelligence && originalIntelligence) {
setHasChanges(JSON.stringify(intelligence) !== JSON.stringify(originalIntelligence))
}
}, [intelligence, originalIntelligence])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const saveConfig = async () => {
if (!intelligence) return
setSaving(true)
setError(null)
setSuccess(null)
try {
const result = await apiUpdateConfig('mesh_intelligence', intelligence)
setOriginalIntelligence(JSON.parse(JSON.stringify(intelligence)))
setHasChanges(false)
setDirty(false)
setSuccess('Mesh intelligence 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 (originalIntelligence) setIntelligence(JSON.parse(JSON.stringify(originalIntelligence)))
setHasChanges(false)
}
return (
<div className="space-y-4">
{/* Tabs */}
<div className="flex gap-1 border-b border-border">
{TABS.map(({ key, label }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`px-4 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${
activeTab === key
? 'border-accent text-accent'
: 'border-transparent text-[#777] hover:text-white'
}`}
>
{label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === 'nodes' && <Mesh />}
{activeTab === 'sources' && <MeshtasticSources />}
{activeTab === 'health' && (
<div className="max-w-2xl mx-auto space-y-6">
{/* Save bar */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">
Mesh health scoring, region management, and automated alerting.
</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>
)}
{loading ? (
<div className="flex items-center justify-center h-32">
<div className="text-slate-400">Loading...</div>
</div>
) : intelligence ? (
<div className="bg-bg-card border border-border p-6">
<MeshIntelligenceSection data={intelligence} onChange={setIntelligence} />
</div>
) : (
<div className="flex items-center justify-center h-32">
<div className="text-red-400">Failed to load config</div>
</div>
)}
</div>
)}
</div>
)
}

View file

@ -8,7 +8,6 @@ import {
} from 'lucide-react' } from 'lucide-react'
import ChannelPicker from '@/components/ChannelPicker' import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker' import NodePicker from '@/components/NodePicker'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext' import { useDirty } from '@/context/DirtyContext'
// Types // Types
@ -365,7 +364,7 @@ export function InfoButton({ info }: { info: string }) {
} }
// Form components // Form components
function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '', info = '' }: { export function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '', info = '' }: {
label: string label: string
value: string value: string
onChange: (v: string) => void onChange: (v: string) => void
@ -406,7 +405,7 @@ function TextInput({ label, value, onChange, type = 'text', placeholder = '', he
) )
} }
function NumberInput({ label, value, onChange, min, max, step = 1, helper = '', info = '' }: { export function NumberInput({ label, value, onChange, min, max, step = 1, helper = '', info = '' }: {
label: string label: string
value: number value: number
onChange: (v: number) => void onChange: (v: number) => void
@ -436,7 +435,7 @@ function NumberInput({ label, value, onChange, min, max, step = 1, helper = '',
) )
} }
function Toggle({ label, checked, onChange, helper = '', info = '' }: { export function Toggle({ label, checked, onChange, helper = '', info = '' }: {
label: string label: string
checked: boolean checked: boolean
onChange: (v: boolean) => void onChange: (v: boolean) => void
@ -469,7 +468,7 @@ function Toggle({ label, checked, onChange, helper = '', info = '' }: {
) )
} }
function TimeInput({ label, value, onChange, helper = '', info = '' }: { export function TimeInput({ label, value, onChange, helper = '', info = '' }: {
label: string label: string
value: string value: string
onChange: (v: string) => void onChange: (v: string) => void
@ -1609,150 +1608,174 @@ export function SeverityChannelMatrix({
) )
} }
function MasterToggles({ toggles, onChange }: { // Merge only the MT+Other-channel-owned delivery fields of `mine` into `fresh`,
// preserving MeshCore entries (meshcore_*) and all gating fields
// (enabled/min_severity/freshness_seconds/cooldown_seconds/regions) that now live
// on the Data Feeds page.
function mergeMeshtasticAndOtherFields(
fresh: NotificationToggle | undefined,
mine: NotificationToggle,
key: string,
): NotificationToggle {
const base: NotificationToggle = fresh ? { ...fresh } : { ...mine, name: key }
base.name = base.name || key
// Merge severity_channels: keep meshcore_* from fresh, overlay non-meshcore from mine.
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 meshcoreOnly = (freshSC[sev] || []).filter((c) => c.startsWith('meshcore_'))
const nonMeshcore = (mineSC[sev] || []).filter((c) => !c.startsWith('meshcore_'))
mergedSC[sev] = [...meshcoreOnly, ...nonMeshcore]
})
base.severity_channels = mergedSC
// Overlay MT delivery fields
base.broadcast_channel = mine.broadcast_channel
base.node_ids = mine.node_ids
// Overlay Other channels delivery fields
base.smtp_host = mine.smtp_host
base.smtp_port = mine.smtp_port
base.smtp_user = mine.smtp_user
base.smtp_password = mine.smtp_password
base.smtp_tls = mine.smtp_tls
base.from_address = mine.from_address
base.recipients = mine.recipients
base.webhook_url = mine.webhook_url
base.webhook_headers = mine.webhook_headers
// NOTE: do NOT overlay gating fields (enabled, min_severity, freshness_seconds,
// cooldown_seconds, regions) — those are managed by Data Feeds > Family Settings.
return base
}
// Always-visible per-family Meshtastic delivery grid.
// Structure mirrors MeshCoreRouting.tsx: pure delivery, no enable toggle, no expander.
function MeshtasticDeliveryGrid({
toggles,
onChange,
}: {
toggles: Record<string, NotificationToggle> toggles: Record<string, NotificationToggle>
onChange: (t: Record<string, NotificationToggle>) => void onChange: (t: Record<string, NotificationToggle>) => void
}) { }) {
const [expanded, setExpanded] = useState<string | null>(null)
const upd = (fam: string, patch: Partial<NotificationToggle>) => const upd = (fam: string, patch: Partial<NotificationToggle>) =>
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle }) onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
return ( return (
<div className="space-y-3 mb-8"> <div className="space-y-3">
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide"> <div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Master Toggles Meshtastic Delivery
<InfoButton info="Per-family notification policy: enable a family, set its severity threshold, choose which channels fire at each severity, and scope to regions (PagerDuty/Grafana-style)." /> <InfoButton info="Per-family Meshtastic delivery matrix. Choose which channels fire at each severity, the broadcast channel index, and DM node IDs. Family on/off and severity threshold are configured on the Data Feeds page." />
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => { {TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
const t = (toggles[key] || ({} as NotificationToggle)) const t = toggles[key] || ({} as NotificationToggle)
const isOpen = expanded === key
const chanCount = Object.values(t.severity_channels || {}).reduce((n, arr) => n + ((arr as string[])?.length || 0), 0)
const regionCount = (t.regions || []).length
return ( return (
<div key={key} className="border border-[#1e2a3a] p-3"> <div key={key} className="border border-[#1e2a3a] p-3 space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center gap-2 text-sm text-slate-200">
<button type="button" onClick={() => setExpanded(isOpen ? null : key)} <Icon size={15} /> {label}
className="flex items-center gap-2 text-sm text-slate-200">
<Icon size={15} /> {label}
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Toggle label="" checked={!!t.enabled} onChange={(v) => upd(key, { enabled: v })} />
</div> </div>
{!isOpen && ( <div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="text-xs text-slate-600 mt-1"> <div className="flex items-center gap-2 text-xs font-medium text-slate-300">
{t.enabled <Radio size={13} />
? `${regionCount || 'all'} region${regionCount === 1 ? '' : 's'}, ${chanCount} channel${chanCount === 1 ? '' : 's'} at ${t.min_severity || 'priority'}+` Meshtastic
: 'OFF'}
</div> </div>
)} <SeverityChannelMatrix
{isOpen && ( channels={MT_CHANNELS}
<div className={`mt-3 space-y-4 ${t.enabled ? '' : 'opacity-40 pointer-events-none select-none'}`}> 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>
</div>
)
})}
</div>
</div>
)
}
{/* ── General ──────────────────────────────────────────────── */} // Per-family Other Channels (email / webhook / digest) delivery grid.
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]"> // Kept on the Meshtastic Routing page so all non-MeshCore delivery lives in one place.
<div className="text-xs text-slate-500 uppercase tracking-wide">General</div> function OtherChannelsGrid({
<SeveritySelector value={t.min_severity || 'priority'} onChange={(v) => upd(key, { min_severity: v })} /> toggles,
<ListInput onChange,
label="Regions (empty = all)" }: {
value={t.regions || []} toggles: Record<string, NotificationToggle>
onChange={(v) => upd(key, { regions: v })} onChange: (t: Record<string, NotificationToggle>) => void
placeholder="Add region..." }) {
/> const upd = (fam: string, patch: Partial<NotificationToggle>) =>
<div className="grid grid-cols-2 gap-3"> onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
<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>
{/* ── 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>
return (
<div className="space-y-3">
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Other Channels (Email / Webhook / Digest)
<InfoButton info="Per-family delivery via email, webhook, and digest. The severity matrix selects which of these channels fire at each level. Email SMTP settings are behind the collapsible below each family." />
</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">
<Mail size={13} />
Other channels
</div> </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>
) )
})} })}
@ -1826,10 +1849,34 @@ export default function Notifications() {
setSuccess(null) setSuccess(null)
try { try {
// Re-fetch the live config and merge ONLY MT+Other delivery fields so we never
// clobber gating fields (enabled/min_severity/freshness/cooldown) managed by
// Data Feeds, nor MeshCore fields managed by the MeshCore 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,
enabled: config.enabled, // global master switch lives on this page
rules: config.rules, // rules are only edited on this page
toggles: { ...(fresh.toggles || {}) },
}
const myToggles = config.toggles || {}
for (const { key } of TOGGLE_FAMILY_META) {
const mine = myToggles[key]
if (!mine) continue
merged.toggles![key] = mergeMeshtasticAndOtherFields(
(fresh.toggles || {})[key],
mine,
key,
)
}
const res = await fetch('/api/config/notifications', { const res = await fetch('/api/config/notifications', {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config), body: JSON.stringify(merged),
}) })
const result = await res.json() const result = await res.json()
@ -1838,10 +1885,11 @@ export default function Notifications() {
throw new Error(result.detail || 'Save failed') throw new Error(result.detail || 'Save failed')
} }
setSuccess('Notifications config saved successfully') setConfig(merged)
setOriginalConfig(JSON.parse(JSON.stringify(config))) setOriginalConfig(JSON.parse(JSON.stringify(merged)))
setHasChanges(false) setHasChanges(false)
setDirty(false) setDirty(false)
setSuccess('Meshtastic routing saved successfully')
setTimeout(() => setSuccess(null), 3000) setTimeout(() => setSuccess(null), 3000)
} catch (err) { } catch (err) {
setError(err instanceof Error ? err.message : 'Save failed') setError(err instanceof Error ? err.message : 'Save failed')
@ -2145,7 +2193,7 @@ export default function Notifications() {
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<p className="text-sm text-slate-500"> <p className="text-sm text-slate-500">
Alert delivery and scheduled reports. Rules define what triggers a notification and where it gets sent. Per-family Meshtastic delivery and other-channel delivery. Family gating (enable, severity threshold, freshness/cooldown) is on <a href="/environment" className="text-accent hover:underline">Data Feeds</a>. MeshCore delivery is on the <a href="/meshcore/routing" className="text-accent hover:underline">MeshCore Routing</a> page.
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -2198,80 +2246,22 @@ export default function Notifications() {
info="When disabled, no alerts or scheduled messages will be delivered. Alerts still get recorded to history." info="When disabled, no alerts or scheduled messages will be delivered. Alerts still get recorded to history."
/> />
{/* Meshtastic delivery grids — always visible regardless of master switch */}
{config.toggles && (
<>
<MeshtasticDeliveryGrid
toggles={config.toggles}
onChange={(t) => setConfig({ ...config, toggles: t })}
/>
<OtherChannelsGrid
toggles={config.toggles}
onChange={(t) => setConfig({ ...config, toggles: t })}
/>
</>
)}
{config.enabled && ( {config.enabled && (
<> {/* Cold-start grace -- v0.5.8b */} <>
<div className="space-y-3 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Cold-start grace</label>
</div>
<NumberInput
label="Grace period (seconds)"
value={config.cold_start_grace_seconds ?? 60}
onChange={(v) => setConfig({ ...config, cold_start_grace_seconds: v })}
min={0}
max={600}
helper="Suppress broadcasts for this many seconds after the first event arrives"
info="When meshai starts seeing events for the first time, suppress mesh broadcasts for this many seconds to absorb any JetStream backlog. Persistence rows still get written; only broadcasts are suppressed."
/>
</div>
{/* Band Conditions -- v0.5.11 */}
<div className="space-y-3 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Band Conditions (HF propagation)</label>
</div>
<Toggle
label="Enable scheduled band-conditions broadcasts"
checked={config.band_conditions_enabled ?? true}
onChange={(v) => setConfig({ ...config, band_conditions_enabled: v })}
helper="3x/day HF propagation summary (Day/Night ratings per band group). The daily fire digest (twice-daily LLM summary of active fires + the last 24h of growth/spotting) is configured separately under Adapter Config -> fires.digest_*. See Reference -> Fire Tracker (Fusion) and Reference -> Broadcast Types for the New/Update/Active prefix system."
info="Source priority: (1) recent SWPC readings persisted locally; (2) HamQSL.com fallback; (3) silent skip if both fail. Persistence rows are written either way for an audit trail."
/>
{(config.band_conditions_enabled ?? true) && (
<div className="grid grid-cols-3 gap-3">
<TimeInput
label="Slot 1"
value={(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])[0] || '06:00'}
onChange={(v) => {
const s = [...(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])]
s[0] = v
setConfig({ ...config, band_conditions_schedule: s })
}}
helper="Morning (default 06:00 MT)"
/>
<TimeInput
label="Slot 2"
value={(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])[1] || '14:00'}
onChange={(v) => {
const s = [...(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])]
s[1] = v
setConfig({ ...config, band_conditions_schedule: s })
}}
helper="Afternoon (default 14:00 MT)"
/>
<TimeInput
label="Slot 3"
value={(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])[2] || '22:00'}
onChange={(v) => {
const s = [...(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])]
s[2] = v
setConfig({ ...config, band_conditions_schedule: s })
}}
helper="Night (default 22:00 MT)"
/>
</div>
)}
<p className="text-xs text-slate-600">All times are Mountain Time (America/Boise). DST handled automatically.</p>
</div>
{/* Master Toggles */}
{config.toggles && (
<MasterToggles
toggles={config.toggles}
onChange={(t) => setConfig({ ...config, toggles: t })}
/>
)}
{/* Rules Section */} {/* Rules Section */}
<div className="space-y-3"> <div className="space-y-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
@ -2344,451 +2334,7 @@ export default function Notifications() {
)} )}
</div> </div>
{/* Danger Zones self-contained panel (own GET/PUT for the isolated
`danger_zones` config section; never entangled with the notifications
save above). Additive only. */}
<DangerZonesPanel />
</div> </div>
) )
} }
// ============================================================================
// Danger Zones panel — fully isolated, additive feature.
// Loads/saves the standalone `danger_zones` config section via the generic
// /api/config helpers. Has its OWN state, fetch (on mount), and save. It is
// intentionally decoupled from the page's `notifications` config so it can
// never be tangled with the existing save logic.
// ============================================================================
const DZ_MONITOR_ROLES = ['CLIENT_BASE', 'ROUTER', 'ROUTER_LATE'] as const
const DZ_DELIVERY_OPTIONS = [
{ value: 'mesh_dm', label: 'Mesh DM (unicast to nodes)' },
{ value: 'mesh_broadcast', label: 'Mesh Broadcast (channel)' },
{ value: 'email', label: 'Email' },
{ value: 'webhook', label: 'Webhook' },
{ value: 'none', label: '(None / log only)' },
]
// Per-family rows. snow is a sub-gate of weather; flood a sub-gate of seismic.
const DZ_FAMILIES: {
key: string
label: string
description: string
Icon: typeof Activity
showAcres?: boolean
tabled?: boolean
}[] = [
{ key: 'fire', label: 'Fire', description: 'Active wildfires (radius from fire perimeter).', Icon: Flame, showAcres: true },
{ key: 'weather', label: 'Weather', description: 'Severe weather warnings near a node.', Icon: Cloud },
{ key: 'snow', label: 'Snow (sub-gate of Weather)', description: 'Snow-category weather events.', Icon: Snowflake, tabled: true },
{ key: 'flood', label: 'Flood (sub-gate of Seismic)', description: 'Stream/flood gauge events.', Icon: Activity },
{ key: 'avalanche', label: 'Avalanche', description: 'Avalanche advisories near a node.', Icon: Mountain },
{ key: 'seismic', label: 'Seismic', description: 'Earthquakes and seismic events near a node.', Icon: Mountain },
]
interface DangerZoneHazardConfig {
enabled: boolean
buffer_mi: number
min_acres: number
}
interface DangerZonesConfig {
enabled: boolean
dry_run: boolean
monitor_roles: string[]
default_buffer_mi: number
cooldown_minutes: number
fire: DangerZoneHazardConfig
weather: DangerZoneHazardConfig
snow: DangerZoneHazardConfig
flood: DangerZoneHazardConfig
avalanche: DangerZoneHazardConfig
seismic: DangerZoneHazardConfig
delivery_type: string
node_ids: string[]
broadcast_channel: number | null
webhook_url: string
webhook_headers: Record<string, string>
}
function dzDefaultHazard(): DangerZoneHazardConfig {
return { enabled: false, buffer_mi: 5.0, min_acres: 0 }
}
// New-object default. NOTE: delivery_type defaults to mesh_dm (do NOT copy the
// page's mesh_broadcast new-rule default).
function dzDefault(): DangerZonesConfig {
return {
enabled: false,
dry_run: true,
monitor_roles: ['ROUTER', 'ROUTER_LATE', 'CLIENT_BASE'],
default_buffer_mi: 5.0,
cooldown_minutes: 360,
fire: dzDefaultHazard(),
weather: dzDefaultHazard(),
snow: dzDefaultHazard(),
flood: dzDefaultHazard(),
avalanche: dzDefaultHazard(),
seismic: dzDefaultHazard(),
delivery_type: 'mesh_dm',
node_ids: [],
broadcast_channel: null,
webhook_url: '',
webhook_headers: {},
}
}
// Minimal local select — SelectInput lives in Config.tsx (which we must not
// touch/entangle), so this panel keeps its own tiny equivalent.
function DZSelect({ label, value, onChange, options, info = '' }: {
label: string
value: string
onChange: (v: string) => void
options: { value: string; label: string }[]
info?: string
}) {
return (
<div className="space-y-1">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
{label}
{info && <InfoButton info={info} />}
</label>
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
>
{options.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
)
}
// Per-family row — mirrors the AlertRuleToggle (toggle + thresholds) pattern.
// AlertRuleToggle itself lives in Config.tsx and is NOT exported, so this is a
// local equivalent purpose-built for the per-family hazard config.
function DZFamilyRow({ meta, cfg, onChange }: {
meta: { key: string; label: string; description: string; Icon: typeof Activity; showAcres?: boolean; tabled?: boolean }
cfg: DangerZoneHazardConfig
onChange: (c: DangerZoneHazardConfig) => void
}) {
const { Icon } = meta
return (
<div className={`border border-[#1e2a3a] p-3 space-y-2 ${meta.tabled ? 'opacity-50' : ''}`}>
<div className="flex items-center justify-between">
<div className="flex items-start gap-2 flex-1">
<Icon size={15} className="text-slate-400 mt-0.5 flex-shrink-0" />
<div className="flex-1">
<span className="text-sm text-slate-300">{meta.label}</span>
<p className="text-xs text-slate-600">{meta.description}</p>
{meta.tabled && (
<span className="inline-block mt-1 px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
Tabled needs snowfall + elevation pipeline
</span>
)}
</div>
</div>
<button
type="button"
disabled={meta.tabled}
onClick={() => { if (!meta.tabled) onChange({ ...cfg, enabled: !cfg.enabled }) }}
className={`relative w-11 h-6 rounded-full transition-colors flex-shrink-0 ml-3 ${
cfg.enabled ? 'bg-accent' : 'bg-[#1e2a3a]'
} ${meta.tabled ? 'cursor-not-allowed' : ''}`}
>
<span
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
cfg.enabled ? 'translate-x-5' : ''
}`}
/>
</button>
</div>
{cfg.enabled && !meta.tabled && (
<div className={`grid gap-3 pt-2 border-t border-[#1e2a3a] ${meta.showAcres ? 'grid-cols-2' : 'grid-cols-1'}`}>
<NumberInput
label="Buffer (mi)"
value={cfg.buffer_mi ?? 0}
onChange={(v) => onChange({ ...cfg, buffer_mi: v })}
min={0}
step={0.5}
/>
{meta.showAcres && (
<NumberInput
label="Min Acres"
value={cfg.min_acres ?? 0}
onChange={(v) => onChange({ ...cfg, min_acres: v })}
min={0}
step={1}
/>
)}
</div>
)}
</div>
)
}
function DangerZonesPanel() {
const [expanded, setExpanded] = useState(false)
const [cfg, setCfg] = useState<DangerZonesConfig | 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 load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const raw = (await apiFetchConfig('danger_zones')) as Partial<DangerZonesConfig>
// Merge over defaults so missing/new fields are always present.
const d = dzDefault()
setCfg({
...d,
...raw,
fire: { ...d.fire, ...(raw.fire || {}) },
weather: { ...d.weather, ...(raw.weather || {}) },
snow: { ...d.snow, ...(raw.snow || {}) },
flood: { ...d.flood, ...(raw.flood || {}) },
avalanche: { ...d.avalanche, ...(raw.avalanche || {}) },
seismic: { ...d.seismic, ...(raw.seismic || {}) },
monitor_roles: raw.monitor_roles ?? d.monitor_roles,
node_ids: raw.node_ids ?? d.node_ids,
webhook_headers: raw.webhook_headers ?? d.webhook_headers,
})
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load danger zones config')
setCfg(dzDefault())
} finally {
setLoading(false)
}
}, [])
useEffect(() => { load() }, [load])
const save = async () => {
if (!cfg) return
setSaving(true)
setError(null)
setSuccess(null)
try {
await apiUpdateConfig('danger_zones', cfg)
setSuccess('Danger Zones config saved')
setTimeout(() => setSuccess(null), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
const upd = (patch: Partial<DangerZonesConfig>) => setCfg(c => (c ? { ...c, ...patch } : c))
const toggleRole = (role: string) => {
if (!cfg) return
const cur = cfg.monitor_roles || []
upd({ monitor_roles: cur.includes(role) ? cur.filter(r => r !== role) : [...cur, role] })
}
return (
<div className="bg-bg-card border border-border">
{/* Collapsible header */}
<button
type="button"
onClick={() => setExpanded(e => !e)}
className="w-full flex items-center justify-between p-4 text-left"
>
<div className="flex items-center gap-3">
<AlertTriangle size={18} className="text-amber-400" />
<div>
<div className="text-sm font-medium text-slate-200">Danger Zones</div>
<div className="text-xs text-slate-500">
Alert when monitored infrastructure nodes are in/near a hazard
</div>
</div>
</div>
<div className="flex items-center gap-2">
{cfg && (
<span className={`text-xs px-2 py-0.5 rounded ${
cfg.enabled
? (cfg.dry_run ? 'bg-yellow-500/10 text-yellow-400' : 'bg-green-500/10 text-green-400')
: 'bg-slate-800 text-slate-500'
}`}>
{cfg.enabled ? (cfg.dry_run ? 'Dry-run' : 'Live') : 'Disabled'}
</span>
)}
{expanded ? <ChevronDown size={18} className="text-slate-500" /> : <ChevronRight size={18} className="text-slate-500" />}
</div>
</button>
{expanded && (
<div className="p-6 pt-0 space-y-6">
{/* Safety copy */}
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/20">
<AlertCircle size={16} className="text-amber-400 mt-0.5 flex-shrink-0" />
<div className="text-xs text-amber-200/90 leading-relaxed">
Ships disabled; when enabled, defaults to dry-run / log-only no mesh traffic
until you turn dry-run off. Requires <span className="font-medium">Enable Notifications</span> (above)
and environmental feeds to be on, since hazard events only flow when those are active.
</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>
)}
{loading || !cfg ? (
<div className="text-sm text-slate-500">Loading danger zones config...</div>
) : (
<>
<Toggle
label="Enable Danger Zones"
checked={cfg.enabled}
onChange={(v) => upd({ enabled: v })}
helper="Master switch for the infrastructure danger-zone correlator"
/>
<Toggle
label="Dry-run (log only)"
checked={cfg.dry_run}
onChange={(v) => upd({ dry_run: v })}
helper="When on, matches are logged but nothing is sent to the mesh. Turn off only after verifying dry-run output."
/>
{/* Monitored roles */}
<div className="space-y-2">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Monitored Roles
<InfoButton info="Which Meshtastic node roles to correlate against hazards. Only nodes that have a GPS position are scanned." />
</label>
<div className="flex flex-wrap gap-2">
{DZ_MONITOR_ROLES.map(role => {
const on = (cfg.monitor_roles || []).includes(role)
return (
<button
key={role}
type="button"
onClick={() => toggleRole(role)}
className={`px-3 py-1.5 rounded text-sm transition-colors ${
on ? 'bg-accent text-white' : 'bg-[#1e2a3a] text-slate-400 hover:text-slate-200'
}`}
>
{role}
</button>
)
})}
</div>
</div>
{/* Global numeric settings */}
<div className="grid grid-cols-2 gap-4">
<NumberInput
label="Default Buffer (mi)"
value={cfg.default_buffer_mi}
onChange={(v) => upd({ default_buffer_mi: v })}
min={0}
step={0.5}
helper="Buffer used when a family has none set"
/>
<NumberInput
label="Cooldown (min)"
value={cfg.cooldown_minutes}
onChange={(v) => upd({ cooldown_minutes: v })}
min={0}
helper="Min time between repeat alerts per node+family"
/>
</div>
{/* Per-family hazard config */}
<div className="space-y-3">
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
Hazard Families
<InfoButton info="Enable each hazard family to monitor, with its own buffer distance and severity threshold. Snow is a sub-gate of Weather; Flood a sub-gate of Seismic." />
</label>
{DZ_FAMILIES.map(meta => (
<DZFamilyRow
key={meta.key}
meta={meta}
cfg={cfg[meta.key as keyof DangerZonesConfig] as DangerZoneHazardConfig}
onChange={(c) => upd({ [meta.key]: c } as Partial<DangerZonesConfig>)}
/>
))}
</div>
{/* Delivery */}
<div className="space-y-4 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="flex items-center gap-2 text-sm font-medium text-slate-300">
<Send size={14} />
DELIVERY
</div>
<DZSelect
label="Delivery Method"
value={cfg.delivery_type || 'mesh_dm'}
onChange={(v) => upd({ delivery_type: v })}
options={DZ_DELIVERY_OPTIONS}
info="Where danger-zone alerts get delivered. Mesh DM unicasts to specific nodes; broadcast sends to a channel. Has no effect while dry-run is on."
/>
{cfg.delivery_type === 'mesh_dm' && (
<NodePicker
label="Recipient Nodes"
value={cfg.node_ids || []}
onChange={(v) => upd({ node_ids: v })}
helper="Nodes that receive direct messages"
valueType="node_id_hex"
/>
)}
{cfg.delivery_type === 'mesh_broadcast' && (
<ChannelPicker
label="Broadcast Channel"
value={cfg.broadcast_channel ?? 0}
onChange={(v) => upd({ broadcast_channel: v })}
helper="Select the mesh radio channel"
mode="single"
/>
)}
{cfg.delivery_type === 'webhook' && (
<TextInput
label="Webhook URL"
value={cfg.webhook_url || ''}
onChange={(v) => upd({ webhook_url: v })}
placeholder="https://discord.com/api/webhooks/..."
helper="POST alert as JSON"
/>
)}
{cfg.delivery_type === 'email' && (
<p className="text-xs text-slate-600">
Email delivery uses the SMTP settings configured for notification rules.
</p>
)}
</div>
{/* Save */}
<div className="flex justify-end">
<button
type="button"
onClick={save}
disabled={saving}
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 Danger Zones'}
</button>
</div>
</>
)}
</div>
)}
</div>
)
}

View file

@ -0,0 +1,43 @@
import { useState, useEffect } from 'react'
import GaugeSites from './GaugeSites'
import TownAnchors from './TownAnchors'
const TABS = [
{ key: 'gauge-sites', label: 'Gauge Sites' },
{ key: 'town-anchors', label: 'Town Anchors' },
] as const
type TabKey = typeof TABS[number]['key']
export default function Places() {
const [activeTab, setActiveTab] = useState<TabKey>('gauge-sites')
useEffect(() => {
document.title = 'Places - MeshAI'
}, [])
return (
<div className="space-y-4">
{/* Tabs */}
<div className="flex gap-1 border-b border-border">
{TABS.map(({ key, label }) => (
<button
key={key}
onClick={() => setActiveTab(key)}
className={`px-4 py-2 text-sm whitespace-nowrap border-b-2 -mb-px transition-colors ${
activeTab === key
? 'border-accent text-accent'
: 'border-transparent text-[#777] hover:text-white'
}`}
>
{label}
</button>
))}
</div>
{/* Tab content */}
{activeTab === 'gauge-sites' && <GaugeSites />}
{activeTab === 'town-anchors' && <TownAnchors />}
</div>
)
}

View file

@ -1186,18 +1186,6 @@ export default function Reference() {
]} ]}
/> />
<SectionHeader>Subscription Commands</SectionHeader>
<RefTable
headers={['Command', 'What It Does']}
rows={[
[<Mono>!subscribe</Mono>, 'Lists all alert categories you can subscribe to'],
[<Mono>!subscribe fire_proximity</Mono>, 'Subscribe to a specific category'],
[<Mono>!subscribe all</Mono>, 'Subscribe to everything'],
[<Mono>!unsubscribe fire_proximity</Mono>, 'Unsubscribe from a category'],
[<Mono>!subscriptions</Mono>, "Shows what you're currently subscribed to"],
]}
/>
<SectionHeader>Conversational</SectionHeader> <SectionHeader>Conversational</SectionHeader>
<p> <p>
Bang commands are the short, predictable interface. For anything that Bang commands are the short, predictable interface. For anything that

View file

@ -0,0 +1,330 @@
import { useState, useEffect, useCallback } from 'react'
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
import { notifyRestartRequired } from '@/components/RestartBanner'
import {
Toggle, NumberInput, TimeInput, InfoButton,
type NotificationsConfig,
} from '@/pages/Notifications'
// Fires adapter config shape (digest settings)
interface FiresConfig {
digest_enabled: boolean
digest_schedule: string[]
digest_timezone: string
}
interface Props {
family?: 'meshtastic' | 'meshcore'
}
export default function ScheduledBroadcasts({ family = 'meshtastic' }: Props) {
const { setDirty } = useDirty()
// Notifications config state (full object — read-modify-write to preserve other fields)
const [notifConfig, setNotifConfig] = useState<NotificationsConfig | null>(null)
const [originalNotifConfig, setOriginalNotifConfig] = useState<NotificationsConfig | null>(null)
// Fires adapter config state
const [firesConfig, setFiresConfig] = useState<FiresConfig>({
digest_enabled: true,
digest_schedule: ['06:00', '18:00'],
digest_timezone: 'America/Boise',
})
const [originalFiresConfig, setOriginalFiresConfig] = useState<string>('')
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)
setError(null)
try {
// Load notifications config (full object for read-modify-write)
const notif = (await apiFetchConfig('notifications')) as NotificationsConfig
setNotifConfig(notif)
setOriginalNotifConfig(JSON.parse(JSON.stringify(notif)))
// Load fires adapter config
try {
const firesRes = await fetch('/api/adapter-config/fires')
if (firesRes.ok) {
const firesData = await firesRes.json()
const fires: FiresConfig = {
digest_enabled: firesData.digest_enabled?.value ?? true,
digest_schedule: firesData.digest_schedule?.value ?? ['06:00', '18:00'],
digest_timezone: firesData.digest_timezone?.value ?? 'America/Boise',
}
setFiresConfig(fires)
setOriginalFiresConfig(JSON.stringify(fires))
}
} catch {
// adapter-config optional — proceed with defaults
setOriginalFiresConfig(JSON.stringify(firesConfig))
}
setHasChanges(false)
} catch (err) {
setError(err instanceof Error ? err.message : 'Failed to load config')
} finally {
setLoading(false)
}
}, []) // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => {
document.title = `Scheduled Broadcasts - MeshAI`
fetchData()
}, [fetchData])
useEffect(() => {
if (notifConfig && originalNotifConfig) {
const notifChanged = JSON.stringify(notifConfig) !== JSON.stringify(originalNotifConfig)
const firesChanged = JSON.stringify(firesConfig) !== originalFiresConfig
setHasChanges(notifChanged || firesChanged)
}
}, [notifConfig, originalNotifConfig, firesConfig, originalFiresConfig])
useEffect(() => {
setDirty(hasChanges)
return () => setDirty(false)
}, [hasChanges, setDirty])
const saveAdapterKey = async (adapter: string, key: string, value: unknown) => {
const res = await fetch(`/api/adapter-config/${adapter}/${key}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value }),
})
if (!res.ok) {
const err = await res.json().catch(() => ({}))
throw new Error(err.detail || `Failed to save ${adapter}.${key}`)
}
}
const saveConfig = async () => {
if (!notifConfig) return
setSaving(true)
setError(null)
setSuccess(null)
try {
// Save full notifications config (read-modify-write — band/cold-start keys round-trip)
const result = await apiUpdateConfig('notifications', notifConfig)
setOriginalNotifConfig(JSON.parse(JSON.stringify(notifConfig)))
if (result.restart_required) notifyRestartRequired([])
// Save fires adapter config — only changed keys
const origFires = originalFiresConfig ? (JSON.parse(originalFiresConfig) as FiresConfig) : null
if (!origFires || firesConfig.digest_enabled !== origFires.digest_enabled) {
await saveAdapterKey('fires', 'digest_enabled', firesConfig.digest_enabled)
}
if (!origFires || JSON.stringify(firesConfig.digest_schedule) !== JSON.stringify(origFires.digest_schedule)) {
await saveAdapterKey('fires', 'digest_schedule', firesConfig.digest_schedule)
}
if (!origFires || firesConfig.digest_timezone !== origFires.digest_timezone) {
await saveAdapterKey('fires', 'digest_timezone', firesConfig.digest_timezone)
}
setOriginalFiresConfig(JSON.stringify(firesConfig))
setHasChanges(false)
setDirty(false)
setSuccess('Scheduled broadcasts saved successfully')
setTimeout(() => setSuccess(null), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
} finally {
setSaving(false)
}
}
const discardChanges = () => {
if (originalNotifConfig) setNotifConfig(JSON.parse(JSON.stringify(originalNotifConfig)))
if (originalFiresConfig) setFiresConfig(JSON.parse(originalFiresConfig))
setHasChanges(false)
}
const subtitle = family === 'meshcore'
? 'MeshCore scheduled broadcasts and band condition reports.'
: 'Meshtastic scheduled broadcasts and band condition reports.'
if (loading) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-slate-400">Loading scheduled broadcasts...</div>
</div>
)
}
if (!notifConfig) {
return (
<div className="flex items-center justify-center h-64">
<div className="text-red-400">Failed to load config</div>
</div>
)
}
return (
<div className="max-w-2xl mx-auto space-y-6">
{/* Header / save bar */}
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-slate-500">{subtitle}</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>
)}
{/* Cold-start grace */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Cold-start grace</label>
</div>
<NumberInput
label="Grace period (seconds)"
value={notifConfig.cold_start_grace_seconds ?? 60}
onChange={(v) => setNotifConfig({ ...notifConfig, cold_start_grace_seconds: v })}
min={0}
max={600}
helper="Suppress broadcasts for this many seconds after the first event arrives"
info="When meshai starts seeing events for the first time, suppress mesh broadcasts for this many seconds to absorb any JetStream backlog. Persistence rows still get written; only broadcasts are suppressed."
/>
</div>
{/* Band Conditions */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">Band Conditions (HF propagation)</label>
</div>
<Toggle
label="Enable scheduled band-conditions broadcasts"
checked={notifConfig.band_conditions_enabled ?? true}
onChange={(v) => setNotifConfig({ ...notifConfig, band_conditions_enabled: v })}
helper="3x/day HF propagation summary (Day/Night ratings per band group). The daily fire digest (twice-daily LLM summary of active fires + the last 24h of growth/spotting) is configured separately under Adapter Config -> fires.digest_*. See Reference -> Fire Tracker (Fusion) and Reference -> Broadcast Types for the New/Update/Active prefix system."
info="Source priority: (1) recent SWPC readings persisted locally; (2) HamQSL.com fallback; (3) silent skip if both fail. Persistence rows are written either way for an audit trail."
/>
{(notifConfig.band_conditions_enabled ?? true) && (
<div className="grid grid-cols-3 gap-3">
<TimeInput
label="Slot 1"
value={(notifConfig.band_conditions_schedule ?? ['06:00', '14:00', '22:00'])[0] || '06:00'}
onChange={(v) => {
const s = [...(notifConfig.band_conditions_schedule ?? ['06:00', '14:00', '22:00'])]
s[0] = v
setNotifConfig({ ...notifConfig, band_conditions_schedule: s })
}}
helper="Morning (default 06:00 MT)"
/>
<TimeInput
label="Slot 2"
value={(notifConfig.band_conditions_schedule ?? ['06:00', '14:00', '22:00'])[1] || '14:00'}
onChange={(v) => {
const s = [...(notifConfig.band_conditions_schedule ?? ['06:00', '14:00', '22:00'])]
s[1] = v
setNotifConfig({ ...notifConfig, band_conditions_schedule: s })
}}
helper="Afternoon (default 14:00 MT)"
/>
<TimeInput
label="Slot 3"
value={(notifConfig.band_conditions_schedule ?? ['06:00', '14:00', '22:00'])[2] || '22:00'}
onChange={(v) => {
const s = [...(notifConfig.band_conditions_schedule ?? ['06:00', '14:00', '22:00'])]
s[2] = v
setNotifConfig({ ...notifConfig, band_conditions_schedule: s })
}}
helper="Night (default 22:00 MT)"
/>
</div>
)}
<p className="text-xs text-slate-600">All times are Mountain Time (America/Boise). DST handled automatically.</p>
</div>
{/* Fire Digest */}
<div className="bg-bg-card border border-border p-6 space-y-4">
<div className="flex items-center gap-2">
<label className="text-xs text-slate-500 uppercase tracking-wide">
Fire Digest
<InfoButton info="Twice-daily LLM summary of active fires + last 24h of growth/spotting events. Configured per the fires adapter." />
</label>
</div>
<Toggle
label="Enable fire digest broadcasts"
checked={firesConfig.digest_enabled}
onChange={(v) => setFiresConfig({ ...firesConfig, digest_enabled: v })}
helper="Send a twice-daily digest of active fire conditions to the mesh"
/>
{firesConfig.digest_enabled && (
<div className="grid grid-cols-2 gap-3">
<TimeInput
label="Digest Slot 1"
value={(firesConfig.digest_schedule ?? ['06:00', '18:00'])[0] || '06:00'}
onChange={(v) => {
const s = [...(firesConfig.digest_schedule ?? ['06:00', '18:00'])]
s[0] = v
setFiresConfig({ ...firesConfig, digest_schedule: s })
}}
helper="Morning digest (default 06:00 MT)"
/>
<TimeInput
label="Digest Slot 2"
value={(firesConfig.digest_schedule ?? ['06:00', '18:00'])[1] || '18:00'}
onChange={(v) => {
const s = [...(firesConfig.digest_schedule ?? ['06:00', '18:00'])]
s[1] = v
setFiresConfig({ ...firesConfig, digest_schedule: s })
}}
helper="Evening digest (default 18:00 MT)"
/>
</div>
)}
<div className="space-y-1">
<label className="text-xs text-slate-500 uppercase tracking-wide">Timezone</label>
<input
type="text"
value={firesConfig.digest_timezone}
onChange={(e) => setFiresConfig({ ...firesConfig, digest_timezone: e.target.value })}
placeholder="America/Boise"
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
/>
<p className="text-xs text-slate-600">IANA timezone name (e.g. America/Boise). DST handled automatically.</p>
</div>
</div>
</div>
)
}

View file

@ -9,7 +9,6 @@ if TYPE_CHECKING:
from .config import AlertRulesConfig, MeshIntelligenceConfig from .config import AlertRulesConfig, MeshIntelligenceConfig
from .mesh_health import MeshHealthEngine from .mesh_health import MeshHealthEngine
from .mesh_reporter import MeshReporter from .mesh_reporter import MeshReporter
from .subscriptions import SubscriptionManager
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -65,14 +64,12 @@ class AlertEngine:
self, self,
health_engine: "MeshHealthEngine", health_engine: "MeshHealthEngine",
reporter: "MeshReporter", reporter: "MeshReporter",
subscription_manager: "SubscriptionManager",
config: "MeshIntelligenceConfig", config: "MeshIntelligenceConfig",
db_path: str = "", db_path: str = "",
timezone: str = "America/Boise", timezone: str = "America/Boise",
): ):
self._health = health_engine self._health = health_engine
self._reporter = reporter self._reporter = reporter
self._subs = subscription_manager
self._rules = config.alert_rules self._rules = config.alert_rules
self._critical_nodes = set(n.upper() for n in (config.critical_nodes or [])) self._critical_nodes = set(n.upper() for n in (config.critical_nodes or []))
self._db_path = db_path self._db_path = db_path
@ -580,14 +577,6 @@ class AlertEngine:
def clear_pending(self): def clear_pending(self):
self._pending_alerts = [] self._pending_alerts = []
def get_subscribers_for_alert(self, alert: dict) -> list[dict]:
if not self._subs:
return []
return self._subs.get_alert_subscribers(
scope_type=alert.get("scope_type"),
scope_value=alert.get("scope_value"),
)
def check_environmental(self, env_store) -> list[dict]: def check_environmental(self, env_store) -> list[dict]:
"""Check environmental feeds for alertable conditions. """Check environmental feeds for alertable conditions.

View file

@ -160,9 +160,7 @@ def create_dispatcher(
mesh_reporter=None, mesh_reporter=None,
data_store=None, data_store=None,
health_engine=None, health_engine=None,
subscription_manager=None,
env_store=None, env_store=None,
notification_router=None,
) -> CommandDispatcher: ) -> CommandDispatcher:
"""Create and populate command dispatcher with default commands. """Create and populate command dispatcher with default commands.
@ -173,7 +171,6 @@ def create_dispatcher(
mesh_reporter: MeshReporter instance for health commands mesh_reporter: MeshReporter instance for health commands
data_store: MeshDataStore for neighbor data data_store: MeshDataStore for neighbor data
health_engine: MeshHealthEngine for infrastructure detection health_engine: MeshHealthEngine for infrastructure detection
subscription_manager: SubscriptionManager for subscription commands
env_store: EnvironmentalStore for weather/propagation commands env_store: EnvironmentalStore for weather/propagation commands
Returns: Returns:
@ -186,7 +183,6 @@ def create_dispatcher(
from .status import StatusCommand from .status import StatusCommand
from .weather import WeatherCommand from .weather import WeatherCommand
from .health import HealthCommand, RegionCommand, NeighborCommand from .health import HealthCommand, RegionCommand, NeighborCommand
from .subscribe import SubCommand, UnsubCommand, MySubsCommand
dispatcher = CommandDispatcher(prefix=prefix, disabled_commands=disabled_commands) dispatcher = CommandDispatcher(prefix=prefix, disabled_commands=disabled_commands)
@ -224,28 +220,6 @@ def create_dispatcher(
alias_handler.name = alias alias_handler.name = alias
dispatcher.register(alias_handler) dispatcher.register(alias_handler)
# Register subscription commands
sub_cmd = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
dispatcher.register(sub_cmd)
for alias in getattr(sub_cmd, 'aliases', []):
alias_handler = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
alias_handler.name = alias
dispatcher.register(alias_handler)
unsub_cmd = UnsubCommand(subscription_manager, notification_router)
dispatcher.register(unsub_cmd)
for alias in getattr(unsub_cmd, 'aliases', []):
alias_handler = UnsubCommand(subscription_manager, notification_router)
alias_handler.name = alias
dispatcher.register(alias_handler)
mysubs_cmd = MySubsCommand(subscription_manager, notification_router)
dispatcher.register(mysubs_cmd)
for alias in getattr(mysubs_cmd, 'aliases', []):
alias_handler = MySubsCommand(subscription_manager, notification_router)
alias_handler.name = alias
dispatcher.register(alias_handler)
# Register environmental commands # Register environmental commands
if env_store: if env_store:
from .alerts_cmd import AlertsCommand from .alerts_cmd import AlertsCommand

View file

@ -32,11 +32,9 @@ class HelpCommand(CommandHandler):
# Group by category # Group by category
health_names = {"health", "region", "neighbors"} health_names = {"health", "region", "neighbors"}
sub_names = {"sub", "unsub", "mysubs"}
health_cmds = [c for c in unique if c.name.lower() in health_names] health_cmds = [c for c in unique if c.name.lower() in health_names]
sub_cmds = [c for c in unique if c.name.lower() in sub_names] other_cmds = [c for c in unique if c.name.lower() not in health_names and c.name.lower() != "help"]
other_cmds = [c for c in unique if c.name.lower() not in health_names and c.name.lower() not in sub_names and c.name.lower() != "help"]
lines = ["Commands:"] lines = ["Commands:"]
@ -46,12 +44,6 @@ class HelpCommand(CommandHandler):
for c in sorted(health_cmds, key=lambda x: x.name): for c in sorted(health_cmds, key=lambda x: x.name):
lines.append(f" !{c.name} - {c.description}") lines.append(f" !{c.name} - {c.description}")
if sub_cmds:
lines.append("")
lines.append("Subscriptions:")
for c in sorted(sub_cmds, key=lambda x: x.name):
lines.append(f" !{c.name} - {c.description}")
if other_cmds: if other_cmds:
lines.append("") lines.append("")
lines.append("Other:") lines.append("Other:")
@ -67,9 +59,6 @@ class HelpCommand(CommandHandler):
def _command_help(self, cmd_name: str) -> str: def _command_help(self, cmd_name: str) -> str:
"""Detailed help for a specific command.""" """Detailed help for a specific command."""
aliases = { aliases = {
"sub": "sub", "subscribe": "sub", "subscription": "sub", "subscriptions": "sub",
"unsub": "unsub", "unsubscribe": "unsub",
"mysubs": "mysubs", "subs": "mysubs",
"health": "health", "mesh": "health", "health": "health", "mesh": "health",
"region": "region", "reg": "region", "region": "region", "reg": "region",
"neighbors": "neighbors", "nbr": "neighbors", "nb": "neighbors", "neighbors": "neighbors", "nbr": "neighbors", "nb": "neighbors",
@ -81,32 +70,6 @@ class HelpCommand(CommandHandler):
registered = {c.name.lower() for c in self._dispatcher.get_commands()} registered = {c.name.lower() for c in self._dispatcher.get_commands()}
texts = { texts = {
"sub": (
"Subscribe to Reports & Alerts\n\n"
"Daily report:\n"
" !sub daily 6pm\n"
" !sub daily 7:30am region SCID\n"
" !sub daily 6pm node MHR\n\n"
"Weekly digest:\n"
" !sub weekly 8am sun\n\n"
"Alerts (instant DM on issues):\n"
" !sub alerts\n"
" !sub alerts region Wood River\n\n"
"Time: 6pm, 6:30pm, 1830, 18:30\n"
"Regions: SCID, SWID, Magic Valley, Twin Falls\n\n"
"Manage:\n"
" !mysubs - list yours\n"
" !unsub daily - remove daily\n"
" !unsub all - remove everything"
),
"unsub": (
"Unsubscribe\n\n"
" !unsub daily - remove daily report\n"
" !unsub weekly - remove weekly digest\n"
" !unsub alerts - remove alerts\n"
" !unsub all - remove everything"
),
"mysubs": "!mysubs - list your active subscriptions",
"health": ( "health": (
"Mesh Health\n\n" "Mesh Health\n\n"
" !health - 5-pillar health summary\n" " !health - 5-pillar health summary\n"

View file

@ -1,381 +0,0 @@
"""Subscription commands for scheduled reports and alerts."""
from typing import TYPE_CHECKING
from .base import CommandContext, CommandHandler
if TYPE_CHECKING:
from ..mesh_data_store import MeshDataStore
from ..mesh_reporter import MeshReporter
from ..subscriptions import SubscriptionManager
from ..notifications.router import NotificationRouter
class SubCommand(CommandHandler):
"""Subscribe to scheduled reports or alerts."""
name = "sub"
description = "Subscribe to reports or alerts"
usage = "!sub daily|weekly|alerts|<category> [time] [day] [scope]"
aliases = ["subscribe"]
def __init__(
self,
subscription_manager: "SubscriptionManager" = None,
mesh_reporter: "MeshReporter" = None,
data_store: "MeshDataStore" = None,
notification_router: "NotificationRouter" = None,
):
self._sub_manager = subscription_manager
self._reporter = mesh_reporter
self._data_store = data_store
self._notification_router = notification_router
async def execute(self, args: str, context: CommandContext) -> str:
"""Handle subscription command."""
parts = args.strip().split()
# No args - show available alert categories
if not parts:
return self._show_categories()
sub_type = parts[0].lower()
# Check if it's a category subscription
if self._notification_router:
from ..notifications.categories import ALERT_CATEGORIES
if sub_type in ALERT_CATEGORIES or sub_type == "all":
return self._handle_category_subscription(sub_type, context)
# Legacy subscription types
if sub_type not in ("daily", "weekly", "alerts"):
return self._show_categories()
if not self._sub_manager:
return "Subscriptions not available."
try:
if sub_type == "daily":
return self._handle_daily(parts[1:], context)
elif sub_type == "weekly":
return self._handle_weekly(parts[1:], context)
else: # alerts
return self._handle_alerts(parts[1:], context)
except ValueError as e:
return f"Error: {e}"
def _show_categories(self) -> str:
"""Show available alert categories."""
try:
from ..notifications.categories import ALERT_CATEGORIES
except ImportError:
return self._usage_help()
lines = ["Available alert categories:"]
for cat_id, cat_info in ALERT_CATEGORIES.items():
lines.append(f" {cat_id} - {cat_info['description']}")
lines.append("")
lines.append("Usage:")
lines.append(" !sub <category> - subscribe to a category")
lines.append(" !sub all - subscribe to all alerts")
lines.append(" !sub alerts - legacy mesh-wide alerts")
return "\n".join(lines)
def _handle_category_subscription(self, category: str, context: CommandContext) -> str:
"""Handle category-based alert subscription."""
node_id = self._get_user_id(context)
if category == "all":
categories = [] # Empty = all categories
else:
categories = [category]
# Add subscription via notification router
rule_name = self._notification_router.add_mesh_subscription(
node_id=node_id,
categories=categories,
)
if category == "all":
return "Subscribed to all alert categories. Use !unsub to remove."
else:
from ..notifications.categories import get_category
cat_info = get_category(category)
return f"Subscribed to {cat_info['name']} alerts. Use !unsub {category} to remove."
def _usage_help(self) -> str:
"""Return usage help."""
return """Usage:
!sub daily 1830 - daily mesh report at 6:30 PM
!sub daily 1830 region SCID - daily region report
!sub weekly 0800 sun - weekly digest Sunday 8 AM
!sub alerts - mesh-wide alerts (legacy)
!sub <category> - subscribe to alert category
!sub all - subscribe to all alerts"""
def _handle_daily(self, args: list, context: CommandContext) -> str:
"""Handle daily subscription."""
if not args:
raise ValueError("Time required. Example: !sub daily 1830")
schedule_time = args[0]
scope_type, scope_value = self._parse_scope(args[1:])
scope_value = self._validate_scope(scope_type, scope_value)
self._sub_manager.add(
user_id=self._get_user_id(context),
sub_type="daily",
schedule_time=schedule_time,
scope_type=scope_type,
scope_value=scope_value,
)
time_fmt = self._format_time(schedule_time)
scope_desc = self._format_scope(scope_type, scope_value)
return f"Subscribed: daily {scope_desc}report at {time_fmt}"
def _handle_weekly(self, args: list, context: CommandContext) -> str:
"""Handle weekly subscription."""
if len(args) < 2:
raise ValueError("Time and day required. Example: !sub weekly 0800 sun")
schedule_time = args[0]
schedule_day = args[1].lower()
scope_type, scope_value = self._parse_scope(args[2:])
scope_value = self._validate_scope(scope_type, scope_value)
self._sub_manager.add(
user_id=self._get_user_id(context),
sub_type="weekly",
schedule_time=schedule_time,
schedule_day=schedule_day,
scope_type=scope_type,
scope_value=scope_value,
)
time_fmt = self._format_time(schedule_time)
day_fmt = schedule_day.capitalize()
scope_desc = self._format_scope(scope_type, scope_value)
return f"Subscribed: weekly {scope_desc}report at {time_fmt} {day_fmt}"
def _handle_alerts(self, args: list, context: CommandContext) -> str:
"""Handle alerts subscription (legacy)."""
scope_type, scope_value = self._parse_scope(args)
scope_value = self._validate_scope(scope_type, scope_value)
self._sub_manager.add(
user_id=self._get_user_id(context),
sub_type="alerts",
scope_type=scope_type,
scope_value=scope_value,
)
scope_desc = self._format_scope(scope_type, scope_value)
return f"Subscribed: alerts for {scope_desc.strip() or 'mesh'}"
def _parse_scope(self, args: list) -> tuple[str, str]:
"""Parse scope from remaining args."""
if not args:
return "mesh", None
scope_type = "mesh"
scope_value = None
for i, arg in enumerate(args):
arg_lower = arg.lower()
if arg_lower == "region":
scope_type = "region"
scope_value = " ".join(args[i + 1:]) if i + 1 < len(args) else None
break
elif arg_lower == "node":
scope_type = "node"
scope_value = args[i + 1] if i + 1 < len(args) else None
break
return scope_type, scope_value
def _validate_scope(self, scope_type: str, scope_value: str) -> str:
"""Validate and resolve scope value."""
if scope_type == "mesh":
return None
if not scope_value:
raise ValueError(f"Missing {scope_type} name")
if scope_type == "region" and self._reporter:
region = self._reporter._find_region(scope_value)
if region:
return region.name
return scope_value
if scope_type == "node" and self._reporter:
node = self._reporter._find_node(scope_value)
if not node:
raise ValueError(f"Node '{scope_value}' not found")
return node.short_name or str(node.node_num)
return scope_value
def _get_user_id(self, context: CommandContext) -> str:
"""Extract user ID from context."""
sender_id = context.sender_id
if sender_id.startswith("!"):
return str(int(sender_id[1:], 16))
return sender_id
def _format_time(self, hhmm: str) -> str:
"""Format HHMM as readable time."""
hours = int(hhmm[:2])
minutes = int(hhmm[2:])
period = "AM" if hours < 12 else "PM"
display_hour = hours % 12 or 12
return f"{display_hour}:{minutes:02d} {period}"
def _format_scope(self, scope_type: str, scope_value: str) -> str:
"""Format scope for display."""
if scope_type == "mesh" or not scope_value:
return "mesh "
return f"{scope_type} {scope_value} "
class UnsubCommand(CommandHandler):
"""Unsubscribe from reports or alerts."""
name = "unsub"
description = "Remove subscription(s)"
usage = "!unsub daily|weekly|alerts|<category>|all"
aliases = ["unsubscribe"]
def __init__(
self,
subscription_manager: "SubscriptionManager" = None,
notification_router: "NotificationRouter" = None,
):
self._sub_manager = subscription_manager
self._notification_router = notification_router
async def execute(self, args: str, context: CommandContext) -> str:
"""Handle unsubscribe command."""
sub_type = args.strip().lower() if args else None
if not sub_type:
return "Usage: !unsub daily|weekly|alerts|<category>|all"
user_id = self._get_user_id(context)
# Check if it's a category unsubscription
if self._notification_router:
from ..notifications.categories import ALERT_CATEGORIES
if sub_type in ALERT_CATEGORIES or sub_type == "all":
self._notification_router.remove_mesh_subscription(user_id)
return "Removed alert subscriptions"
# Legacy subscription types
if not self._sub_manager:
return "Subscriptions not available."
if sub_type not in ("daily", "weekly", "alerts", "all"):
return f"Invalid type '{sub_type}'. Use: daily, weekly, alerts, <category>, or all"
removed = self._sub_manager.remove(user_id, sub_type if sub_type != "all" else None)
if removed == 0:
return "No subscriptions found to remove"
elif sub_type == "all":
return f"Removed all {removed} subscription(s)"
else:
return f"Removed {removed} {sub_type} subscription(s)"
def _get_user_id(self, context: CommandContext) -> str:
"""Extract user ID from context."""
sender_id = context.sender_id
if sender_id.startswith("!"):
return str(int(sender_id[1:], 16))
return sender_id
class MySubsCommand(CommandHandler):
"""List active subscriptions."""
name = "mysubs"
description = "List your subscriptions"
usage = "!mysubs"
aliases = ["subs", "subscriptions"]
def __init__(
self,
subscription_manager: "SubscriptionManager" = None,
notification_router: "NotificationRouter" = None,
):
self._sub_manager = subscription_manager
self._notification_router = notification_router
async def execute(self, args: str, context: CommandContext) -> str:
"""List user's subscriptions."""
user_id = self._get_user_id(context)
lines = []
# Check notification router subscriptions
if self._notification_router:
categories = self._notification_router.get_node_subscriptions(user_id)
if categories:
if categories == ["all"]:
lines.append("Alert subscriptions: all categories")
else:
lines.append(f"Alert subscriptions: {', '.join(categories)}")
# Check legacy subscriptions
if self._sub_manager:
subs = self._sub_manager.get_user_subs(user_id)
if subs:
if not lines:
lines.append("Your subscriptions:")
else:
lines.append("\nScheduled reports:")
for i, sub in enumerate(subs, 1):
lines.append(f" {i}. {self._format_sub(sub)}")
if not lines:
return "No active subscriptions. Use !sub to subscribe."
return "\n".join(lines)
def _format_sub(self, sub: dict) -> str:
"""Format a subscription for display."""
sub_type = sub["sub_type"]
scope_type = sub.get("scope_type", "mesh")
scope_value = sub.get("scope_value")
scope_desc = ""
if scope_type == "region" and scope_value:
scope_desc = f"region {scope_value} "
elif scope_type == "node" and scope_value:
scope_desc = f"node {scope_value} "
if sub_type == "daily":
time_str = self._format_time(sub.get("schedule_time", "0000"))
return f"Daily {scope_desc}report at {time_str}"
elif sub_type == "weekly":
time_str = self._format_time(sub.get("schedule_time", "0000"))
day_str = (sub.get("schedule_day") or "").capitalize()
return f"Weekly {scope_desc}report at {time_str} {day_str}"
else:
return f"Alerts for {scope_desc.strip() or 'mesh'}"
def _format_time(self, hhmm: str) -> str:
"""Format HHMM as readable time."""
if not hhmm or len(hhmm) != 4:
return hhmm
hours = int(hhmm[:2])
minutes = int(hhmm[2:])
period = "AM" if hours < 12 else "PM"
display_hour = hours % 12 or 12
return f"{display_hour}:{minutes:02d} {period}"
def _get_user_id(self, context: CommandContext) -> str:
"""Extract user ID from context."""
sender_id = context.sender_id
if sender_id.startswith("!"):
return str(int(sender_id[1:], 16))
return sender_id

View file

@ -104,6 +104,15 @@ class ContextConfig:
max_context_items: int = 20 # Max observations injected into LLM context max_context_items: int = 20 # Max observations injected into LLM context
@dataclass
class MeshCoreContextConfig:
"""MeshCore passive-context / bot-behavior settings (MeshCore-native)."""
enable_passive_context: bool = True
observe_channels: list[str] = field(default_factory=list) # channel NAMES, empty = all
ignore_contacts: list[str] = field(default_factory=list) # contact names or pubkey prefixes
respond_to_dms: bool = True
@dataclass @dataclass
class CommandsConfig: class CommandsConfig:
"""Command settings.""" """Command settings."""
@ -782,6 +791,7 @@ class Config:
history: HistoryConfig = field(default_factory=HistoryConfig) history: HistoryConfig = field(default_factory=HistoryConfig)
memory: MemoryConfig = field(default_factory=MemoryConfig) memory: MemoryConfig = field(default_factory=MemoryConfig)
context: ContextConfig = field(default_factory=ContextConfig) context: ContextConfig = field(default_factory=ContextConfig)
meshcore_context: MeshCoreContextConfig = field(default_factory=MeshCoreContextConfig)
commands: CommandsConfig = field(default_factory=CommandsConfig) commands: CommandsConfig = field(default_factory=CommandsConfig)
llm: LLMConfig = field(default_factory=LLMConfig) llm: LLMConfig = field(default_factory=LLMConfig)
weather: WeatherConfig = field(default_factory=WeatherConfig) weather: WeatherConfig = field(default_factory=WeatherConfig)

View file

@ -56,31 +56,29 @@ async def get_alert_history(
} }
@router.get("/subscriptions") @router.get("/activity")
async def get_subscriptions(request: Request): async def get_activity(
"""Get all alert subscriptions.""" request: Request,
subscription_manager = getattr(request.app.state, "subscription_manager", None) limit: int = Query(100, ge=1, le=500),
):
"""Activity Log: most recent outbound mesh broadcasts, newest first.
if not subscription_manager: Reads mesh_broadcasts_out from the persistence/migration DB (get_db) and
return [] returns every column as a plain dict. Legacy rows keep NULL
transport/success. If the table doesn't exist yet, returns [].
"""
from meshai.persistence import get_db
try: try:
subs = subscription_manager.get_all_subs() conn = get_db()
return [ rows = conn.execute(
{ "SELECT * FROM mesh_broadcasts_out "
"id": sub["id"], "ORDER BY sent_at DESC, id DESC LIMIT ?",
"user_id": sub["user_id"], (limit,),
"sub_type": sub["sub_type"], ).fetchall()
"schedule_time": sub.get("schedule_time"),
"schedule_day": sub.get("schedule_day"),
"scope_type": sub.get("scope_type", "mesh"),
"scope_value": sub.get("scope_value"),
"enabled": sub.get("enabled", 1) == 1,
}
for sub in subs
]
except Exception: except Exception:
return [] return []
return [dict(r) for r in rows]
def _map_severity(alert: dict) -> str: def _map_severity(alert: dict) -> str:

View file

@ -45,6 +45,7 @@ VALID_SECTIONS = {
"history", "history",
"memory", "memory",
"context", "context",
"meshcore_context",
"commands", "commands",
"llm", "llm",
"weather", "weather",

View file

@ -119,7 +119,6 @@ async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster:
app.state.health_engine = meshai_instance.health_engine app.state.health_engine = meshai_instance.health_engine
app.state.alert_engine = getattr(meshai_instance, "alert_engine", None) app.state.alert_engine = getattr(meshai_instance, "alert_engine", None)
app.state.env_store = getattr(meshai_instance, "env_store", None) app.state.env_store = getattr(meshai_instance, "env_store", None)
app.state.subscription_manager = meshai_instance.subscription_manager
app.state.notification_router = getattr(meshai_instance, "notification_router", None) app.state.notification_router = getattr(meshai_instance, "notification_router", None)
app.state.connector = meshai_instance.connector app.state.connector = meshai_instance.connector
app.state.bus = getattr(meshai_instance, "event_bus", None) app.state.bus = getattr(meshai_instance, "event_bus", None)

View file

@ -45,7 +45,6 @@ class MeshAI:
self.data_store = None # Replaces source_manager self.data_store = None # Replaces source_manager
self.health_engine = None self.health_engine = None
self.mesh_reporter = None self.mesh_reporter = None
self.subscription_manager = None
self.alert_engine = None self.alert_engine = None
self.notification_router = None self.notification_router = None
self.event_bus = None # Notification pipeline EventBus (v0.3) self.event_bus = None # Notification pipeline EventBus (v0.3)
@ -53,7 +52,6 @@ class MeshAI:
self.env_store = None # Environmental feeds store self.env_store = None # Environmental feeds store
self._central_consumer = None # Central NATS consumer (v0.4) self._central_consumer = None # Central NATS consumer (v0.4)
self._fire_pacer = None # FirePacer for rate-limited fire broadcasts self._fire_pacer = None # FirePacer for rate-limited fire broadcasts
self._last_sub_check: float = 0.0
self.router: Optional[MessageRouter] = None self.router: Optional[MessageRouter] = None
self.responder: Optional[Responder] = None self.responder: Optional[Responder] = None
self._running = False self._running = False
@ -223,12 +221,6 @@ class MeshAI:
except Exception as e: except Exception as e:
logger.debug("Env refresh error: %s", e) logger.debug("Env refresh error: %s", e)
# Check scheduled subscriptions (every 60 seconds)
if self.subscription_manager and self.mesh_reporter:
if time.time() - self._last_sub_check >= 60:
await self._check_scheduled_subs()
self._last_sub_check = time.time()
# Periodic cleanup # Periodic cleanup
if time.time() - self._last_cleanup >= 3600: if time.time() - self._last_cleanup >= 3600:
await self.history.cleanup_expired() await self.history.cleanup_expired()
@ -326,8 +318,6 @@ class MeshAI:
if self.data_store: if self.data_store:
await self.data_store.stop_mqtt_sources() await self.data_store.stop_mqtt_sources()
self.data_store.close() self.data_store.close()
if self.subscription_manager:
self.subscription_manager.close()
self._remove_pid() self._remove_pid()
logger.info("MeshAI stopped") logger.info("MeshAI stopped")
@ -395,7 +385,10 @@ class MeshAI:
await self._load_summaries() await self._load_summaries()
# Transport connector (factory derives backend from config.connection.meshcore_host) # Transport connector (factory derives backend from config.connection.meshcore_host)
self.connector = build_transport(self.config.connection) self.connector = build_transport(
self.config.connection,
meshcore_context=self.config.meshcore_context,
)
# Fit every broadcast handler's one-packet formatter to the active mesh # Fit every broadcast handler's one-packet formatter to the active mesh
# transport's budget (LoRa max_chars, 140). Durable across adapter_config # transport's budget (LoRa max_chars, 140). Durable across adapter_config
@ -494,22 +487,13 @@ class MeshAI:
else: else:
self.mesh_reporter = None self.mesh_reporter = None
# Subscription manager (uses same db as data_store) # Alert engine (needs health engine and reporter)
if self.data_store: if self.health_engine and self.mesh_reporter:
from .subscriptions import SubscriptionManager
self.subscription_manager = SubscriptionManager(db_path="/data/mesh_history.db")
logger.info("Subscription manager enabled")
else:
self.subscription_manager = None
# Alert engine (needs health engine, reporter, and subscription manager)
if self.health_engine and self.mesh_reporter and self.subscription_manager:
from .alert_engine import AlertEngine from .alert_engine import AlertEngine
mi = self.config.mesh_intelligence mi = self.config.mesh_intelligence
self.alert_engine = AlertEngine( self.alert_engine = AlertEngine(
health_engine=self.health_engine, health_engine=self.health_engine,
reporter=self.mesh_reporter, reporter=self.mesh_reporter,
subscription_manager=self.subscription_manager,
config=mi, config=mi,
db_path="/data/mesh_history.db", db_path="/data/mesh_history.db",
timezone=self.config.timezone, timezone=self.config.timezone,
@ -610,9 +594,7 @@ class MeshAI:
mesh_reporter=self.mesh_reporter, mesh_reporter=self.mesh_reporter,
data_store=self.data_store, data_store=self.data_store,
health_engine=self.health_engine, health_engine=self.health_engine,
subscription_manager=self.subscription_manager,
env_store=self.env_store, env_store=self.env_store,
notification_router=self.notification_router,
) )
# Message router # Message router
@ -796,94 +778,9 @@ class MeshAI:
except Exception as e: except Exception as e:
logger.error(f"Failed to send channel alert: {e}") logger.error(f"Failed to send channel alert: {e}")
# Fallback: Send DMs to matching subscribers
if self.alert_engine and self.subscription_manager:
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
for sub in subscribers:
user_id = sub["user_id"]
try:
await self._send_sub_dm(user_id, message)
logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
except Exception as e:
logger.error(f"Failed to send alert DM to {user_id}: {e}")
if self.alert_engine: if self.alert_engine:
self.alert_engine.clear_pending() self.alert_engine.clear_pending()
async def _check_scheduled_subs(self) -> None:
"""Check for and deliver due scheduled reports."""
from datetime import datetime
from zoneinfo import ZoneInfo
tz = ZoneInfo(self.config.timezone)
now = datetime.now(tz)
current_hhmm = now.strftime("%H%M")
current_day = now.strftime("%a").lower()
due_subs = self.subscription_manager.get_due_subscriptions(current_hhmm, current_day)
for sub in due_subs:
try:
# Generate report based on scope
report = self._generate_sub_report(sub)
if not report:
continue
# Send DM to subscriber
user_id = sub["user_id"]
await self._send_sub_dm(user_id, report)
# Mark as sent
self.subscription_manager.mark_sent(sub["id"])
logger.info(f"Delivered {sub['sub_type']} report to {user_id}")
except Exception as e:
logger.error(f"Error delivering subscription {sub['id']}: {e}")
def _generate_sub_report(self, sub: dict) -> str:
"""Generate report content for a subscription."""
if not self.mesh_reporter:
return None
sub_type = sub["sub_type"]
scope_type = sub.get("scope_type", "mesh")
scope_value = sub.get("scope_value")
if scope_type == "region" and scope_value:
# Region-scoped report
region = self.mesh_reporter._find_region(scope_value)
if region:
return self.mesh_reporter.build_region_compact(region.name)
return None
elif scope_type == "node" and scope_value:
# Node-scoped report
return self.mesh_reporter.build_node_compact(scope_value)
else:
# Mesh-wide report
return self.mesh_reporter.build_lora_compact(scope="mesh")
async def _send_sub_dm(self, node_num: str, message: str) -> None:
"""Send a subscription DM to a node."""
if not self.connector:
return
# Convert node_num to destination format
try:
dest = int(node_num)
except ValueError:
dest = node_num
# Send via responder for proper chunking
if self.responder:
await self.responder.send_response(
message,
destination=dest,
channel=0, # DM channel
)
else:
# Fallback to direct send
self.connector.send_message(message, destination=dest)
def setup_logging(verbose: bool = False) -> None: def setup_logging(verbose: bool = False) -> None:
"""Configure logging.""" """Configure logging."""

View file

@ -445,6 +445,8 @@ class Dispatcher:
delivered_any = False delivered_any = False
for ch_type in ch_types: for ch_type in ch_types:
rule = None
payload = None
try: try:
rule = self._toggle_to_rule(tog, ch_type, event) rule = self._toggle_to_rule(tog, ch_type, event)
channel = self._channel_factory(rule, self._connector) channel = self._channel_factory(rule, self._connector)
@ -458,15 +460,20 @@ class Dispatcher:
if success: if success:
delivered_any = True delivered_any = True
self._logger.info(f"Dispatched event {event.id} via toggle {fam}/{ch_type}") self._logger.info(f"Dispatched event {event.id} via toggle {fam}/{ch_type}")
# v0.5.8b post-broadcast commit. Persistence-side
# bookkeeping that should only happen when a delivery
# actually went out: mesh_broadcasts_out audit row +
# handler-supplied last_broadcast_* UPDATE callback.
self._post_broadcast_commit(event, payload, rule, ch_type)
else: else:
self._logger.warning(f"Toggle channel delivery returned False for {fam}/{ch_type}") self._logger.warning(f"Toggle channel delivery returned False for {fam}/{ch_type}")
# v0.5.8b post-broadcast commit -> v20 per-mesh audit.
# Written ONCE PER MESH CHANNEL with its own transport+success,
# so a fan-out to both meshes yields two rows and a skip
# (deliver()==False) is still visible as success=0. The
# last_broadcast_* callback fires only when success is truthy.
self._post_broadcast_commit(event, payload, rule, ch_type,
success=bool(success))
except Exception: except Exception:
self._logger.exception(f"Toggle channel delivery failed for {fam}/{ch_type}") self._logger.exception(f"Toggle channel delivery failed for {fam}/{ch_type}")
# A crashed delivery is still a failed send -> success=0 row.
self._post_broadcast_commit(event, payload, rule, ch_type,
success=False)
# ---------- Section 6 — guard commit (v0.6-4, B13 fix) ---------- # ---------- Section 6 — guard commit (v0.6-4, B13 fix) ----------
# Cooldown arming + dedup recording happen ONLY after at least one # Cooldown arming + dedup recording happen ONLY after at least one
@ -600,39 +607,75 @@ class Dispatcher:
success = await channel.deliver(payload, rule) success = await channel.deliver(payload, rule)
except Exception: except Exception:
self._logger.exception( self._logger.exception(
"scheduled-broadcast: delivery raised for %s; skipping", ch_type) "scheduled-broadcast: delivery raised for %s", ch_type)
continue success = False
if success: if success:
delivered_any = True delivered_any = True
# Audit row -- mirrors _post_broadcast_commit for scheduled.
try: # v20 per-mesh audit row. Written once per mesh channel with its
from meshai.persistence import get_db # own transport+success, so a fan-out to both meshes yields two
conn = get_db() # rows and a skip (deliver()==False) is visible as success=0.
bytes_sent = len(text.encode("utf-8")) if text else 0 try:
conn.execute( from meshai.persistence import get_db
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, " conn = get_db()
"channel, text, source_event_table, source_event_pk, " bytes_sent = len(text.encode("utf-8")) if text else 0
"bytes_sent, ack_received) VALUES (?,?,?,?,?,?,?,?)", transport, channel_id, recipient = self._audit_route(rule, ch_type)
(int(time.time()), "broadcast", conn.execute(
rf.broadcast_channel, text, "INSERT INTO mesh_broadcasts_out(sent_at, recipient, "
source_event_table, str(source_event_pk), "channel, text, source_event_table, source_event_pk, "
bytes_sent, 0), "bytes_sent, ack_received, transport, success) "
) "VALUES (?,?,?,?,?,?,?,?,?,?)",
except Exception: (int(time.time()), recipient,
self._logger.exception( channel_id, text,
"scheduled-broadcast: audit row insert failed for %s", ch_type) source_event_table, str(source_event_pk),
bytes_sent, 0,
transport, 1 if success else 0),
)
except Exception:
self._logger.exception(
"scheduled-broadcast: audit row insert failed for %s", ch_type)
return delivered_any return delivered_any
def _post_broadcast_commit(self, event, payload, rule, ch_type: str) -> None: @staticmethod
"""Persistence side-effects of an actually-successful broadcast. def _audit_route(rule, ch_type: str):
"""Resolve (transport, channel_id, recipient) for a mesh delivery.
Inserts the mesh_broadcasts_out audit row when the handler signalled transport is the mesh family the row belongs to ("meshtastic" /
it wants one via `event.data["_broadcast_audit"]`, then invokes the "meshcore"); channel_id is the Meshtastic channel INDEX or the
handler-supplied `_on_broadcast_committed` callback so the handler MeshCore channel NAME; recipient is 'broadcast' or the DM target
can refresh its own last_broadcast_* bookkeeping. Both calls are list. Mirrors create_channel()'s delivery_type routing.
wrapped: a bookkeeping failure must NOT undo the actual broadcast """
nor break dispatch for sibling toggles. if ch_type == "mesh_broadcast":
return "meshtastic", getattr(rule, "broadcast_channel", None), "broadcast"
if ch_type == "meshcore_broadcast":
return "meshcore", getattr(rule, "meshcore_channel", None), "broadcast"
if ch_type == "mesh_dm":
node_ids = list(getattr(rule, "node_ids", []) or [])
return "meshtastic", None, (",".join(map(str, node_ids)) or "dm")
if ch_type == "meshcore_dm":
contacts = list(getattr(rule, "meshcore_dm_contacts", []) or [])
return "meshcore", None, (",".join(map(str, contacts)) or "meshcore_dm")
# Unknown / non-mesh: leave transport NULL, fall back to legacy channel.
return None, getattr(rule, "broadcast_channel", None), "broadcast"
def _post_broadcast_commit(self, event, payload, rule, ch_type: str,
*, success: bool = True) -> None:
"""Persistence side-effects of a per-mesh broadcast delivery.
Called ONCE PER MESH CHANNEL (one per delivery_type family), so a
broadcast that fans to both meshes writes TWO mesh_broadcasts_out
rows -- each carrying its own `transport` + `success` flag. The row
is written whenever the handler signalled it wants an audit trail
via `event.data["_broadcast_audit"]`, REGARDLESS of success, so a
skip/failure (e.g. MeshCore channel-not-found -> deliver()==False)
is still visible as success=0.
The handler-supplied `_on_broadcast_committed` callback (which
refreshes last_broadcast_* bookkeeping) fires ONLY when the send
actually landed (success is truthy). Both calls are wrapped: a
bookkeeping failure must NOT undo the actual broadcast nor break
dispatch for sibling toggles.
""" """
data = getattr(event, "data", None) or {} data = getattr(event, "data", None) or {}
if not data: if not data:
@ -646,23 +689,17 @@ class Dispatcher:
conn = get_db() conn = get_db()
text = payload.message if payload is not None else (event.title or "") text = payload.message if payload is not None else (event.title or "")
bytes_sent = len(text.encode("utf-8")) if text else 0 bytes_sent = len(text.encode("utf-8")) if text else 0
if ch_type == "mesh_dm": transport, channel, recipient = self._audit_route(rule, ch_type)
node_ids = list(getattr(rule, "node_ids", []) or [])
recipient = ",".join(map(str, node_ids)) or "dm"
elif ch_type == "meshcore_dm":
contacts = list(getattr(rule, "meshcore_dm_contacts", []) or [])
recipient = ",".join(map(str, contacts)) or "meshcore_dm"
else:
recipient = "broadcast"
channel = getattr(rule, "broadcast_channel", None)
conn.execute( conn.execute(
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, channel, " "INSERT INTO mesh_broadcasts_out(sent_at, recipient, channel, "
"text, source_event_table, source_event_pk, bytes_sent, " "text, source_event_table, source_event_pk, bytes_sent, "
"ack_received) VALUES (?,?,?,?,?,?,?,?)", "ack_received, transport, success) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
( (
int(committed_at), recipient, channel, text, int(committed_at), recipient, channel, text,
audit.get("table"), audit.get("pk"), audit.get("table"), audit.get("pk"),
bytes_sent, 0, bytes_sent, 0,
transport, 1 if success else 0,
), ),
) )
except Exception: except Exception:
@ -672,6 +709,11 @@ class Dispatcher:
audit.get("table"), audit.get("pk"), audit.get("table"), audit.get("pk"),
) )
if not success:
# A failed/skipped send is audited above but must NOT arm the
# handler's last_broadcast_* bookkeeping.
return
cb = data.get("_on_broadcast_committed") cb = data.get("_on_broadcast_committed")
if callable(cb): if callable(cb):
try: try:

View file

@ -735,45 +735,6 @@ class NotificationRouter:
return {"matches": False, "conditions": [], "preview": "Unknown rule type"} return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
def add_mesh_subscription(self, node_id: str, categories: list[str], rule_name: Optional[str] = None) -> str:
"""Add a mesh DM subscription for a node."""
if not rule_name:
rule_name = "sub_%s" % node_id
for rule in self._rules:
if rule.get("name") == rule_name:
rule["categories"] = categories if categories else []
rule["node_ids"] = [node_id]
return rule_name
self._rules.append({
"name": rule_name,
"enabled": True,
"trigger_type": "condition",
"categories": categories if categories else [],
"min_severity": "priority",
"delivery_type": "mesh_dm",
"node_ids": [node_id],
"cooldown_minutes": 10,
})
return rule_name
def remove_mesh_subscription(self, node_id: str) -> bool:
"""Remove a mesh subscription for a node."""
rule_name = "sub_%s" % node_id
self._rules = [r for r in self._rules if r.get("name") != rule_name]
return True
def get_node_subscriptions(self, node_id: str) -> list[str]:
"""Get categories a node is subscribed to."""
rule_name = "sub_%s" % node_id
for rule in self._rules:
if rule.get("name") == rule_name:
categories = rule.get("categories", [])
return categories if categories else ["all"]
return []
async def generate_report(self, report_type: str, env_store, health_engine) -> str: async def generate_report(self, report_type: str, env_store, health_engine) -> str:
"""Generate an LLM-summarized report from current data.""" """Generate an LLM-summarized report from current data."""
context_parts = [] context_parts = []

View file

@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
DEFAULT_DB_PATH = "/data/meshai.sqlite" DEFAULT_DB_PATH = "/data/meshai.sqlite"
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH" MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
SCHEMA_VERSION = 19 SCHEMA_VERSION = 20
SCHEMA_META_TABLE = "schema_meta" SCHEMA_META_TABLE = "schema_meta"
MIGRATIONS_DIR = Path(__file__).parent / "migrations" MIGRATIONS_DIR = Path(__file__).parent / "migrations"

View file

@ -0,0 +1,9 @@
-- v20: per-mesh broadcast audit. transport + success columns on
-- mesh_broadcasts_out so each SEND records which mesh it went to and
-- whether it landed. A broadcast fanning to BOTH meshes writes one row
-- per mesh, each with its own success flag.
-- Nullable, no index, no backfill. Legacy rows keep NULL transport/success
-- (Activity Log treats NULL as legacy/meshtastic-unknown).
ALTER TABLE mesh_broadcasts_out ADD COLUMN transport TEXT;
ALTER TABLE mesh_broadcasts_out ADD COLUMN success INTEGER;

View file

@ -1,278 +0,0 @@
"""Subscription management for scheduled reports and alerts."""
import logging
import sqlite3
import time
from typing import Optional
logger = logging.getLogger(__name__)
# Valid subscription types
VALID_SUB_TYPES = {"daily", "weekly", "alerts"}
VALID_DAYS = {"mon", "tue", "wed", "thu", "fri", "sat", "sun"}
VALID_SCOPE_TYPES = {"mesh", "region", "node"}
class SubscriptionManager:
"""Manages user subscriptions with SQLite storage."""
def __init__(self, db_path: str):
"""Initialize subscription manager.
Args:
db_path: Path to SQLite database (same as mesh_history.db)
"""
self._db_path = db_path
self._db: Optional[sqlite3.Connection] = None
self._init_db()
def _init_db(self):
"""Initialize database connection and schema."""
self._db = sqlite3.connect(self._db_path, check_same_thread=False)
self._db.row_factory = sqlite3.Row
self._db.executescript("""
CREATE TABLE IF NOT EXISTS subscriptions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
sub_type TEXT NOT NULL,
schedule_time TEXT,
schedule_day TEXT,
scope_type TEXT DEFAULT 'mesh',
scope_value TEXT,
created_at REAL NOT NULL,
last_sent REAL DEFAULT 0,
enabled INTEGER DEFAULT 1
);
CREATE INDEX IF NOT EXISTS idx_sub_user ON subscriptions(user_id);
CREATE INDEX IF NOT EXISTS idx_sub_type ON subscriptions(sub_type);
""")
self._db.commit()
logger.info("Subscription manager initialized")
def _row_to_dict(self, row: sqlite3.Row) -> dict:
"""Convert sqlite Row to dict."""
return dict(row)
def add(self, user_id: str, sub_type: str, schedule_time: str = None,
schedule_day: str = None, scope_type: str = "mesh",
scope_value: str = None) -> dict:
"""Add a subscription.
Args:
user_id: Subscriber node_num
sub_type: "daily", "weekly", or "alerts"
schedule_time: HHMM format (required for daily/weekly)
schedule_day: mon-sun (required for weekly)
scope_type: "mesh", "region", or "node"
scope_value: Region name or node identifier
Returns:
Created subscription dict
Raises:
ValueError: If validation fails
"""
# Validate sub_type
if sub_type not in VALID_SUB_TYPES:
raise ValueError(f"Invalid type '{sub_type}'. Use: daily, weekly, or alerts")
# Validate schedule_time for daily/weekly
if sub_type in ("daily", "weekly"):
if not schedule_time:
raise ValueError(f"Time required for {sub_type} subscription. Use HHMM format (e.g., 1830)")
if not self._validate_time(schedule_time):
raise ValueError("Invalid time format. Use HHMM (e.g., 1830 for 6:30 PM)")
# Validate schedule_day for weekly
if sub_type == "weekly":
if not schedule_day:
raise ValueError("Day required for weekly subscription. Use: mon, tue, wed, thu, fri, sat, sun")
if schedule_day.lower() not in VALID_DAYS:
raise ValueError("Invalid day. Use: mon, tue, wed, thu, fri, sat, sun")
schedule_day = schedule_day.lower()
# Validate scope_type
if scope_type not in VALID_SCOPE_TYPES:
raise ValueError(f"Invalid scope '{scope_type}'. Use: mesh, region, or node")
# Check for duplicates
existing = self._db.execute("""
SELECT id FROM subscriptions
WHERE user_id = ? AND sub_type = ? AND scope_type = ?
AND (scope_value = ? OR (scope_value IS NULL AND ? IS NULL))
AND enabled = 1
""", (user_id, sub_type, scope_type, scope_value, scope_value)).fetchone()
if existing:
scope_desc = f" for {scope_type} {scope_value}" if scope_value else ""
raise ValueError(f"Already subscribed to {sub_type}{scope_desc}")
# Insert subscription
now = time.time()
cursor = self._db.execute("""
INSERT INTO subscriptions (user_id, sub_type, schedule_time, schedule_day,
scope_type, scope_value, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?)
""", (user_id, sub_type, schedule_time, schedule_day, scope_type, scope_value, now))
self._db.commit()
sub_id = cursor.lastrowid
return self._get_by_id(sub_id)
def _validate_time(self, time_str: str) -> bool:
"""Validate HHMM time format."""
if not time_str or len(time_str) != 4 or not time_str.isdigit():
return False
hours = int(time_str[:2])
minutes = int(time_str[2:])
return 0 <= hours <= 23 and 0 <= minutes <= 59
def _get_by_id(self, sub_id: int) -> dict:
"""Get subscription by ID."""
row = self._db.execute(
"SELECT * FROM subscriptions WHERE id = ?", (sub_id,)
).fetchone()
return self._row_to_dict(row) if row else None
def remove(self, user_id: str, sub_type: str = None) -> int:
"""Remove subscription(s).
Args:
user_id: Subscriber node_num
sub_type: "daily", "weekly", "alerts", or None for all
Returns:
Number of subscriptions removed
"""
if sub_type and sub_type != "all":
cursor = self._db.execute(
"DELETE FROM subscriptions WHERE user_id = ? AND sub_type = ?",
(user_id, sub_type)
)
else:
cursor = self._db.execute(
"DELETE FROM subscriptions WHERE user_id = ?",
(user_id,)
)
self._db.commit()
return cursor.rowcount
def get_user_subs(self, user_id: str) -> list[dict]:
"""Get all subscriptions for a user."""
rows = self._db.execute(
"SELECT * FROM subscriptions WHERE user_id = ? AND enabled = 1 ORDER BY created_at",
(user_id,)
).fetchall()
return [self._row_to_dict(r) for r in rows]
def get_due_subscriptions(self, current_time_hhmm: str, current_day: str) -> list[dict]:
"""Get subscriptions that should fire right now.
Args:
current_time_hhmm: Current time as "HHMM" (e.g., "1830")
current_day: Current day as 3-letter lowercase (e.g., "sun")
Returns:
List of subscription dicts that are due
"""
now = time.time()
due = []
# Get all daily/weekly subscriptions
rows = self._db.execute("""
SELECT * FROM subscriptions
WHERE sub_type IN ('daily', 'weekly') AND enabled = 1
""").fetchall()
current_minutes = int(current_time_hhmm[:2]) * 60 + int(current_time_hhmm[2:])
for row in rows:
sub = self._row_to_dict(row)
schedule_time = sub.get("schedule_time")
if not schedule_time:
continue
schedule_minutes = int(schedule_time[:2]) * 60 + int(schedule_time[2:])
# 5-minute matching window
if abs(schedule_minutes - current_minutes) > 5:
continue
sub_type = sub["sub_type"]
last_sent = sub.get("last_sent", 0) or 0
if sub_type == "daily":
# Don't fire if sent within last 23 hours
if now - last_sent < 23 * 3600:
continue
due.append(sub)
elif sub_type == "weekly":
# Check day matches
schedule_day = sub.get("schedule_day", "").lower()
if schedule_day != current_day.lower():
continue
# Don't fire if sent within last 6 days
if now - last_sent < 6 * 24 * 3600:
continue
due.append(sub)
return due
def get_alert_subscribers(self, scope_type: str = None, scope_value: str = None) -> list[dict]:
"""Get users subscribed to alerts matching a scope.
Args:
scope_type: "mesh", "region", or "node"
scope_value: Region name or node identifier
Returns:
List of subscription dicts where scope matches
"""
# Get all alert subscriptions
rows = self._db.execute("""
SELECT * FROM subscriptions
WHERE sub_type = 'alerts' AND enabled = 1
""").fetchall()
matching = []
for row in rows:
sub = self._row_to_dict(row)
sub_scope = sub.get("scope_type", "mesh")
sub_value = sub.get("scope_value")
# Mesh scope gets ALL alerts
if sub_scope == "mesh":
matching.append(sub)
# Region scope gets alerts for that region
elif sub_scope == "region" and scope_type == "region":
if sub_value and scope_value and sub_value.lower() == scope_value.lower():
matching.append(sub)
# Node scope gets alerts for that node
elif sub_scope == "node" and scope_type == "node":
if sub_value and scope_value and sub_value.lower() == scope_value.lower():
matching.append(sub)
return matching
def mark_sent(self, subscription_id: int):
"""Update last_sent timestamp to now."""
self._db.execute(
"UPDATE subscriptions SET last_sent = ? WHERE id = ?",
(time.time(), subscription_id)
)
self._db.commit()
def get_all_subs(self) -> list[dict]:
"""Get all subscriptions (for admin view)."""
rows = self._db.execute(
"SELECT * FROM subscriptions WHERE enabled = 1 ORDER BY user_id, created_at"
).fetchall()
return [self._row_to_dict(r) for r in rows]
def close(self):
"""Close database connection."""
if self._db:
self._db.close()
self._db = None

View file

@ -3,7 +3,7 @@
from .base import MeshTransport from .base import MeshTransport
def build_transport(config) -> MeshTransport: def build_transport(config, meshcore_context=None) -> MeshTransport:
"""Instantiate and return the active MeshTransport derived from config. """Instantiate and return the active MeshTransport derived from config.
The active transports are derived from the connection config, not a The active transports are derived from the connection config, not a
@ -16,6 +16,8 @@ def build_transport(config) -> MeshTransport:
Args: Args:
config: A ConnectionConfig (or duck-compatible object). config: A ConnectionConfig (or duck-compatible object).
meshcore_context: Optional MeshCoreContextConfig for the MeshCore
passive-context / bot-behavior filter (None = pass-through).
Returns: Returns:
A concrete MeshTransport instance ready to be connected. A concrete MeshTransport instance ready to be connected.
@ -27,6 +29,9 @@ def build_transport(config) -> MeshTransport:
if meshcore_host.strip(): if meshcore_host.strip():
from meshai.transport.meshcore_transport import MeshCoreTransport from meshai.transport.meshcore_transport import MeshCoreTransport
from meshai.transport.composite_transport import CompositeTransport from meshai.transport.composite_transport import CompositeTransport
return CompositeTransport([meshtastic, MeshCoreTransport(config)], config=config) return CompositeTransport(
[meshtastic, MeshCoreTransport(config, meshcore_context=meshcore_context)],
config=config,
)
return meshtastic return meshtastic

View file

@ -24,6 +24,30 @@ logger = logging.getLogger(__name__)
_COMMAND_TIMEOUT = 10.0 _COMMAND_TIMEOUT = 10.0
def mc_context_allows(cfg, msg, idx_to_name):
"""Return True if a MeshCore inbound MeshMessage should be forwarded.
cfg: MeshCoreContextConfig or None. idx_to_name: dict[int,str] channel-idx->name.
"""
if cfg is None:
return True
if msg.is_dm:
if not cfg.respond_to_dms:
return False
# ignore_contacts matches the pubkey prefix (sender_id) OR the contact name (sender_name)
if msg.sender_id in cfg.ignore_contacts or msg.sender_name in cfg.ignore_contacts:
return False
return True
# channel (non-DM) message -> only relevant for passive context
if not cfg.enable_passive_context:
return False
if cfg.observe_channels:
name = idx_to_name.get(msg.channel)
if name is None or name not in cfg.observe_channels:
return False
return True
class MeshCoreTransport(MeshTransport): class MeshCoreTransport(MeshTransport):
"""MeshTransport implementation over a pyMC companion TCP frame server. """MeshTransport implementation over a pyMC companion TCP frame server.
@ -41,8 +65,12 @@ class MeshCoreTransport(MeshTransport):
# Name tag used by CompositeTransport for routing hints. # Name tag used by CompositeTransport for routing hints.
transport_name: str = "meshcore" transport_name: str = "meshcore"
def __init__(self, config) -> None: def __init__(self, config, meshcore_context=None) -> None:
self.config = config self.config = config
# MeshCore passive-context / bot-behavior filter (MeshCoreContextConfig
# or None). None = pass-through (no filtering). Injected at construction
# time by the factory; can also be (re)set via set_context_config().
self._mc_context = meshcore_context
self._mc = None # meshcore.MeshCore instance self._mc = None # meshcore.MeshCore instance
self._loop: Optional[asyncio.AbstractEventLoop] = None self._loop: Optional[asyncio.AbstractEventLoop] = None
self._loop_thread: Optional[threading.Thread] = None self._loop_thread: Optional[threading.Thread] = None
@ -142,6 +170,13 @@ class MeshCoreTransport(MeshTransport):
"""Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect).""" """Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect)."""
return list(self._chan_name_to_idx.keys()) return list(self._chan_name_to_idx.keys())
def set_context_config(self, cfg) -> None:
"""Set (or clear) the MeshCore passive-context filter config.
cfg: MeshCoreContextConfig or None (None = pass-through).
"""
self._mc_context = cfg
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Internal coroutines (run on the dedicated loop) # Internal coroutines (run on the dedicated loop)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -416,13 +451,21 @@ class MeshCoreTransport(MeshTransport):
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _on_dm_event(self, event) -> None: def _on_dm_event(self, event) -> None:
"""Handle CONTACT_MSG_RECV: normalize and dispatch to meshai.""" """Handle CONTACT_MSG_RECV: normalize, filter, and dispatch to meshai."""
msg = self._normalize_dm_event(event) msg = self._normalize_dm_event(event)
if msg is None or not mc_context_allows(
self._mc_context, msg, {v: k for k, v in self._chan_name_to_idx.items()}
):
return
self._dispatch_message(msg) self._dispatch_message(msg)
def _on_channel_event(self, event) -> None: def _on_channel_event(self, event) -> None:
"""Handle CHANNEL_MSG_RECV: normalize and dispatch to meshai.""" """Handle CHANNEL_MSG_RECV: normalize, filter, and dispatch to meshai."""
msg = self._normalize_channel_event(event) msg = self._normalize_channel_event(event)
if msg is None or not mc_context_allows(
self._mc_context, msg, {v: k for k, v in self._chan_name_to_idx.items()}
):
return
self._dispatch_message(msg) self._dispatch_message(msg)
def _dispatch_message(self, msg: Optional[MeshMessage]) -> None: def _dispatch_message(self, msg: Optional[MeshMessage]) -> None:

View file

@ -0,0 +1,50 @@
"""Config round-trip tests for the MeshCore passive-context block.
Verifies that ``meshcore_context`` survives save_config -> load_config, and
that a YAML lacking the section yields the dataclass defaults (proving the
generic nested-dataclass loader branch handles it with no special-casing).
"""
import yaml
from meshai.config import (
Config,
MeshCoreContextConfig,
load_config,
save_config,
)
def test_meshcore_context_round_trip(tmp_path):
cfg = Config()
cfg.meshcore_context = MeshCoreContextConfig(
enable_passive_context=False,
observe_channels=["#aida", "#general"],
ignore_contacts=["a1b2", "SpamNode"],
respond_to_dms=False,
)
path = tmp_path / "config.yaml"
save_config(cfg, path)
loaded = load_config(path)
mc = loaded.meshcore_context
assert isinstance(mc, MeshCoreContextConfig)
assert mc.enable_passive_context is False
assert mc.observe_channels == ["#aida", "#general"]
assert mc.ignore_contacts == ["a1b2", "SpamNode"]
assert mc.respond_to_dms is False
def test_meshcore_context_defaults_when_absent(tmp_path):
# A minimal YAML with no meshcore_context section at all.
path = tmp_path / "config.yaml"
path.write_text(yaml.safe_dump({"timezone": "America/Boise"}))
loaded = load_config(path)
mc = loaded.meshcore_context
assert isinstance(mc, MeshCoreContextConfig)
assert mc.enable_passive_context is True
assert mc.observe_channels == []
assert mc.ignore_contacts == []
assert mc.respond_to_dms is True

View file

@ -0,0 +1,85 @@
"""Unit tests for the MeshCore passive-context / bot-behavior filter.
Covers the pure module-level helper ``mc_context_allows`` no hardware,
no meshcore lib, no event loop. MeshMessage and MeshCoreContextConfig are
constructed directly.
"""
from meshai.config import MeshCoreContextConfig
from meshai.connector import MeshMessage
from meshai.transport.meshcore_transport import mc_context_allows
def _dm(sender_id="a1b2", sender_name="Alice", text="hi"):
return MeshMessage(
sender_id=sender_id,
sender_name=sender_name,
text=text,
channel=0,
is_dm=True,
transport="meshcore",
)
def _chan(channel=1, text="hello", sender_id=None, sender_name=None):
marker = f"chan:{channel}"
return MeshMessage(
sender_id=sender_id or marker,
sender_name=sender_name or marker,
text=text,
channel=channel,
is_dm=False,
transport="meshcore",
)
def test_cfg_none_always_passes():
assert mc_context_allows(None, _dm(), {}) is True
assert mc_context_allows(None, _chan(), {}) is True
def test_defaults_pass_through():
cfg = MeshCoreContextConfig() # empty lists, passive on, respond_to_dms on
idx_to_name = {1: "#general"}
assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is True
assert mc_context_allows(cfg, _dm(), idx_to_name) is True
def test_observe_channels_filters_by_name():
cfg = MeshCoreContextConfig(observe_channels=["#aida"])
idx_to_name = {1: "#general", 2: "#aida"}
# idx 1 -> #general -> not in observe list -> DROPPED
assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is False
# idx 2 -> #aida -> in observe list -> PASSES
assert mc_context_allows(cfg, _chan(channel=2), idx_to_name) is True
# idx 3 -> no name mapping -> DROPPED
assert mc_context_allows(cfg, _chan(channel=3), idx_to_name) is False
def test_ignore_contacts_matches_id_or_name():
cfg = MeshCoreContextConfig(ignore_contacts=["a1b2"])
# matched by sender_id
assert mc_context_allows(cfg, _dm(sender_id="a1b2", sender_name="Alice"), {}) is False
# matched by sender_name
assert mc_context_allows(cfg, _dm(sender_id="ffff", sender_name="a1b2"), {}) is False
# unrelated DM passes
assert mc_context_allows(cfg, _dm(sender_id="c3d4", sender_name="Bob"), {}) is True
def test_respond_to_dms_false_drops_dms_only():
cfg = MeshCoreContextConfig(respond_to_dms=False)
idx_to_name = {1: "#general"}
# any DM dropped
assert mc_context_allows(cfg, _dm(), idx_to_name) is False
assert mc_context_allows(cfg, _dm(sender_id="zzzz", sender_name="Zed"), idx_to_name) is False
# channel msgs unaffected (passive still on)
assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is True
def test_passive_disabled_drops_channel_but_dm_still_respected():
cfg = MeshCoreContextConfig(enable_passive_context=False)
idx_to_name = {1: "#general"}
# channel msg dropped
assert mc_context_allows(cfg, _chan(channel=1), idx_to_name) is False
# DM still respects respond_to_dms (True by default) -> passes
assert mc_context_allows(cfg, _dm(), idx_to_name) is True