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 Environment from './pages/Environment'
import Config from './pages/Config'
import Alerts from './pages/Alerts'
import ActivityLog from './pages/ActivityLog'
import Notifications from './pages/Notifications'
import Reference from './pages/Reference'
import AdapterConfig from './pages/AdapterConfig'
@ -12,10 +12,15 @@ import GaugeSites from './pages/GaugeSites'
import TownAnchors from './pages/TownAnchors'
import MeshCoreRouting from './pages/MeshCoreRouting'
import MeshCoreConnection from './pages/MeshCoreConnection'
import MeshCoreContacts from './pages/MeshCoreContacts'
import MeshCoreCompanion from './pages/MeshCoreCompanion'
import MeshtasticConnection from './pages/MeshtasticConnection'
import MeshtasticSources from './pages/MeshtasticSources'
import 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 { DirtyProvider } from './context/DirtyContext'
@ -25,22 +30,38 @@ function App() {
<ToastProvider>
<Layout>
<Routes>
{/* Core routes */}
<Route path="/" element={<Dashboard />} />
<Route path="/mesh" element={<Mesh />} />
<Route path="/environment" element={<Environment />} />
<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="/reference" element={<Reference />} />
<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="/town-anchors" element={<TownAnchors />} />
<Route path="/meshcore/routing" element={<MeshCoreRouting />} />
<Route path="/meshcore/connection" element={<MeshCoreConnection />} />
<Route path="/meshcore/contacts" element={<MeshCoreContacts />} />
<Route path="/meshcore/companion" element={<MeshCoreCompanion />} />
<Route path="/mesh" element={<Mesh />} />
{/* Meshtastic routes */}
<Route path="/meshtastic/connection" element={<MeshtasticConnection />} />
<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>
</Layout>
</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,
Radio,
Cloud,
Bell,
BellRing,
BookOpen,
Sliders,
@ -17,6 +16,9 @@ import {
Users,
Bot,
Settings,
Calendar,
Activity,
AlertTriangle,
type LucideIcon,
} from 'lucide-react'
import { fetchStatus, type SystemStatus } from '@/lib/api'
@ -39,28 +41,26 @@ interface NavGroup {
items: NavItem[]
}
// Top-level, ungrouped items (no header).
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).
// All nav items are grouped — no ungrouped topNavItems.
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',
items: [
{ path: '/meshtastic/connection', label: 'Connection', icon: Wifi },
{ path: '/notifications', label: 'Routing', icon: BellRing },
{ path: '/mesh', label: 'Mesh', icon: Radio },
{ path: '/meshtastic/sources', label: 'Sources', icon: Layers },
{ path: '/meshtastic/scheduled', label: 'Scheduled Broadcasts', icon: Calendar },
{ 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: [
{ path: '/meshcore/connection', label: 'Connection', icon: Network },
{ path: '/meshcore/routing', label: 'Routing', icon: BellRing },
{ path: '/meshcore/contacts', label: 'Contacts', icon: Users },
{ path: '/meshcore/companion', label: 'Companion', icon: Bot },
{ path: '/meshcore/scheduled', label: 'Scheduled Broadcasts', icon: Calendar },
{ 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.
const allNavItems: NavItem[] = [
...topNavItems,
...navGroups.flatMap((g) => g.items),
// Flattened view of every nav item for title lookup.
// Also include de-navved routes so they still resolve a title.
const extraTitleItems: NavItem[] = [
{ 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 {
const days = Math.floor(seconds / 86400)
const hours = Math.floor((seconds % 86400) / 3600)
@ -125,11 +154,13 @@ function renderNavItem(
}
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)
if (exact) return exact.label
// Fallback: match by pathname only, ignoring query strings.
const base = fullPath.split('?')[0]
const byPath = allNavItems.find((i) => i.path.split('?')[0] === base)
return byPath?.label || 'Dashboard'
}
@ -203,7 +234,6 @@ export default function Layout({ children }: LayoutProps) {
{/* Navigation */}
<nav className="flex-1 py-4">
{topNavItems.map((item) => renderNavItem(item, location.pathname, location.search, handleNavClick))}
{navGroups.map((group) => (
<div key={group.header} className="mt-4">
<div className="px-5 pt-2 pb-1 text-[10px] font-sans font-semibold uppercase tracking-wider text-[#555]">

View file

@ -111,15 +111,18 @@ export interface AlertHistoryResponse {
total: number
}
export interface Subscription {
export interface ActivityEntry {
id: number
user_id: string
sub_type: string
schedule_time?: string
schedule_day?: string
scope_type: string
scope_value?: string
enabled: boolean
sent_at: number | string | null // epoch seconds (int) on new rows
recipient: string | null
channel: string | number | null
text: string | null
source_event_table: string | null
source_event_pk: string | number | null
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 {
@ -294,8 +297,8 @@ export async function fetchAlertHistory(
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
}
export async function fetchSubscriptions(): Promise<Subscription[]> {
return fetchJson<Subscription[]>('/api/subscriptions')
export async function fetchActivity(limit = 100): Promise<ActivityEntry[]> {
return fetchJson<ActivityEntry[]>(`/api/activity?limit=${limit}`)
}
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).
// Cache invalidation is server-side -- every PUT triggers it. The handler
// 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 {
@ -14,6 +18,20 @@ import {
Sliders,
} 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 {
adapter: string
key: string
@ -38,7 +56,14 @@ type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
// Brief animation after a successful save.
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 [meta, setMeta] = useState<MetaMap>({})
const [loading, setLoading] = useState(true)
@ -183,7 +208,7 @@ export default function AdapterConfig() {
<Sliders className="w-5 h-5" />
<h1 className="text-xl font-semibold">Adapter Config</h1>
<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>
</div>
<p className="text-xs text-[#777] max-w-3xl">
@ -198,7 +223,10 @@ export default function AdapterConfig() {
include_in_llm_context: true,
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 metaId = `meta:${adapter}`
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>
<code className="text-xs text-[#666]">{adapter}</code>
{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 && (
<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>
{m.description && (
@ -231,7 +259,8 @@ export default function AdapterConfig() {
)}
</div>
{/* include_in_llm_context toggle */}
{/* include_in_llm_context toggle — hidden when surfaced in curated panels */}
{!hideLlmToggle && (
<label className="flex items-center gap-2 text-xs text-[#e0e0e0] select-none">
<input
type="checkbox"
@ -242,6 +271,7 @@ export default function AdapterConfig() {
LLM context
<SaveBadge status={metaStatus} error={saveError[metaId]} />
</label>
)}
</div>
{/* 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
}
interface MeshIntelligenceConfig {
export interface MeshIntelligenceConfig {
enabled: boolean
regions: RegionAnchor[]
locality_radius_miles: number
@ -267,9 +267,6 @@ const AVAILABLE_COMMANDS = [
{ name: 'ping', description: 'Test bot responsiveness' },
{ name: 'clear', description: 'Clear your conversation history' },
{ 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: 'solar', description: 'Space weather and HF propagation conditions' },
{ name: 'hf', description: 'HF radio propagation (alias for !solar)' },
@ -916,21 +913,9 @@ function ContextSection({ data, onChange }: { data: ContextConfig; onChange: (d:
/>
{data.enabled && (
<>
<ChannelPicker
label="Observe Channels"
value={data.observe_channels}
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."
/>
{/* Observe Channels + Ignore Nodes moved to the Meshtastic Connection
page ("Bot behavior" section). max_age / max_context_items remain
here as general context knobs. */}
<div className="grid grid-cols-2 gap-4">
<NumberInput
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)
return (

View file

@ -1,7 +1,8 @@
import { useEffect, useState, type ReactNode } from 'react'
import {
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'
import {
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
@ -11,6 +12,8 @@ import {
fetchEnvStatus, fetchEnvActive,
type EnvStatus, type EnvEvent,
} from '@/lib/api'
import { TOGGLE_FAMILY_META, type NotificationToggle, type NotificationsConfig } from './Notifications'
import AdapterConfig, { CURATED_KEYS } from './AdapterConfig'
type FeedSource = 'native' | 'central'
@ -170,12 +173,16 @@ function FeedSourceToggle({ value, onChange, disabled, centralDisabled }: {
}
// ---------------------------------------------------------------- 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
enabled: boolean; onEnabled: (v: boolean) => void
feedSource: FeedSource; onFeedSource: (v: FeedSource) => void
hasCentral: boolean; nativeOnly: boolean; hasKey: boolean
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
return (
@ -186,6 +193,17 @@ function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedS
{subtitle && <p className="text-xs text-[#666]">{subtitle}</p>}
</div>
<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">
<span className="text-[10px] uppercase tracking-wide text-[#666]">source</span>
<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: 'tracking', label: 'Tracking', icon: Satellite, adapters: ['satpass'] },
{ key: 'mesh', label: 'Mesh Health', icon: Activity, adapters: [] },
{ key: 'family_settings', label: 'Family Settings', icon: Bell, adapters: [] },
]
// ---------------------------------------------------------------- main page
@ -264,6 +283,12 @@ export default function Environment() {
const [family, setFamily] = useState('weather')
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
const [wfigsConfig, setWfigsConfig] = useState<WfigsConfig>({
allowed_incident_types: ['WF'],
@ -322,6 +347,13 @@ export default function Environment() {
})
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(() => {
document.title = 'Environment — MeshAI'
@ -332,125 +364,151 @@ export default function Environment() {
setEnv(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 {
const wfigsRes = await fetch("/api/adapter-config/wfigs")
if (wfigsRes.ok) {
const wfigsData = await wfigsRes.json()
const wfigsData = toMap(await wfigsRes.json())
const cfg: WfigsConfig = {
allowed_incident_types: wfigsData.allowed_incident_types?.value ?? ['WF'],
freshness_seconds: wfigsData.freshness_seconds?.value ?? 0,
cooldown_seconds: wfigsData.cooldown_seconds?.value ?? 28800,
broadcast_on_acres: wfigsData.broadcast_on_acres?.value ?? true,
broadcast_on_contained: wfigsData.broadcast_on_contained?.value ?? true,
allowed_incident_types: (wfigsData.allowed_incident_types?.value as string[]) ?? ['WF'],
freshness_seconds: (wfigsData.freshness_seconds?.value as number) ?? 0,
cooldown_seconds: (wfigsData.cooldown_seconds?.value as number) ?? 28800,
broadcast_on_acres: (wfigsData.broadcast_on_acres?.value as boolean) ?? true,
broadcast_on_contained: (wfigsData.broadcast_on_contained?.value as boolean) ?? true,
}
setWfigsConfig(cfg)
setWfigsOriginal(JSON.stringify(cfg))
}
} catch { /* adapter-config optional */ }
// Load adapter-config for fires (digest settings)
// Load adapter-config for fires/digest (array → object fix: line ~367)
try {
const firesRes = await fetch("/api/adapter-config/fires")
if (firesRes.ok) {
const firesData = await firesRes.json()
const firesData = toMap(await firesRes.json())
const cfg: 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",
digest_enabled: (firesData.digest_enabled?.value as boolean) ?? true,
digest_schedule: (firesData.digest_schedule?.value as string[]) ?? ["06:00", "18:00"],
digest_timezone: (firesData.digest_timezone?.value as string) ?? "America/Boise",
}
setFiresConfig(cfg)
setFiresOriginal(JSON.stringify(cfg))
}
} catch { /* adapter-config optional */ }
// Load adapter-config for tomtom_incidents
// Load adapter-config for tomtom_incidents (array → object fix: line ~382)
try {
const ttRes = await fetch("/api/adapter-config/tomtom_incidents")
if (ttRes.ok) {
const ttData = await ttRes.json()
const ttData = toMap(await ttRes.json())
const cfg: TomtomConfig = {
min_magnitude: ttData.min_magnitude?.value ?? 4,
drop_non_present: ttData.drop_non_present?.value ?? true,
drop_zero_magnitude: ttData.drop_zero_magnitude?.value ?? true,
min_magnitude: (ttData.min_magnitude?.value as number) ?? 4,
drop_non_present: (ttData.drop_non_present?.value as boolean) ?? true,
drop_zero_magnitude: (ttData.drop_zero_magnitude?.value as boolean) ?? true,
}
setTomtomConfig(cfg)
setTomtomOriginal(JSON.stringify(cfg))
}
} catch { /* adapter-config optional */ }
// Load adapter-config for itd_511
// Load adapter-config for itd_511 (array → object fix: line ~398)
try {
const r511Res = await fetch("/api/adapter-config/itd_511")
if (r511Res.ok) {
const r511Data = await r511Res.json()
const r511Data = toMap(await r511Res.json())
const cfg: Roads511Config = {
min_severity: r511Data.min_severity?.value ?? "None",
enabled_categories: r511Data.enabled_categories?.value ?? ["incident", "closure"],
enabled_sub_types: r511Data.enabled_sub_types?.value ?? ["accident", "road_closed", "closure", "lane_closed", "vehicle_on_fire", "flooding", "debris"],
min_severity: (r511Data.min_severity?.value as string) ?? "None",
enabled_categories: (r511Data.enabled_categories?.value as string[]) ?? ["incident", "closure"],
enabled_sub_types: (r511Data.enabled_sub_types?.value as string[]) ?? ["accident", "road_closed", "closure", "lane_closed", "vehicle_on_fire", "flooding", "debris"],
}
setRoads511Config(cfg)
setRoads511Original(JSON.stringify(cfg))
}
} catch { /* adapter-config optional */ }
// Load adapter-config for wzdx
// Load adapter-config for wzdx (array → object fix: line ~413)
try {
const wzdxRes = await fetch("/api/adapter-config/wzdx")
if (wzdxRes.ok) {
const wzdxData = await wzdxRes.json()
const wzdxData = toMap(await wzdxRes.json())
const cfg: WzdxConfig = {
broadcast: wzdxData.broadcast?.value ?? false,
min_severity: wzdxData.min_severity?.value ?? "Minor",
sub_types: wzdxData.sub_types?.value ?? ["road_works", "lane_closed", "road_closed"],
broadcast: (wzdxData.broadcast?.value as boolean) ?? false,
min_severity: (wzdxData.min_severity?.value as string) ?? "Minor",
sub_types: (wzdxData.sub_types?.value as string[]) ?? ["road_works", "lane_closed", "road_closed"],
}
setWzdxConfig(cfg)
setWzdxOriginal(JSON.stringify(cfg))
}
} catch { /* adapter-config optional */ }
// Load adapter-config for nws
// Load adapter-config for nws (array → object fix: line ~427)
try {
const nwsRes = await fetch("/api/adapter-config/nws")
if (nwsRes.ok) {
const nwsData = await nwsRes.json()
const nwsData = toMap(await nwsRes.json())
const cfg: NwsConfig = {
broadcast_severities: nwsData.broadcast_severities?.value ?? ["Extreme", "Severe"],
duplicate_allowed_after_seconds: nwsData.duplicate_allowed_after_seconds?.value ?? 3600,
broadcast_severities: (nwsData.broadcast_severities?.value as string[]) ?? ["Extreme", "Severe"],
duplicate_allowed_after_seconds: (nwsData.duplicate_allowed_after_seconds?.value as number) ?? 3600,
}
setNwsConfig(cfg)
setNwsOriginal(JSON.stringify(cfg))
}
} catch { /* adapter-config optional */ }
// Load adapter-config for avalanche
// Load adapter-config for avalanche (array → object fix: line ~441)
try {
const avyRes = await fetch("/api/adapter-config/avalanche")
if (avyRes.ok) {
const avyData = await avyRes.json()
const avyData = toMap(await avyRes.json())
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)
setAvalancheOriginal(JSON.stringify(cfg))
}
} catch { /* adapter-config optional */ }
// Load adapter-config for swpc
// Load adapter-config for swpc (array → object fix: line ~453)
try {
const swpcRes = await fetch("/api/adapter-config/swpc")
if (swpcRes.ok) {
const swpcData = await swpcRes.json()
const swpcData = toMap(await swpcRes.json())
const cfg: SwpcConfig = {
geomag_kp_floor: swpcData.geomag_kp_floor?.value ?? 7.0,
flare_class_floor: swpcData.flare_class_floor?.value ?? "X1",
proton_pfu_floor: swpcData.proton_pfu_floor?.value ?? 10.0,
geomag_kp_floor: (swpcData.geomag_kp_floor?.value as number) ?? 7.0,
flare_class_floor: (swpcData.flare_class_floor?.value as string) ?? "X1",
proton_pfu_floor: (swpcData.proton_pfu_floor?.value as number) ?? 10.0,
}
setSwpcConfig(cfg)
setSwpcOriginal(JSON.stringify(cfg))
}
} 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
try {
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(() => {
const load = async () => {
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 () => {
if (!env) return
setSaving(true); setError(null); setSuccess(null)
@ -702,6 +786,86 @@ const save = async () => {
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 (!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="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 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>
)
case 'avalanche': return (
@ -1168,8 +1358,10 @@ const save = async () => {
<div className="space-y-6">
{/* Header + master enable + save bar */}
<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">
{pageTab === 'curated' && (
<>
<Toggle label="Feeds Enabled" checked={env.enabled} onChange={(v) => up({ enabled: v })} />
{hasChanges && (
<>
@ -1181,6 +1373,8 @@ const save = async () => {
</button>
</>
)}
</>
)}
</div>
</div>
@ -1193,6 +1387,33 @@ const save = async () => {
</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 */}
<div className="flex gap-1 border-b border-border overflow-x-auto">
@ -1248,6 +1469,87 @@ const save = async () => {
</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 */}
{fam.adapters.length > 0 && activeAdapter && (
<>
@ -1273,11 +1575,15 @@ const save = async () => {
hasKey={META[activeAdapter].hasKey}
health={healthFor(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)}
</AdapterPanel>
</>
)}
</> /* end curated tab */}
</div>
)
}

View file

@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'
import { Link } from 'react-router-dom'
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 { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
@ -19,10 +19,24 @@ interface ConnectionConfig {
[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() {
const { setDirty } = useDirty()
const [config, setConfig] = 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 [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -40,9 +54,14 @@ export default function MeshCoreConnection() {
const fetchConfig = useCallback(async () => {
setLoading(true)
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)
setOriginalConfig(JSON.parse(JSON.stringify(data)))
setMcContext(mcCtx)
setOriginalMcContext(JSON.parse(JSON.stringify(mcCtx)))
setHasChanges(false)
setError(null)
} catch (err) {
@ -87,10 +106,13 @@ export default function MeshCoreConnection() {
}
useEffect(() => {
if (config && originalConfig) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
if (config && originalConfig && mcContext && originalMcContext) {
const changed =
JSON.stringify(config) !== JSON.stringify(originalConfig) ||
JSON.stringify(mcContext) !== JSON.stringify(originalMcContext)
setHasChanges(changed)
}
}, [config, originalConfig])
}, [config, originalConfig, mcContext, originalMcContext])
useEffect(() => {
setDirty(hasChanges)
@ -101,18 +123,23 @@ export default function MeshCoreConnection() {
setConfig((c) => (c ? { ...c, ...patch } : c))
const saveConfig = async () => {
if (!config) return
if (!config || !mcContext) return
setSaving(true)
setError(null)
setSuccess(null)
try {
// PUT the whole connection object so Meshtastic fields are preserved.
const result = await apiUpdateConfig('connection', config)
// PUT the whole objects so sibling fields (Meshtastic connection fields,
// 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)))
setOriginalMcContext(JSON.parse(JSON.stringify(mcContext)))
setHasChanges(false)
setDirty(false)
setSuccess('MeshCore connection saved successfully')
if (result.restart_required) {
if (results.some((r) => r.restart_required)) {
notifyRestartRequired([])
}
setTimeout(() => setSuccess(null), 3000)
@ -124,10 +151,20 @@ export default function MeshCoreConnection() {
}
const discardChanges = () => {
if (originalConfig) {
setConfig(JSON.parse(JSON.stringify(originalConfig)))
if (originalConfig) setConfig(JSON.parse(JSON.stringify(originalConfig)))
if (originalMcContext) setMcContext(JSON.parse(JSON.stringify(originalMcContext)))
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) {
@ -228,6 +265,63 @@ export default function MeshCoreConnection() {
</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 */}
<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>

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>
{/* 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">
<ExternalLink size={16} className="text-accent mt-0.5 flex-shrink-0" />
<div>
Shared per-family settings (enable, severity threshold, regions, freshness/cooldown, and
Meshtastic / email / webhook delivery) live on the{' '}
Family gating (enable, severity threshold, freshness/cooldown) is on{' '}
<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">
Meshtastic Routing
</Link>{' '}
page. This page edits only the MeshCore delivery for each family.
</Link>
. This page edits only the MeshCore delivery for each family.
</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="flex items-center text-xs text-slate-500 uppercase tracking-wide">
MeshCore Delivery
<InfoButton info="For each notification family, choose which MeshCore channels fire at each severity, the MeshCore channel name to broadcast on, and the DM contacts to unicast to. Enabling a family and its severity threshold are set on the Meshtastic Routing page." />
<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 className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {

View file

@ -1,14 +1,36 @@
import { useState, useEffect, useCallback } from '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 { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api'
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() {
const { setDirty } = useDirty()
const [config, setConfig] = 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 [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
@ -41,9 +63,17 @@ export default function MeshtasticConnection() {
const fetchConfig = useCallback(async () => {
setLoading(true)
try {
const data = (await apiFetchConfig('connection')) as ConnectionConfig
setConfig(data)
setOriginalConfig(JSON.parse(JSON.stringify(data)))
const [conn, ctx, botData] = await Promise.all([
apiFetchConfig('connection') as Promise<ConnectionConfig>,
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)
setError(null)
} catch (err) {
@ -59,10 +89,14 @@ export default function MeshtasticConnection() {
}, [fetchConfig])
useEffect(() => {
if (config && originalConfig) {
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
if (config && originalConfig && context && originalContext && bot && originalBot) {
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(() => {
setDirty(hasChanges)
@ -70,18 +104,25 @@ export default function MeshtasticConnection() {
}, [hasChanges, setDirty])
const saveConfig = async () => {
if (!config) return
if (!config || !context || !bot) return
setSaving(true)
setError(null)
setSuccess(null)
try {
// PUT the whole connection object so MeshCore fields aren't clobbered.
const result = await apiUpdateConfig('connection', config)
// PUT the whole objects so sibling fields (MeshCore connection fields,
// 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)))
setOriginalContext(JSON.parse(JSON.stringify(context)))
setOriginalBot(JSON.parse(JSON.stringify(bot)))
setHasChanges(false)
setDirty(false)
setSuccess('Meshtastic connection saved successfully')
if (result.restart_required) {
if (results.some((r) => r.restart_required)) {
notifyRestartRequired([])
}
setTimeout(() => setSuccess(null), 3000)
@ -93,11 +134,11 @@ export default function MeshtasticConnection() {
}
const discardChanges = () => {
if (originalConfig) {
setConfig(JSON.parse(JSON.stringify(originalConfig)))
if (originalConfig) setConfig(JSON.parse(JSON.stringify(originalConfig)))
if (originalContext) setContext(JSON.parse(JSON.stringify(originalContext)))
if (originalBot) setBot(JSON.parse(JSON.stringify(originalBot)))
setHasChanges(false)
}
}
if (loading) {
return (
@ -167,6 +208,42 @@ export default function MeshtasticConnection() {
<ConnectionSection data={config} onChange={setConfig} />
</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 */}
<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>

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'
import ChannelPicker from '@/components/ChannelPicker'
import NodePicker from '@/components/NodePicker'
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
import { useDirty } from '@/context/DirtyContext'
// Types
@ -365,7 +364,7 @@ export function InfoButton({ info }: { info: string }) {
}
// 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
value: string
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
value: number
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
checked: boolean
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
value: string
onChange: (v: string) => void
@ -1609,76 +1608,75 @@ 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>
onChange: (t: Record<string, NotificationToggle>) => void
}) {
const [expanded, setExpanded] = useState<string | null>(null)
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
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">
Master Toggles
<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)." />
Meshtastic Delivery
<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 className="grid grid-cols-1 md:grid-cols-2 gap-3">
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
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
const t = toggles[key] || ({} as NotificationToggle)
return (
<div key={key} className="border border-[#1e2a3a] p-3">
<div className="flex items-center justify-between">
<button type="button" onClick={() => setExpanded(isOpen ? null : key)}
className="flex items-center gap-2 text-sm text-slate-200">
<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}
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
</button>
<Toggle label="" checked={!!t.enabled} onChange={(v) => upd(key, { enabled: v })} />
</div>
{!isOpen && (
<div className="text-xs text-slate-600 mt-1">
{t.enabled
? `${regionCount || 'all'} region${regionCount === 1 ? '' : 's'}, ${chanCount} channel${chanCount === 1 ? '' : 's'} at ${t.min_severity || 'priority'}+`
: 'OFF'}
</div>
)}
{isOpen && (
<div className={`mt-3 space-y-4 ${t.enabled ? '' : 'opacity-40 pointer-events-none select-none'}`}>
{/* ── General ──────────────────────────────────────────────── */}
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
<div className="text-xs text-slate-500 uppercase tracking-wide">General</div>
<SeveritySelector value={t.min_severity || 'priority'} onChange={(v) => upd(key, { min_severity: v })} />
<ListInput
label="Regions (empty = all)"
value={t.regions || []}
onChange={(v) => upd(key, { regions: v })}
placeholder="Add region..."
/>
<div className="grid grid-cols-2 gap-3">
<NumberInput
label="Freshness (sec)"
value={t.freshness_seconds ?? 600}
onChange={(v) => upd(key, { freshness_seconds: v })}
min={0}
helper="Drop events older than this"
info="Events older than this window (seconds) are discarded at dispatcher entrance. 600 = 10 min."
/>
<NumberInput
label="Cooldown (sec)"
value={t.cooldown_seconds ?? 0}
onChange={(v) => upd(key, { cooldown_seconds: v })}
min={0}
helper="0 = no throttle"
info="Per (family, category, region) throttle window. Prevents repeat sends within this window."
/>
</div>
</div>
{/* ── 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} />
@ -1706,12 +1704,40 @@ function MasterToggles({ toggles, onChange }: {
info="Hex node IDs for mesh_dm delivery (e.g. !a1b2c3d4). Used when mesh_dm is enabled for a severity."
/>
</div>
</div>
)
})}
</div>
</div>
)
}
{/* MeshCore delivery controls moved to the dedicated
MeshCore -> Routing page (/meshcore/routing). Shared
family settings (enable/severity/regions) stay here. */}
// Per-family Other Channels (email / webhook / digest) delivery grid.
// Kept on the Meshtastic Routing page so all non-MeshCore delivery lives in one place.
function OtherChannelsGrid({
toggles,
onChange,
}: {
toggles: Record<string, NotificationToggle>
onChange: (t: Record<string, NotificationToggle>) => void
}) {
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
{/* ── Other channels ───────────────────────────────────────── */}
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} />
@ -1750,9 +1776,6 @@ function MasterToggles({ toggles, onChange }: {
helper="POST alert as JSON"
/>
</div>
</div>
)}
</div>
)
})}
@ -1826,10 +1849,34 @@ export default function Notifications() {
setSuccess(null)
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', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(config),
body: JSON.stringify(merged),
})
const result = await res.json()
@ -1838,10 +1885,11 @@ export default function Notifications() {
throw new Error(result.detail || 'Save failed')
}
setSuccess('Notifications config saved successfully')
setOriginalConfig(JSON.parse(JSON.stringify(config)))
setConfig(merged)
setOriginalConfig(JSON.parse(JSON.stringify(merged)))
setHasChanges(false)
setDirty(false)
setSuccess('Meshtastic routing saved successfully')
setTimeout(() => setSuccess(null), 3000)
} catch (err) {
setError(err instanceof Error ? err.message : 'Save failed')
@ -2145,7 +2193,7 @@ export default function Notifications() {
<div className="flex items-center justify-between">
<div>
<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>
</div>
<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."
/>
{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 */}
{/* Meshtastic delivery grids — always visible regardless of master switch */}
{config.toggles && (
<MasterToggles
<>
<MeshtasticDeliveryGrid
toggles={config.toggles}
onChange={(t) => setConfig({ ...config, toggles: t })}
/>
<OtherChannelsGrid
toggles={config.toggles}
onChange={(t) => setConfig({ ...config, toggles: t })}
/>
</>
)}
{config.enabled && (
<>
{/* Rules Section */}
<div className="space-y-3">
<div className="flex items-center justify-between">
@ -2344,451 +2334,7 @@ export default function Notifications() {
)}
</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>
)
}
// ============================================================================
// 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>
<p>
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 .mesh_health import MeshHealthEngine
from .mesh_reporter import MeshReporter
from .subscriptions import SubscriptionManager
logger = logging.getLogger(__name__)
@ -65,14 +64,12 @@ class AlertEngine:
self,
health_engine: "MeshHealthEngine",
reporter: "MeshReporter",
subscription_manager: "SubscriptionManager",
config: "MeshIntelligenceConfig",
db_path: str = "",
timezone: str = "America/Boise",
):
self._health = health_engine
self._reporter = reporter
self._subs = subscription_manager
self._rules = config.alert_rules
self._critical_nodes = set(n.upper() for n in (config.critical_nodes or []))
self._db_path = db_path
@ -580,14 +577,6 @@ class AlertEngine:
def clear_pending(self):
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]:
"""Check environmental feeds for alertable conditions.

View file

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

View file

@ -32,11 +32,9 @@ class HelpCommand(CommandHandler):
# Group by category
health_names = {"health", "region", "neighbors"}
sub_names = {"sub", "unsub", "mysubs"}
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() not in sub_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() != "help"]
lines = ["Commands:"]
@ -46,12 +44,6 @@ class HelpCommand(CommandHandler):
for c in sorted(health_cmds, key=lambda x: x.name):
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:
lines.append("")
lines.append("Other:")
@ -67,9 +59,6 @@ class HelpCommand(CommandHandler):
def _command_help(self, cmd_name: str) -> str:
"""Detailed help for a specific command."""
aliases = {
"sub": "sub", "subscribe": "sub", "subscription": "sub", "subscriptions": "sub",
"unsub": "unsub", "unsubscribe": "unsub",
"mysubs": "mysubs", "subs": "mysubs",
"health": "health", "mesh": "health",
"region": "region", "reg": "region",
"neighbors": "neighbors", "nbr": "neighbors", "nb": "neighbors",
@ -81,32 +70,6 @@ class HelpCommand(CommandHandler):
registered = {c.name.lower() for c in self._dispatcher.get_commands()}
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": (
"Mesh Health\n\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
@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
class CommandsConfig:
"""Command settings."""
@ -782,6 +791,7 @@ class Config:
history: HistoryConfig = field(default_factory=HistoryConfig)
memory: MemoryConfig = field(default_factory=MemoryConfig)
context: ContextConfig = field(default_factory=ContextConfig)
meshcore_context: MeshCoreContextConfig = field(default_factory=MeshCoreContextConfig)
commands: CommandsConfig = field(default_factory=CommandsConfig)
llm: LLMConfig = field(default_factory=LLMConfig)
weather: WeatherConfig = field(default_factory=WeatherConfig)

View file

@ -56,31 +56,29 @@ async def get_alert_history(
}
@router.get("/subscriptions")
async def get_subscriptions(request: Request):
"""Get all alert subscriptions."""
subscription_manager = getattr(request.app.state, "subscription_manager", None)
@router.get("/activity")
async def get_activity(
request: Request,
limit: int = Query(100, ge=1, le=500),
):
"""Activity Log: most recent outbound mesh broadcasts, newest first.
if not subscription_manager:
return []
Reads mesh_broadcasts_out from the persistence/migration DB (get_db) and
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:
subs = subscription_manager.get_all_subs()
return [
{
"id": sub["id"],
"user_id": sub["user_id"],
"sub_type": sub["sub_type"],
"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
]
conn = get_db()
rows = conn.execute(
"SELECT * FROM mesh_broadcasts_out "
"ORDER BY sent_at DESC, id DESC LIMIT ?",
(limit,),
).fetchall()
except Exception:
return []
return [dict(r) for r in rows]
def _map_severity(alert: dict) -> str:

View file

@ -45,6 +45,7 @@ VALID_SECTIONS = {
"history",
"memory",
"context",
"meshcore_context",
"commands",
"llm",
"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.alert_engine = getattr(meshai_instance, "alert_engine", 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.connector = meshai_instance.connector
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.health_engine = None
self.mesh_reporter = None
self.subscription_manager = None
self.alert_engine = None
self.notification_router = None
self.event_bus = None # Notification pipeline EventBus (v0.3)
@ -53,7 +52,6 @@ class MeshAI:
self.env_store = None # Environmental feeds store
self._central_consumer = None # Central NATS consumer (v0.4)
self._fire_pacer = None # FirePacer for rate-limited fire broadcasts
self._last_sub_check: float = 0.0
self.router: Optional[MessageRouter] = None
self.responder: Optional[Responder] = None
self._running = False
@ -223,12 +221,6 @@ class MeshAI:
except Exception as 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
if time.time() - self._last_cleanup >= 3600:
await self.history.cleanup_expired()
@ -326,8 +318,6 @@ class MeshAI:
if self.data_store:
await self.data_store.stop_mqtt_sources()
self.data_store.close()
if self.subscription_manager:
self.subscription_manager.close()
self._remove_pid()
logger.info("MeshAI stopped")
@ -395,7 +385,10 @@ class MeshAI:
await self._load_summaries()
# 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
# transport's budget (LoRa max_chars, 140). Durable across adapter_config
@ -494,22 +487,13 @@ class MeshAI:
else:
self.mesh_reporter = None
# Subscription manager (uses same db as data_store)
if self.data_store:
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:
# Alert engine (needs health engine and reporter)
if self.health_engine and self.mesh_reporter:
from .alert_engine import AlertEngine
mi = self.config.mesh_intelligence
self.alert_engine = AlertEngine(
health_engine=self.health_engine,
reporter=self.mesh_reporter,
subscription_manager=self.subscription_manager,
config=mi,
db_path="/data/mesh_history.db",
timezone=self.config.timezone,
@ -610,9 +594,7 @@ class MeshAI:
mesh_reporter=self.mesh_reporter,
data_store=self.data_store,
health_engine=self.health_engine,
subscription_manager=self.subscription_manager,
env_store=self.env_store,
notification_router=self.notification_router,
)
# Message router
@ -796,94 +778,9 @@ class MeshAI:
except Exception as 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:
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:
"""Configure logging."""

View file

@ -445,6 +445,8 @@ class Dispatcher:
delivered_any = False
for ch_type in ch_types:
rule = None
payload = None
try:
rule = self._toggle_to_rule(tog, ch_type, event)
channel = self._channel_factory(rule, self._connector)
@ -458,15 +460,20 @@ class Dispatcher:
if success:
delivered_any = True
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:
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:
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) ----------
# Cooldown arming + dedup recording happen ONLY after at least one
@ -600,39 +607,75 @@ class Dispatcher:
success = await channel.deliver(payload, rule)
except Exception:
self._logger.exception(
"scheduled-broadcast: delivery raised for %s; skipping", ch_type)
continue
"scheduled-broadcast: delivery raised for %s", ch_type)
success = False
if success:
delivered_any = True
# Audit row -- mirrors _post_broadcast_commit for scheduled.
# v20 per-mesh audit row. 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 visible as success=0.
try:
from meshai.persistence import get_db
conn = get_db()
bytes_sent = len(text.encode("utf-8")) if text else 0
transport, channel_id, recipient = self._audit_route(rule, ch_type)
conn.execute(
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, "
"channel, text, source_event_table, source_event_pk, "
"bytes_sent, ack_received) VALUES (?,?,?,?,?,?,?,?)",
(int(time.time()), "broadcast",
rf.broadcast_channel, text,
"bytes_sent, ack_received, transport, success) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
(int(time.time()), recipient,
channel_id, text,
source_event_table, str(source_event_pk),
bytes_sent, 0),
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
def _post_broadcast_commit(self, event, payload, rule, ch_type: str) -> None:
"""Persistence side-effects of an actually-successful broadcast.
@staticmethod
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
it wants one via `event.data["_broadcast_audit"]`, then invokes the
handler-supplied `_on_broadcast_committed` callback so the handler
can refresh its own last_broadcast_* bookkeeping. Both calls are
wrapped: a bookkeeping failure must NOT undo the actual broadcast
nor break dispatch for sibling toggles.
transport is the mesh family the row belongs to ("meshtastic" /
"meshcore"); channel_id is the Meshtastic channel INDEX or the
MeshCore channel NAME; recipient is 'broadcast' or the DM target
list. Mirrors create_channel()'s delivery_type routing.
"""
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 {}
if not data:
@ -646,23 +689,17 @@ class Dispatcher:
conn = get_db()
text = payload.message if payload is not None else (event.title or "")
bytes_sent = len(text.encode("utf-8")) if text else 0
if ch_type == "mesh_dm":
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)
transport, channel, recipient = self._audit_route(rule, ch_type)
conn.execute(
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, channel, "
"text, source_event_table, source_event_pk, bytes_sent, "
"ack_received) VALUES (?,?,?,?,?,?,?,?)",
"ack_received, transport, success) "
"VALUES (?,?,?,?,?,?,?,?,?,?)",
(
int(committed_at), recipient, channel, text,
audit.get("table"), audit.get("pk"),
bytes_sent, 0,
transport, 1 if success else 0,
),
)
except Exception:
@ -672,6 +709,11 @@ class Dispatcher:
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")
if callable(cb):
try:

View file

@ -735,45 +735,6 @@ class NotificationRouter:
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:
"""Generate an LLM-summarized report from current data."""
context_parts = []

View file

@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
DEFAULT_DB_PATH = "/data/meshai.sqlite"
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
SCHEMA_VERSION = 19
SCHEMA_VERSION = 20
SCHEMA_META_TABLE = "schema_meta"
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
def build_transport(config) -> MeshTransport:
def build_transport(config, meshcore_context=None) -> MeshTransport:
"""Instantiate and return the active MeshTransport derived from config.
The active transports are derived from the connection config, not a
@ -16,6 +16,8 @@ def build_transport(config) -> MeshTransport:
Args:
config: A ConnectionConfig (or duck-compatible object).
meshcore_context: Optional MeshCoreContextConfig for the MeshCore
passive-context / bot-behavior filter (None = pass-through).
Returns:
A concrete MeshTransport instance ready to be connected.
@ -27,6 +29,9 @@ def build_transport(config) -> MeshTransport:
if meshcore_host.strip():
from meshai.transport.meshcore_transport import MeshCoreTransport
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

View file

@ -24,6 +24,30 @@ logger = logging.getLogger(__name__)
_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):
"""MeshTransport implementation over a pyMC companion TCP frame server.
@ -41,8 +65,12 @@ class MeshCoreTransport(MeshTransport):
# Name tag used by CompositeTransport for routing hints.
transport_name: str = "meshcore"
def __init__(self, config) -> None:
def __init__(self, config, meshcore_context=None) -> None:
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._loop: Optional[asyncio.AbstractEventLoop] = 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)."""
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)
# ------------------------------------------------------------------
@ -416,13 +451,21 @@ class MeshCoreTransport(MeshTransport):
# ------------------------------------------------------------------
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)
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)
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)
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)
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