mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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:
parent
11bac716d0
commit
0460462485
38 changed files with 2502 additions and 2342 deletions
|
|
@ -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>
|
||||
|
|
|
|||
449
work/dashboard-frontend/src/components/DangerZonesPanel.tsx
Normal file
449
work/dashboard-frontend/src/components/DangerZonesPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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]">
|
||||
|
|
|
|||
|
|
@ -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> {
|
||||
|
|
|
|||
180
work/dashboard-frontend/src/pages/ActivityLog.tsx
Normal file
180
work/dashboard-frontend/src/pages/ActivityLog.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
33
work/dashboard-frontend/src/pages/MeshCoreDangerZones.tsx
Normal file
33
work/dashboard-frontend/src/pages/MeshCoreDangerZones.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
17
work/dashboard-frontend/src/pages/MeshtasticDangerZones.tsx
Normal file
17
work/dashboard-frontend/src/pages/MeshtasticDangerZones.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
178
work/dashboard-frontend/src/pages/MeshtasticNodes.tsx
Normal file
178
work/dashboard-frontend/src/pages/MeshtasticNodes.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
43
work/dashboard-frontend/src/pages/Places.tsx
Normal file
43
work/dashboard-frontend/src/pages/Places.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
330
work/dashboard-frontend/src/pages/ScheduledBroadcasts.tsx
Normal file
330
work/dashboard-frontend/src/pages/ScheduledBroadcasts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ VALID_SECTIONS = {
|
|||
"history",
|
||||
"memory",
|
||||
"context",
|
||||
"meshcore_context",
|
||||
"commands",
|
||||
"llm",
|
||||
"weather",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
9
work/meshai/persistence/migrations/v20.sql
Normal file
9
work/meshai/persistence/migrations/v20.sql
Normal 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;
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
50
work/tests/test_meshcore_context_config.py
Normal file
50
work/tests/test_meshcore_context_config.py
Normal 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
|
||||
85
work/tests/test_meshcore_context_filter.py
Normal file
85
work/tests/test_meshcore_context_filter.py
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue