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 Mesh from './pages/Mesh'
|
||||||
import Environment from './pages/Environment'
|
import Environment from './pages/Environment'
|
||||||
import Config from './pages/Config'
|
import Config from './pages/Config'
|
||||||
import Alerts from './pages/Alerts'
|
import ActivityLog from './pages/ActivityLog'
|
||||||
import Notifications from './pages/Notifications'
|
import Notifications from './pages/Notifications'
|
||||||
import Reference from './pages/Reference'
|
import Reference from './pages/Reference'
|
||||||
import AdapterConfig from './pages/AdapterConfig'
|
import AdapterConfig from './pages/AdapterConfig'
|
||||||
|
|
@ -12,10 +12,15 @@ import GaugeSites from './pages/GaugeSites'
|
||||||
import TownAnchors from './pages/TownAnchors'
|
import TownAnchors from './pages/TownAnchors'
|
||||||
import MeshCoreRouting from './pages/MeshCoreRouting'
|
import MeshCoreRouting from './pages/MeshCoreRouting'
|
||||||
import MeshCoreConnection from './pages/MeshCoreConnection'
|
import MeshCoreConnection from './pages/MeshCoreConnection'
|
||||||
import MeshCoreContacts from './pages/MeshCoreContacts'
|
|
||||||
import MeshCoreCompanion from './pages/MeshCoreCompanion'
|
import MeshCoreCompanion from './pages/MeshCoreCompanion'
|
||||||
import MeshtasticConnection from './pages/MeshtasticConnection'
|
import MeshtasticConnection from './pages/MeshtasticConnection'
|
||||||
import MeshtasticSources from './pages/MeshtasticSources'
|
import MeshtasticSources from './pages/MeshtasticSources'
|
||||||
|
import Places from './pages/Places'
|
||||||
|
import MeshtasticNodes from './pages/MeshtasticNodes'
|
||||||
|
import MeshCoreContactsCompanion from './pages/MeshCoreContactsCompanion'
|
||||||
|
import ScheduledBroadcasts from './pages/ScheduledBroadcasts'
|
||||||
|
import MeshtasticDangerZones from './pages/MeshtasticDangerZones'
|
||||||
|
import MeshCoreDangerZones from './pages/MeshCoreDangerZones'
|
||||||
import { ToastProvider } from './components/ToastProvider'
|
import { ToastProvider } from './components/ToastProvider'
|
||||||
import { DirtyProvider } from './context/DirtyContext'
|
import { DirtyProvider } from './context/DirtyContext'
|
||||||
|
|
||||||
|
|
@ -25,22 +30,38 @@ function App() {
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<Layout>
|
<Layout>
|
||||||
<Routes>
|
<Routes>
|
||||||
|
{/* Core routes */}
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/mesh" element={<Mesh />} />
|
|
||||||
<Route path="/environment" element={<Environment />} />
|
<Route path="/environment" element={<Environment />} />
|
||||||
<Route path="/config" element={<Config />} />
|
<Route path="/config" element={<Config />} />
|
||||||
<Route path="/alerts" element={<Alerts />} />
|
<Route path="/alerts" element={<ActivityLog />} />
|
||||||
|
<Route path="/activity" element={<ActivityLog />} />
|
||||||
<Route path="/notifications" element={<Notifications />} />
|
<Route path="/notifications" element={<Notifications />} />
|
||||||
<Route path="/reference" element={<Reference />} />
|
<Route path="/reference" element={<Reference />} />
|
||||||
<Route path="/adapter-config" element={<AdapterConfig />} />
|
<Route path="/adapter-config" element={<AdapterConfig />} />
|
||||||
|
|
||||||
|
{/* New aggregated pages */}
|
||||||
|
<Route path="/places" element={<Places />} />
|
||||||
|
|
||||||
|
{/* De-navved routes still work */}
|
||||||
<Route path="/gauge-sites" element={<GaugeSites />} />
|
<Route path="/gauge-sites" element={<GaugeSites />} />
|
||||||
<Route path="/town-anchors" element={<TownAnchors />} />
|
<Route path="/town-anchors" element={<TownAnchors />} />
|
||||||
<Route path="/meshcore/routing" element={<MeshCoreRouting />} />
|
<Route path="/mesh" element={<Mesh />} />
|
||||||
<Route path="/meshcore/connection" element={<MeshCoreConnection />} />
|
|
||||||
<Route path="/meshcore/contacts" element={<MeshCoreContacts />} />
|
{/* Meshtastic routes */}
|
||||||
<Route path="/meshcore/companion" element={<MeshCoreCompanion />} />
|
|
||||||
<Route path="/meshtastic/connection" element={<MeshtasticConnection />} />
|
<Route path="/meshtastic/connection" element={<MeshtasticConnection />} />
|
||||||
<Route path="/meshtastic/sources" element={<MeshtasticSources />} />
|
<Route path="/meshtastic/sources" element={<MeshtasticSources />} />
|
||||||
|
<Route path="/meshtastic/scheduled" element={<ScheduledBroadcasts family="meshtastic" />} />
|
||||||
|
<Route path="/meshtastic/nodes" element={<MeshtasticNodes />} />
|
||||||
|
<Route path="/meshtastic/danger-zones" element={<MeshtasticDangerZones />} />
|
||||||
|
|
||||||
|
{/* MeshCore routes */}
|
||||||
|
<Route path="/meshcore/connection" element={<MeshCoreConnection />} />
|
||||||
|
<Route path="/meshcore/routing" element={<MeshCoreRouting />} />
|
||||||
|
<Route path="/meshcore/scheduled" element={<ScheduledBroadcasts family="meshcore" />} />
|
||||||
|
<Route path="/meshcore/contacts" element={<MeshCoreContactsCompanion />} />
|
||||||
|
<Route path="/meshcore/companion" element={<MeshCoreCompanion />} />
|
||||||
|
<Route path="/meshcore/danger-zones" element={<MeshCoreDangerZones />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Layout>
|
</Layout>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
|
|
|
||||||
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,
|
LayoutDashboard,
|
||||||
Radio,
|
Radio,
|
||||||
Cloud,
|
Cloud,
|
||||||
Bell,
|
|
||||||
BellRing,
|
BellRing,
|
||||||
BookOpen,
|
BookOpen,
|
||||||
Sliders,
|
Sliders,
|
||||||
|
|
@ -17,6 +16,9 @@ import {
|
||||||
Users,
|
Users,
|
||||||
Bot,
|
Bot,
|
||||||
Settings,
|
Settings,
|
||||||
|
Calendar,
|
||||||
|
Activity,
|
||||||
|
AlertTriangle,
|
||||||
type LucideIcon,
|
type LucideIcon,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
import { fetchStatus, type SystemStatus } from '@/lib/api'
|
||||||
|
|
@ -39,28 +41,26 @@ interface NavGroup {
|
||||||
items: NavItem[]
|
items: NavItem[]
|
||||||
}
|
}
|
||||||
|
|
||||||
// Top-level, ungrouped items (no header).
|
// All nav items are grouped — no ungrouped topNavItems.
|
||||||
const topNavItems: NavItem[] = [
|
|
||||||
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
|
||||||
{ path: '/environment', label: 'Environment', icon: Cloud },
|
|
||||||
{ path: '/alerts', label: 'Alerts', icon: Bell },
|
|
||||||
{ path: '/reference', label: 'Reference', icon: BookOpen },
|
|
||||||
{ path: '/adapter-config', label: 'Adapter Config', icon: Sliders },
|
|
||||||
{ path: '/config', label: 'Config', icon: Settings },
|
|
||||||
{ path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets },
|
|
||||||
{ path: '/town-anchors', label: 'Town Anchors', icon: MapPin },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Grouped sections with labeled headers. Meshtastic "Connection" and "Sources"
|
|
||||||
// are focused standalone pages (/meshtastic/connection, /meshtastic/sources).
|
|
||||||
const navGroups: NavGroup[] = [
|
const navGroups: NavGroup[] = [
|
||||||
|
{
|
||||||
|
header: 'General',
|
||||||
|
items: [
|
||||||
|
{ path: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ path: '/config', label: 'Settings', icon: Settings },
|
||||||
|
{ path: '/environment', label: 'Data Feeds', icon: Cloud },
|
||||||
|
{ path: '/activity', label: 'Activity Log', icon: Activity },
|
||||||
|
{ path: '/places', label: 'Places', icon: MapPin },
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
header: 'Meshtastic',
|
header: 'Meshtastic',
|
||||||
items: [
|
items: [
|
||||||
{ path: '/meshtastic/connection', label: 'Connection', icon: Wifi },
|
{ path: '/meshtastic/connection', label: 'Connection', icon: Wifi },
|
||||||
{ path: '/notifications', label: 'Routing', icon: BellRing },
|
{ path: '/notifications', label: 'Routing', icon: BellRing },
|
||||||
{ path: '/mesh', label: 'Mesh', icon: Radio },
|
{ path: '/meshtastic/scheduled', label: 'Scheduled Broadcasts', icon: Calendar },
|
||||||
{ path: '/meshtastic/sources', label: 'Sources', icon: Layers },
|
{ path: '/meshtastic/nodes', label: 'Nodes & Health', icon: Activity },
|
||||||
|
{ path: '/meshtastic/danger-zones', label: 'Danger Zones', icon: AlertTriangle },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -68,18 +68,47 @@ const navGroups: NavGroup[] = [
|
||||||
items: [
|
items: [
|
||||||
{ path: '/meshcore/connection', label: 'Connection', icon: Network },
|
{ path: '/meshcore/connection', label: 'Connection', icon: Network },
|
||||||
{ path: '/meshcore/routing', label: 'Routing', icon: BellRing },
|
{ path: '/meshcore/routing', label: 'Routing', icon: BellRing },
|
||||||
{ path: '/meshcore/contacts', label: 'Contacts', icon: Users },
|
{ path: '/meshcore/scheduled', label: 'Scheduled Broadcasts', icon: Calendar },
|
||||||
{ path: '/meshcore/companion', label: 'Companion', icon: Bot },
|
{ path: '/meshcore/contacts', label: 'Contacts & Companion', icon: Users },
|
||||||
|
{ path: '/meshcore/danger-zones', label: 'Danger Zones', icon: AlertTriangle },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
header: 'Documentation',
|
||||||
|
items: [
|
||||||
|
{ path: '/reference', label: 'Reference', icon: BookOpen },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// Flattened view of every nav item (top + all groups) for title lookup.
|
// Flattened view of every nav item for title lookup.
|
||||||
const allNavItems: NavItem[] = [
|
// Also include de-navved routes so they still resolve a title.
|
||||||
...topNavItems,
|
const extraTitleItems: NavItem[] = [
|
||||||
...navGroups.flatMap((g) => g.items),
|
{ path: '/adapter-config', label: 'Adapter Config', icon: Sliders },
|
||||||
|
{ path: '/gauge-sites', label: 'Gauge Sites', icon: Droplets },
|
||||||
|
{ path: '/town-anchors', label: 'Town Anchors', icon: MapPin },
|
||||||
|
{ path: '/mesh', label: 'Mesh', icon: Radio },
|
||||||
|
{ path: '/meshtastic/sources', label: 'Sources', icon: Layers },
|
||||||
|
{ path: '/meshcore/companion', label: 'Companion', icon: Bot },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const allNavItems: NavItem[] = [
|
||||||
|
...navGroups.flatMap((g) => g.items),
|
||||||
|
...extraTitleItems,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Titles for new pages not reachable via explicit nav items.
|
||||||
|
const pathTitles: Record<string, string> = {
|
||||||
|
'/places': 'Places',
|
||||||
|
'/meshtastic/scheduled': 'Scheduled Broadcasts',
|
||||||
|
'/meshcore/scheduled': 'Scheduled Broadcasts',
|
||||||
|
'/meshtastic/nodes': 'Nodes & Health',
|
||||||
|
'/meshtastic/danger-zones': 'Danger Zones',
|
||||||
|
'/meshcore/danger-zones': 'Danger Zones',
|
||||||
|
'/meshcore/contacts': 'Contacts & Companion',
|
||||||
|
'/meshcore/companion': 'Contacts & Companion',
|
||||||
|
}
|
||||||
|
|
||||||
function formatUptime(seconds: number): string {
|
function formatUptime(seconds: number): string {
|
||||||
const days = Math.floor(seconds / 86400)
|
const days = Math.floor(seconds / 86400)
|
||||||
const hours = Math.floor((seconds % 86400) / 3600)
|
const hours = Math.floor((seconds % 86400) / 3600)
|
||||||
|
|
@ -125,11 +154,13 @@ function renderNavItem(
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPageTitle(fullPath: string): string {
|
function getPageTitle(fullPath: string): string {
|
||||||
// Exact match first (honors any ?section= query on deep-linked items).
|
// Check explicit path-title map first (covers new pages).
|
||||||
|
const base = fullPath.split('?')[0]
|
||||||
|
if (pathTitles[base]) return pathTitles[base]
|
||||||
|
// Exact match (honors ?section= deep-links).
|
||||||
const exact = allNavItems.find((i) => i.path === fullPath)
|
const exact = allNavItems.find((i) => i.path === fullPath)
|
||||||
if (exact) return exact.label
|
if (exact) return exact.label
|
||||||
// Fallback: match by pathname only, ignoring query strings.
|
// Fallback: match by pathname only, ignoring query strings.
|
||||||
const base = fullPath.split('?')[0]
|
|
||||||
const byPath = allNavItems.find((i) => i.path.split('?')[0] === base)
|
const byPath = allNavItems.find((i) => i.path.split('?')[0] === base)
|
||||||
return byPath?.label || 'Dashboard'
|
return byPath?.label || 'Dashboard'
|
||||||
}
|
}
|
||||||
|
|
@ -203,7 +234,6 @@ export default function Layout({ children }: LayoutProps) {
|
||||||
|
|
||||||
{/* Navigation */}
|
{/* Navigation */}
|
||||||
<nav className="flex-1 py-4">
|
<nav className="flex-1 py-4">
|
||||||
{topNavItems.map((item) => renderNavItem(item, location.pathname, location.search, handleNavClick))}
|
|
||||||
{navGroups.map((group) => (
|
{navGroups.map((group) => (
|
||||||
<div key={group.header} className="mt-4">
|
<div key={group.header} className="mt-4">
|
||||||
<div className="px-5 pt-2 pb-1 text-[10px] font-sans font-semibold uppercase tracking-wider text-[#555]">
|
<div className="px-5 pt-2 pb-1 text-[10px] font-sans font-semibold uppercase tracking-wider text-[#555]">
|
||||||
|
|
|
||||||
|
|
@ -111,15 +111,18 @@ export interface AlertHistoryResponse {
|
||||||
total: number
|
total: number
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Subscription {
|
export interface ActivityEntry {
|
||||||
id: number
|
id: number
|
||||||
user_id: string
|
sent_at: number | string | null // epoch seconds (int) on new rows
|
||||||
sub_type: string
|
recipient: string | null
|
||||||
schedule_time?: string
|
channel: string | number | null
|
||||||
schedule_day?: string
|
text: string | null
|
||||||
scope_type: string
|
source_event_table: string | null
|
||||||
scope_value?: string
|
source_event_pk: string | number | null
|
||||||
enabled: boolean
|
bytes_sent: number | null
|
||||||
|
ack_received: number | null
|
||||||
|
transport: string | null // 'meshtastic' | 'meshcore' | null (legacy)
|
||||||
|
success: number | null // 1 sent, 0 skip/fail, null legacy
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface EnvStatus {
|
export interface EnvStatus {
|
||||||
|
|
@ -294,8 +297,8 @@ export async function fetchAlertHistory(
|
||||||
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
return fetchJson<AlertHistoryResponse | AlertHistoryItem[]>(`/api/alerts/history?${params.toString()}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchSubscriptions(): Promise<Subscription[]> {
|
export async function fetchActivity(limit = 100): Promise<ActivityEntry[]> {
|
||||||
return fetchJson<Subscription[]>('/api/subscriptions')
|
return fetchJson<ActivityEntry[]>(`/api/activity?limit=${limit}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
export async function fetchEnvStatus(): Promise<EnvStatus> {
|
||||||
|
|
|
||||||
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).
|
// Auto-saves on blur (text/number inputs) or change (bool toggle + select).
|
||||||
// Cache invalidation is server-side -- every PUT triggers it. The handler
|
// Cache invalidation is server-side -- every PUT triggers it. The handler
|
||||||
// reads via the in-process accessor on its next call.
|
// reads via the in-process accessor on its next call.
|
||||||
|
//
|
||||||
|
// Phase D: can be embedded in Advanced tab of Data Feeds. Pass excludeKeys
|
||||||
|
// to hide curated keys (owned by the curated panels), and hideLlmToggle=true
|
||||||
|
// to hide the include_in_llm_context checkbox (surfaced in curated panels).
|
||||||
|
|
||||||
import { useEffect, useState, useCallback } from 'react'
|
import { useEffect, useState, useCallback } from 'react'
|
||||||
import {
|
import {
|
||||||
|
|
@ -14,6 +18,20 @@ import {
|
||||||
Sliders,
|
Sliders,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
// Curated keys — owned by Environment.tsx curated panels. Filtered out of the
|
||||||
|
// Advanced (raw) view to avoid double-editing.
|
||||||
|
export const CURATED_KEYS: Record<string, string[]> = {
|
||||||
|
wfigs: ['allowed_incident_types', 'freshness_seconds', 'cooldown_seconds', 'broadcast_on_acres', 'broadcast_on_contained'],
|
||||||
|
fires: ['digest_enabled', 'digest_schedule', 'digest_timezone'],
|
||||||
|
tomtom_incidents: ['min_magnitude', 'drop_non_present', 'drop_zero_magnitude'],
|
||||||
|
itd_511: ['min_severity', 'enabled_categories', 'enabled_sub_types'],
|
||||||
|
wzdx: ['broadcast', 'min_severity', 'sub_types'],
|
||||||
|
nws: ['broadcast_severities', 'duplicate_allowed_after_seconds'],
|
||||||
|
avalanche: ['min_danger_level'],
|
||||||
|
swpc: ['geomag_kp_floor', 'flare_class_floor', 'proton_pfu_floor'],
|
||||||
|
satpass: ['enabled', 'observers', 'min_elevation', 'norad_ids', 'max_broadcasts_per_hour', 'dry_run'],
|
||||||
|
}
|
||||||
|
|
||||||
interface ConfigRow {
|
interface ConfigRow {
|
||||||
adapter: string
|
adapter: string
|
||||||
key: string
|
key: string
|
||||||
|
|
@ -38,7 +56,14 @@ type SaveStatus = 'idle' | 'saving' | 'saved' | 'error'
|
||||||
// Brief animation after a successful save.
|
// Brief animation after a successful save.
|
||||||
const SAVED_BADGE_MS = 1500
|
const SAVED_BADGE_MS = 1500
|
||||||
|
|
||||||
export default function AdapterConfig() {
|
interface AdapterConfigProps {
|
||||||
|
/** Keys to hide per adapter (curated keys owned by Data Feeds panels). */
|
||||||
|
excludeKeys?: Record<string, string[]>
|
||||||
|
/** When true, hides the include_in_llm_context checkbox (surfaced in curated panels). */
|
||||||
|
hideLlmToggle?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdapterConfig({ excludeKeys, hideLlmToggle }: AdapterConfigProps = {}) {
|
||||||
const [config, setConfig] = useState<GroupedConfig>({})
|
const [config, setConfig] = useState<GroupedConfig>({})
|
||||||
const [meta, setMeta] = useState<MetaMap>({})
|
const [meta, setMeta] = useState<MetaMap>({})
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
@ -183,7 +208,7 @@ export default function AdapterConfig() {
|
||||||
<Sliders className="w-5 h-5" />
|
<Sliders className="w-5 h-5" />
|
||||||
<h1 className="text-xl font-semibold">Adapter Config</h1>
|
<h1 className="text-xl font-semibold">Adapter Config</h1>
|
||||||
<span className="text-xs text-[#666] ml-2">
|
<span className="text-xs text-[#666] ml-2">
|
||||||
{Object.values(config).reduce((n, l) => n + l.length, 0)} settings across {allAdapters.length} adapters
|
{Object.entries(config).reduce((n, [adp, l]) => n + (excludeKeys?.[adp] ? l.filter((r) => !excludeKeys[adp].includes(r.key)).length : l.length), 0)} settings across {allAdapters.length} adapters
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-[#777] max-w-3xl">
|
<p className="text-xs text-[#777] max-w-3xl">
|
||||||
|
|
@ -198,7 +223,10 @@ export default function AdapterConfig() {
|
||||||
include_in_llm_context: true,
|
include_in_llm_context: true,
|
||||||
description: '',
|
description: '',
|
||||||
}
|
}
|
||||||
const rows = config[adapter] || []
|
const rawRows = config[adapter] || []
|
||||||
|
const rows = excludeKeys?.[adapter]
|
||||||
|
? rawRows.filter((r) => !excludeKeys[adapter].includes(r.key))
|
||||||
|
: rawRows
|
||||||
const isExpanded = expanded[adapter] ?? false
|
const isExpanded = expanded[adapter] ?? false
|
||||||
const metaId = `meta:${adapter}`
|
const metaId = `meta:${adapter}`
|
||||||
const metaStatus = saveStatus[metaId] || 'idle'
|
const metaStatus = saveStatus[metaId] || 'idle'
|
||||||
|
|
@ -220,10 +248,10 @@ export default function AdapterConfig() {
|
||||||
<h2 className="text-base font-semibold text-white">{m.display_name}</h2>
|
<h2 className="text-base font-semibold text-white">{m.display_name}</h2>
|
||||||
<code className="text-xs text-[#666]">{adapter}</code>
|
<code className="text-xs text-[#666]">{adapter}</code>
|
||||||
{rows.length > 0 && (
|
{rows.length > 0 && (
|
||||||
<span className="text-xs text-[#777] ml-1">({rows.length} settings)</span>
|
<span className="text-xs text-[#777] ml-1">({rows.length} settings{excludeKeys?.[adapter]?.length ? `, ${excludeKeys[adapter].length} curated` : ''})</span>
|
||||||
)}
|
)}
|
||||||
{rows.length === 0 && (
|
{rows.length === 0 && (
|
||||||
<span className="text-xs text-[#666] ml-1 italic">(meta only)</span>
|
<span className="text-xs text-[#666] ml-1 italic">{rawRows.length > 0 ? '(all curated)' : '(meta only)'}</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{m.description && (
|
{m.description && (
|
||||||
|
|
@ -231,17 +259,19 @@ export default function AdapterConfig() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* include_in_llm_context toggle */}
|
{/* include_in_llm_context toggle — hidden when surfaced in curated panels */}
|
||||||
<label className="flex items-center gap-2 text-xs text-[#e0e0e0] select-none">
|
{!hideLlmToggle && (
|
||||||
<input
|
<label className="flex items-center gap-2 text-xs text-[#e0e0e0] select-none">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={m.include_in_llm_context}
|
type="checkbox"
|
||||||
onChange={(e) => putMeta(adapter, { include_in_llm_context: e.target.checked })}
|
checked={m.include_in_llm_context}
|
||||||
className="w-4 h-4 accent-[#f59e0b]"
|
onChange={(e) => putMeta(adapter, { include_in_llm_context: e.target.checked })}
|
||||||
/>
|
className="w-4 h-4 accent-[#f59e0b]"
|
||||||
LLM context
|
/>
|
||||||
<SaveBadge status={metaStatus} error={saveError[metaId]} />
|
LLM context
|
||||||
</label>
|
<SaveBadge status={metaStatus} error={saveError[metaId]} />
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Expanded body */}
|
{/* Expanded body */}
|
||||||
|
|
|
||||||
|
|
@ -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
|
region_score_threshold: number
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MeshIntelligenceConfig {
|
export interface MeshIntelligenceConfig {
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
regions: RegionAnchor[]
|
regions: RegionAnchor[]
|
||||||
locality_radius_miles: number
|
locality_radius_miles: number
|
||||||
|
|
@ -267,9 +267,6 @@ const AVAILABLE_COMMANDS = [
|
||||||
{ name: 'ping', description: 'Test bot responsiveness' },
|
{ name: 'ping', description: 'Test bot responsiveness' },
|
||||||
{ name: 'clear', description: 'Clear your conversation history' },
|
{ name: 'clear', description: 'Clear your conversation history' },
|
||||||
{ name: 'reset', description: 'Reset conversation context' },
|
{ name: 'reset', description: 'Reset conversation context' },
|
||||||
{ name: 'sub', description: 'Subscribe to scheduled reports or alerts' },
|
|
||||||
{ name: 'unsub', description: 'Remove a subscription' },
|
|
||||||
{ name: 'mysubs', description: 'List your active subscriptions' },
|
|
||||||
{ name: 'alerts', description: 'Active NWS weather alerts for mesh area' },
|
{ name: 'alerts', description: 'Active NWS weather alerts for mesh area' },
|
||||||
{ name: 'solar', description: 'Space weather and HF propagation conditions' },
|
{ name: 'solar', description: 'Space weather and HF propagation conditions' },
|
||||||
{ name: 'hf', description: 'HF radio propagation (alias for !solar)' },
|
{ name: 'hf', description: 'HF radio propagation (alias for !solar)' },
|
||||||
|
|
@ -916,21 +913,9 @@ function ContextSection({ data, onChange }: { data: ContextConfig; onChange: (d:
|
||||||
/>
|
/>
|
||||||
{data.enabled && (
|
{data.enabled && (
|
||||||
<>
|
<>
|
||||||
<ChannelPicker
|
{/* Observe Channels + Ignore Nodes moved to the Meshtastic Connection
|
||||||
label="Observe Channels"
|
page ("Bot behavior" section). max_age / max_context_items remain
|
||||||
value={data.observe_channels}
|
here as general context knobs. */}
|
||||||
onChange={(v) => onChange({ ...data, observe_channels: v })}
|
|
||||||
helper="Channels to monitor (empty = all)"
|
|
||||||
info="Meshtastic channels to listen on. Leave empty to monitor all channels."
|
|
||||||
mode="multi"
|
|
||||||
/>
|
|
||||||
<NodePicker
|
|
||||||
label="Ignore Nodes"
|
|
||||||
value={data.ignore_nodes}
|
|
||||||
onChange={(v) => onChange({ ...data, ignore_nodes: v })}
|
|
||||||
helper="Nodes to exclude from context"
|
|
||||||
info="Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes."
|
|
||||||
/>
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Max Age (sec)"
|
label="Max Age (sec)"
|
||||||
|
|
@ -1421,7 +1406,7 @@ export function MeshSourcesSection({ data, onChange }: { data: MeshSourceConfig[
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MeshIntelligenceSection({ data, onChange }: { data: MeshIntelligenceConfig; onChange: (d: MeshIntelligenceConfig) => void }) {
|
export function MeshIntelligenceSection({ data, onChange }: { data: MeshIntelligenceConfig; onChange: (d: MeshIntelligenceConfig) => void }) {
|
||||||
const [expandedRegion, setExpandedRegion] = useState<number | null>(null)
|
const [expandedRegion, setExpandedRegion] = useState<number | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,8 @@
|
||||||
import { useEffect, useState, type ReactNode } from 'react'
|
import { useEffect, useState, type ReactNode } from 'react'
|
||||||
import {
|
import {
|
||||||
Cloud, Flame, Radio, Car, Mountain, Satellite, Activity, Server,
|
Cloud, Flame, Radio, Car, Mountain, Satellite, Activity, Server,
|
||||||
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info,
|
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info, Bell,
|
||||||
|
Sliders,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
|
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
|
||||||
|
|
@ -11,6 +12,8 @@ import {
|
||||||
fetchEnvStatus, fetchEnvActive,
|
fetchEnvStatus, fetchEnvActive,
|
||||||
type EnvStatus, type EnvEvent,
|
type EnvStatus, type EnvEvent,
|
||||||
} from '@/lib/api'
|
} from '@/lib/api'
|
||||||
|
import { TOGGLE_FAMILY_META, type NotificationToggle, type NotificationsConfig } from './Notifications'
|
||||||
|
import AdapterConfig, { CURATED_KEYS } from './AdapterConfig'
|
||||||
|
|
||||||
type FeedSource = 'native' | 'central'
|
type FeedSource = 'native' | 'central'
|
||||||
|
|
||||||
|
|
@ -170,12 +173,16 @@ function FeedSourceToggle({ value, onChange, disabled, centralDisabled }: {
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------- adapter panel
|
// ---------------------------------------------------------------- adapter panel
|
||||||
function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedSource, hasCentral, nativeOnly, hasKey, health, events, children }: {
|
function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedSource, hasCentral, nativeOnly, hasKey, health, events, children, llmContext, onLlmContext }: {
|
||||||
title: string; subtitle?: string
|
title: string; subtitle?: string
|
||||||
enabled: boolean; onEnabled: (v: boolean) => void
|
enabled: boolean; onEnabled: (v: boolean) => void
|
||||||
feedSource: FeedSource; onFeedSource: (v: FeedSource) => void
|
feedSource: FeedSource; onFeedSource: (v: FeedSource) => void
|
||||||
hasCentral: boolean; nativeOnly: boolean; hasKey: boolean
|
hasCentral: boolean; nativeOnly: boolean; hasKey: boolean
|
||||||
health?: FeedHealth; events?: EnvEvent[]; children?: ReactNode
|
health?: FeedHealth; events?: EnvEvent[]; children?: ReactNode
|
||||||
|
/** Current value of include_in_llm_context; undefined = not applicable */
|
||||||
|
llmContext?: boolean
|
||||||
|
/** Called when user toggles include_in_llm_context */
|
||||||
|
onLlmContext?: (v: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const centralDisabled = nativeOnly || !hasCentral
|
const centralDisabled = nativeOnly || !hasCentral
|
||||||
return (
|
return (
|
||||||
|
|
@ -186,6 +193,17 @@ function AdapterPanel({ title, subtitle, enabled, onEnabled, feedSource, onFeedS
|
||||||
{subtitle && <p className="text-xs text-[#666]">{subtitle}</p>}
|
{subtitle && <p className="text-xs text-[#666]">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
|
{onLlmContext !== undefined && (
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer select-none" title="Include this adapter's data in LLM (bot) context">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={llmContext ?? true}
|
||||||
|
onChange={(e) => onLlmContext(e.target.checked)}
|
||||||
|
className="w-3.5 h-3.5 accent-[#f59e0b]"
|
||||||
|
/>
|
||||||
|
<span className="text-[10px] uppercase tracking-wide text-[#666]">LLM</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-[10px] uppercase tracking-wide text-[#666]">source</span>
|
<span className="text-[10px] uppercase tracking-wide text-[#666]">source</span>
|
||||||
<FeedSourceToggle value={feedSource} onChange={onFeedSource} disabled={!enabled} centralDisabled={centralDisabled} />
|
<FeedSourceToggle value={feedSource} onChange={onFeedSource} disabled={!enabled} centralDisabled={centralDisabled} />
|
||||||
|
|
@ -248,6 +266,7 @@ const FAMILIES: { key: string; label: string; icon: typeof Cloud; adapters: Adap
|
||||||
{ key: 'geohazards', label: 'Geohazards', icon: Mountain, adapters: ['usgs_quake', 'usgs', 'avalanche'] },
|
{ key: 'geohazards', label: 'Geohazards', icon: Mountain, adapters: ['usgs_quake', 'usgs', 'avalanche'] },
|
||||||
{ key: 'tracking', label: 'Tracking', icon: Satellite, adapters: ['satpass'] },
|
{ key: 'tracking', label: 'Tracking', icon: Satellite, adapters: ['satpass'] },
|
||||||
{ key: 'mesh', label: 'Mesh Health', icon: Activity, adapters: [] },
|
{ key: 'mesh', label: 'Mesh Health', icon: Activity, adapters: [] },
|
||||||
|
{ key: 'family_settings', label: 'Family Settings', icon: Bell, adapters: [] },
|
||||||
]
|
]
|
||||||
|
|
||||||
// ---------------------------------------------------------------- main page
|
// ---------------------------------------------------------------- main page
|
||||||
|
|
@ -264,6 +283,12 @@ export default function Environment() {
|
||||||
const [family, setFamily] = useState('weather')
|
const [family, setFamily] = useState('weather')
|
||||||
const [adapter, setAdapter] = useState<AdapterKey | null>('nws')
|
const [adapter, setAdapter] = useState<AdapterKey | null>('nws')
|
||||||
|
|
||||||
|
// Top-level tab: 'curated' = existing panels, 'advanced' = raw key/value editor
|
||||||
|
const [pageTab, setPageTab] = useState<'curated' | 'advanced'>('curated')
|
||||||
|
|
||||||
|
// include_in_llm_context per backend adapter name — fetched from /api/adapter-meta
|
||||||
|
const [llmMeta, setLlmMeta] = useState<Record<string, boolean>>({})
|
||||||
|
|
||||||
// WFIGS/fires adapter config state
|
// WFIGS/fires adapter config state
|
||||||
const [wfigsConfig, setWfigsConfig] = useState<WfigsConfig>({
|
const [wfigsConfig, setWfigsConfig] = useState<WfigsConfig>({
|
||||||
allowed_incident_types: ['WF'],
|
allowed_incident_types: ['WF'],
|
||||||
|
|
@ -322,6 +347,13 @@ export default function Environment() {
|
||||||
})
|
})
|
||||||
const [satpassOriginal, setSatpassOriginal] = useState<string>("")
|
const [satpassOriginal, setSatpassOriginal] = useState<string>("")
|
||||||
|
|
||||||
|
// ── Notification family gating state ──────────────────────────────────────
|
||||||
|
const [notifConfig, setNotifConfig] = useState<NotificationsConfig | null>(null)
|
||||||
|
const [notifOriginal, setNotifOriginal] = useState<string>('')
|
||||||
|
const [notifSaving, setNotifSaving] = useState(false)
|
||||||
|
const [notifError, setNotifError] = useState<string | null>(null)
|
||||||
|
const [notifSuccess, setNotifSuccess] = useState<string | null>(null)
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = 'Environment — MeshAI'
|
document.title = 'Environment — MeshAI'
|
||||||
|
|
@ -332,125 +364,151 @@ export default function Environment() {
|
||||||
setEnv(data)
|
setEnv(data)
|
||||||
setOriginal(JSON.stringify(data))
|
setOriginal(JSON.stringify(data))
|
||||||
|
|
||||||
// Load adapter-config for wfigs
|
// Helper: normalize GET /api/adapter-config/{adapter} response.
|
||||||
|
// The API returns an ARRAY [{key, value}, ...]. Convert to {key: {value}} map
|
||||||
|
// so callers can read data.field?.value just like an object response.
|
||||||
|
const toMap = (arr: unknown): Record<string, { value: unknown }> => {
|
||||||
|
const result: Record<string, { value: unknown }> = {}
|
||||||
|
if (Array.isArray(arr)) {
|
||||||
|
for (const r of arr as Array<{ key: string; value: unknown }>) {
|
||||||
|
result[r.key] = { value: r.value }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load adapter-config for wfigs (array → object fix: line ~350)
|
||||||
try {
|
try {
|
||||||
const wfigsRes = await fetch("/api/adapter-config/wfigs")
|
const wfigsRes = await fetch("/api/adapter-config/wfigs")
|
||||||
if (wfigsRes.ok) {
|
if (wfigsRes.ok) {
|
||||||
const wfigsData = await wfigsRes.json()
|
const wfigsData = toMap(await wfigsRes.json())
|
||||||
const cfg: WfigsConfig = {
|
const cfg: WfigsConfig = {
|
||||||
allowed_incident_types: wfigsData.allowed_incident_types?.value ?? ['WF'],
|
allowed_incident_types: (wfigsData.allowed_incident_types?.value as string[]) ?? ['WF'],
|
||||||
freshness_seconds: wfigsData.freshness_seconds?.value ?? 0,
|
freshness_seconds: (wfigsData.freshness_seconds?.value as number) ?? 0,
|
||||||
cooldown_seconds: wfigsData.cooldown_seconds?.value ?? 28800,
|
cooldown_seconds: (wfigsData.cooldown_seconds?.value as number) ?? 28800,
|
||||||
broadcast_on_acres: wfigsData.broadcast_on_acres?.value ?? true,
|
broadcast_on_acres: (wfigsData.broadcast_on_acres?.value as boolean) ?? true,
|
||||||
broadcast_on_contained: wfigsData.broadcast_on_contained?.value ?? true,
|
broadcast_on_contained: (wfigsData.broadcast_on_contained?.value as boolean) ?? true,
|
||||||
}
|
}
|
||||||
setWfigsConfig(cfg)
|
setWfigsConfig(cfg)
|
||||||
setWfigsOriginal(JSON.stringify(cfg))
|
setWfigsOriginal(JSON.stringify(cfg))
|
||||||
}
|
}
|
||||||
} catch { /* adapter-config optional */ }
|
} catch { /* adapter-config optional */ }
|
||||||
|
|
||||||
// Load adapter-config for fires (digest settings)
|
// Load adapter-config for fires/digest (array → object fix: line ~367)
|
||||||
try {
|
try {
|
||||||
const firesRes = await fetch("/api/adapter-config/fires")
|
const firesRes = await fetch("/api/adapter-config/fires")
|
||||||
if (firesRes.ok) {
|
if (firesRes.ok) {
|
||||||
const firesData = await firesRes.json()
|
const firesData = toMap(await firesRes.json())
|
||||||
const cfg: FiresConfig = {
|
const cfg: FiresConfig = {
|
||||||
digest_enabled: firesData.digest_enabled?.value ?? true,
|
digest_enabled: (firesData.digest_enabled?.value as boolean) ?? true,
|
||||||
digest_schedule: firesData.digest_schedule?.value ?? ["06:00", "18:00"],
|
digest_schedule: (firesData.digest_schedule?.value as string[]) ?? ["06:00", "18:00"],
|
||||||
digest_timezone: firesData.digest_timezone?.value ?? "America/Boise",
|
digest_timezone: (firesData.digest_timezone?.value as string) ?? "America/Boise",
|
||||||
}
|
}
|
||||||
setFiresConfig(cfg)
|
setFiresConfig(cfg)
|
||||||
setFiresOriginal(JSON.stringify(cfg))
|
setFiresOriginal(JSON.stringify(cfg))
|
||||||
}
|
}
|
||||||
} catch { /* adapter-config optional */ }
|
} catch { /* adapter-config optional */ }
|
||||||
|
|
||||||
// Load adapter-config for tomtom_incidents
|
// Load adapter-config for tomtom_incidents (array → object fix: line ~382)
|
||||||
try {
|
try {
|
||||||
const ttRes = await fetch("/api/adapter-config/tomtom_incidents")
|
const ttRes = await fetch("/api/adapter-config/tomtom_incidents")
|
||||||
if (ttRes.ok) {
|
if (ttRes.ok) {
|
||||||
const ttData = await ttRes.json()
|
const ttData = toMap(await ttRes.json())
|
||||||
const cfg: TomtomConfig = {
|
const cfg: TomtomConfig = {
|
||||||
min_magnitude: ttData.min_magnitude?.value ?? 4,
|
min_magnitude: (ttData.min_magnitude?.value as number) ?? 4,
|
||||||
drop_non_present: ttData.drop_non_present?.value ?? true,
|
drop_non_present: (ttData.drop_non_present?.value as boolean) ?? true,
|
||||||
drop_zero_magnitude: ttData.drop_zero_magnitude?.value ?? true,
|
drop_zero_magnitude: (ttData.drop_zero_magnitude?.value as boolean) ?? true,
|
||||||
}
|
}
|
||||||
setTomtomConfig(cfg)
|
setTomtomConfig(cfg)
|
||||||
setTomtomOriginal(JSON.stringify(cfg))
|
setTomtomOriginal(JSON.stringify(cfg))
|
||||||
}
|
}
|
||||||
} catch { /* adapter-config optional */ }
|
} catch { /* adapter-config optional */ }
|
||||||
|
|
||||||
// Load adapter-config for itd_511
|
// Load adapter-config for itd_511 (array → object fix: line ~398)
|
||||||
try {
|
try {
|
||||||
const r511Res = await fetch("/api/adapter-config/itd_511")
|
const r511Res = await fetch("/api/adapter-config/itd_511")
|
||||||
if (r511Res.ok) {
|
if (r511Res.ok) {
|
||||||
const r511Data = await r511Res.json()
|
const r511Data = toMap(await r511Res.json())
|
||||||
const cfg: Roads511Config = {
|
const cfg: Roads511Config = {
|
||||||
min_severity: r511Data.min_severity?.value ?? "None",
|
min_severity: (r511Data.min_severity?.value as string) ?? "None",
|
||||||
enabled_categories: r511Data.enabled_categories?.value ?? ["incident", "closure"],
|
enabled_categories: (r511Data.enabled_categories?.value as string[]) ?? ["incident", "closure"],
|
||||||
enabled_sub_types: r511Data.enabled_sub_types?.value ?? ["accident", "road_closed", "closure", "lane_closed", "vehicle_on_fire", "flooding", "debris"],
|
enabled_sub_types: (r511Data.enabled_sub_types?.value as string[]) ?? ["accident", "road_closed", "closure", "lane_closed", "vehicle_on_fire", "flooding", "debris"],
|
||||||
}
|
}
|
||||||
setRoads511Config(cfg)
|
setRoads511Config(cfg)
|
||||||
setRoads511Original(JSON.stringify(cfg))
|
setRoads511Original(JSON.stringify(cfg))
|
||||||
}
|
}
|
||||||
} catch { /* adapter-config optional */ }
|
} catch { /* adapter-config optional */ }
|
||||||
|
|
||||||
// Load adapter-config for wzdx
|
// Load adapter-config for wzdx (array → object fix: line ~413)
|
||||||
try {
|
try {
|
||||||
const wzdxRes = await fetch("/api/adapter-config/wzdx")
|
const wzdxRes = await fetch("/api/adapter-config/wzdx")
|
||||||
if (wzdxRes.ok) {
|
if (wzdxRes.ok) {
|
||||||
const wzdxData = await wzdxRes.json()
|
const wzdxData = toMap(await wzdxRes.json())
|
||||||
const cfg: WzdxConfig = {
|
const cfg: WzdxConfig = {
|
||||||
broadcast: wzdxData.broadcast?.value ?? false,
|
broadcast: (wzdxData.broadcast?.value as boolean) ?? false,
|
||||||
min_severity: wzdxData.min_severity?.value ?? "Minor",
|
min_severity: (wzdxData.min_severity?.value as string) ?? "Minor",
|
||||||
sub_types: wzdxData.sub_types?.value ?? ["road_works", "lane_closed", "road_closed"],
|
sub_types: (wzdxData.sub_types?.value as string[]) ?? ["road_works", "lane_closed", "road_closed"],
|
||||||
}
|
}
|
||||||
setWzdxConfig(cfg)
|
setWzdxConfig(cfg)
|
||||||
setWzdxOriginal(JSON.stringify(cfg))
|
setWzdxOriginal(JSON.stringify(cfg))
|
||||||
}
|
}
|
||||||
} catch { /* adapter-config optional */ }
|
} catch { /* adapter-config optional */ }
|
||||||
|
|
||||||
// Load adapter-config for nws
|
// Load adapter-config for nws (array → object fix: line ~427)
|
||||||
try {
|
try {
|
||||||
const nwsRes = await fetch("/api/adapter-config/nws")
|
const nwsRes = await fetch("/api/adapter-config/nws")
|
||||||
if (nwsRes.ok) {
|
if (nwsRes.ok) {
|
||||||
const nwsData = await nwsRes.json()
|
const nwsData = toMap(await nwsRes.json())
|
||||||
const cfg: NwsConfig = {
|
const cfg: NwsConfig = {
|
||||||
broadcast_severities: nwsData.broadcast_severities?.value ?? ["Extreme", "Severe"],
|
broadcast_severities: (nwsData.broadcast_severities?.value as string[]) ?? ["Extreme", "Severe"],
|
||||||
duplicate_allowed_after_seconds: nwsData.duplicate_allowed_after_seconds?.value ?? 3600,
|
duplicate_allowed_after_seconds: (nwsData.duplicate_allowed_after_seconds?.value as number) ?? 3600,
|
||||||
}
|
}
|
||||||
setNwsConfig(cfg)
|
setNwsConfig(cfg)
|
||||||
setNwsOriginal(JSON.stringify(cfg))
|
setNwsOriginal(JSON.stringify(cfg))
|
||||||
}
|
}
|
||||||
} catch { /* adapter-config optional */ }
|
} catch { /* adapter-config optional */ }
|
||||||
|
|
||||||
// Load adapter-config for avalanche
|
// Load adapter-config for avalanche (array → object fix: line ~441)
|
||||||
try {
|
try {
|
||||||
const avyRes = await fetch("/api/adapter-config/avalanche")
|
const avyRes = await fetch("/api/adapter-config/avalanche")
|
||||||
if (avyRes.ok) {
|
if (avyRes.ok) {
|
||||||
const avyData = await avyRes.json()
|
const avyData = toMap(await avyRes.json())
|
||||||
const cfg: AvalancheConfig = {
|
const cfg: AvalancheConfig = {
|
||||||
min_danger_level: avyData.min_danger_level?.value ?? 3,
|
min_danger_level: (avyData.min_danger_level?.value as number) ?? 3,
|
||||||
}
|
}
|
||||||
setAvalancheConfig(cfg)
|
setAvalancheConfig(cfg)
|
||||||
setAvalancheOriginal(JSON.stringify(cfg))
|
setAvalancheOriginal(JSON.stringify(cfg))
|
||||||
}
|
}
|
||||||
} catch { /* adapter-config optional */ }
|
} catch { /* adapter-config optional */ }
|
||||||
|
|
||||||
// Load adapter-config for swpc
|
// Load adapter-config for swpc (array → object fix: line ~453)
|
||||||
try {
|
try {
|
||||||
const swpcRes = await fetch("/api/adapter-config/swpc")
|
const swpcRes = await fetch("/api/adapter-config/swpc")
|
||||||
if (swpcRes.ok) {
|
if (swpcRes.ok) {
|
||||||
const swpcData = await swpcRes.json()
|
const swpcData = toMap(await swpcRes.json())
|
||||||
const cfg: SwpcConfig = {
|
const cfg: SwpcConfig = {
|
||||||
geomag_kp_floor: swpcData.geomag_kp_floor?.value ?? 7.0,
|
geomag_kp_floor: (swpcData.geomag_kp_floor?.value as number) ?? 7.0,
|
||||||
flare_class_floor: swpcData.flare_class_floor?.value ?? "X1",
|
flare_class_floor: (swpcData.flare_class_floor?.value as string) ?? "X1",
|
||||||
proton_pfu_floor: swpcData.proton_pfu_floor?.value ?? 10.0,
|
proton_pfu_floor: (swpcData.proton_pfu_floor?.value as number) ?? 10.0,
|
||||||
}
|
}
|
||||||
setSwpcConfig(cfg)
|
setSwpcConfig(cfg)
|
||||||
setSwpcOriginal(JSON.stringify(cfg))
|
setSwpcOriginal(JSON.stringify(cfg))
|
||||||
}
|
}
|
||||||
} catch { /* adapter-config optional */ }
|
} catch { /* adapter-config optional */ }
|
||||||
|
|
||||||
|
// Load adapter-meta for include_in_llm_context per adapter
|
||||||
|
try {
|
||||||
|
const metaRes = await fetch('/api/adapter-meta')
|
||||||
|
if (metaRes.ok) {
|
||||||
|
const metaData = await metaRes.json() as Record<string, { include_in_llm_context?: boolean }>
|
||||||
|
const llmMap: Record<string, boolean> = {}
|
||||||
|
for (const [k, v] of Object.entries(metaData)) {
|
||||||
|
llmMap[k] = v.include_in_llm_context ?? true
|
||||||
|
}
|
||||||
|
setLlmMeta(llmMap)
|
||||||
|
}
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
|
||||||
// Load adapter-config for satpass
|
// Load adapter-config for satpass
|
||||||
try {
|
try {
|
||||||
const satpassRes = await fetch("/api/adapter-config/satpass")
|
const satpassRes = await fetch("/api/adapter-config/satpass")
|
||||||
|
|
@ -479,6 +537,20 @@ export default function Environment() {
|
||||||
})()
|
})()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
// Fetch notification family gating config separately (best-effort)
|
||||||
|
useEffect(() => {
|
||||||
|
;(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/config/notifications')
|
||||||
|
if (res.ok) {
|
||||||
|
const data: NotificationsConfig = await res.json()
|
||||||
|
setNotifConfig(data)
|
||||||
|
setNotifOriginal(JSON.stringify(data))
|
||||||
|
}
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
})()
|
||||||
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
try {
|
try {
|
||||||
|
|
@ -516,6 +588,18 @@ export default function Environment() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-save include_in_llm_context toggle via /api/adapter-meta/{adapter}
|
||||||
|
const saveLlmContext = async (adapterName: string, val: boolean) => {
|
||||||
|
setLlmMeta((prev) => ({ ...prev, [adapterName]: val }))
|
||||||
|
try {
|
||||||
|
await fetch(`/api/adapter-meta/${adapterName}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ include_in_llm_context: val }),
|
||||||
|
})
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!env) return
|
if (!env) return
|
||||||
setSaving(true); setError(null); setSuccess(null)
|
setSaving(true); setError(null); setSuccess(null)
|
||||||
|
|
@ -702,6 +786,86 @@ const save = async () => {
|
||||||
|
|
||||||
const up = (patch: Partial<EnvConfig>) => env && setEnv({ ...env, ...patch })
|
const up = (patch: Partial<EnvConfig>) => env && setEnv({ ...env, ...patch })
|
||||||
|
|
||||||
|
// Maps from Environment.tsx adapter key → backend adapter-meta name.
|
||||||
|
// Used to read/write include_in_llm_context per adapter panel.
|
||||||
|
const PANEL_META_KEY: Partial<Record<AdapterKey, string>> = {
|
||||||
|
nws: 'nws',
|
||||||
|
fires: 'wfigs',
|
||||||
|
firms: 'firms',
|
||||||
|
swpc: 'swpc',
|
||||||
|
ducting: 'ducting',
|
||||||
|
traffic: 'tomtom_incidents',
|
||||||
|
roads511: 'itd_511',
|
||||||
|
wzdx: 'wzdx',
|
||||||
|
usgs: 'usgs',
|
||||||
|
usgs_quake: 'usgs_quake',
|
||||||
|
avalanche: 'avalanche',
|
||||||
|
satpass: 'satpass',
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Notification family gating helpers ────────────────────────────────────
|
||||||
|
const notifToggles: Record<string, NotificationToggle> = notifConfig?.toggles || {}
|
||||||
|
const notifHasChanges = notifConfig !== null && JSON.stringify(notifConfig) !== notifOriginal
|
||||||
|
|
||||||
|
const updNotif = (fam: string, patch: Partial<NotificationToggle>) => {
|
||||||
|
if (!notifConfig) return
|
||||||
|
const t = notifConfig.toggles || {}
|
||||||
|
setNotifConfig({
|
||||||
|
...notifConfig,
|
||||||
|
toggles: {
|
||||||
|
...t,
|
||||||
|
[fam]: { ...(t[fam] || {}), name: fam, ...patch } as NotificationToggle,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveNotif = async () => {
|
||||||
|
if (!notifConfig) return
|
||||||
|
setNotifSaving(true)
|
||||||
|
setNotifError(null)
|
||||||
|
setNotifSuccess(null)
|
||||||
|
try {
|
||||||
|
// Re-fetch and merge only gating fields, preserving all delivery fields.
|
||||||
|
const freshRes = await fetch('/api/config/notifications')
|
||||||
|
if (!freshRes.ok) throw new Error('Failed to re-fetch notifications config')
|
||||||
|
const fresh: NotificationsConfig = await freshRes.json()
|
||||||
|
const merged: NotificationsConfig = { ...fresh, toggles: { ...(fresh.toggles || {}) } }
|
||||||
|
const myToggles = notifConfig.toggles || {}
|
||||||
|
for (const { key } of TOGGLE_FAMILY_META) {
|
||||||
|
const mine = myToggles[key]
|
||||||
|
if (!mine) continue
|
||||||
|
const freshT = (fresh.toggles || {})[key] || {}
|
||||||
|
merged.toggles![key] = {
|
||||||
|
...freshT,
|
||||||
|
name: (freshT as NotificationToggle).name || key,
|
||||||
|
enabled: mine.enabled,
|
||||||
|
min_severity: mine.min_severity,
|
||||||
|
freshness_seconds: mine.freshness_seconds ?? (freshT as NotificationToggle).freshness_seconds ?? 600,
|
||||||
|
cooldown_seconds: mine.cooldown_seconds ?? (freshT as NotificationToggle).cooldown_seconds ?? 0,
|
||||||
|
} as NotificationToggle
|
||||||
|
}
|
||||||
|
const res = await fetch('/api/config/notifications', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(merged),
|
||||||
|
})
|
||||||
|
const result = await res.json()
|
||||||
|
if (!res.ok) throw new Error(result.detail || 'Save failed')
|
||||||
|
setNotifConfig(merged)
|
||||||
|
setNotifOriginal(JSON.stringify(merged))
|
||||||
|
setNotifSuccess('Family settings saved')
|
||||||
|
setTimeout(() => setNotifSuccess(null), 3000)
|
||||||
|
} catch (e) {
|
||||||
|
setNotifError(e instanceof Error ? e.message : 'Save failed')
|
||||||
|
} finally {
|
||||||
|
setNotifSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const discardNotif = () => {
|
||||||
|
if (notifOriginal) setNotifConfig(JSON.parse(notifOriginal))
|
||||||
|
}
|
||||||
|
|
||||||
if (loading) return <div className="flex items-center justify-center h-64 text-[#777]">Loading environmental config…</div>
|
if (loading) return <div className="flex items-center justify-center h-64 text-[#777]">Loading environmental config…</div>
|
||||||
if (!env) return <div className="flex items-center justify-center h-64 text-red-400">{error || 'No config'}</div>
|
if (!env) return <div className="flex items-center justify-center h-64 text-red-400">{error || 'No config'}</div>
|
||||||
|
|
||||||
|
|
@ -836,6 +1000,32 @@ const save = async () => {
|
||||||
<NumberInput label="Update Cooldown (hours)" value={Math.round(wfigsConfig.cooldown_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, cooldown_seconds: v * 3600 })} min={0} helper="Minimum hours between updates for the same fire" />
|
<NumberInput label="Update Cooldown (hours)" value={Math.round(wfigsConfig.cooldown_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, cooldown_seconds: v * 3600 })} min={0} helper="Minimum hours between updates for the same fire" />
|
||||||
<NumberInput label="Freshness Window (hours)" value={Math.round(wfigsConfig.freshness_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, freshness_seconds: v * 3600 })} min={0} helper="0 = always broadcast regardless of event age" />
|
<NumberInput label="Freshness Window (hours)" value={Math.round(wfigsConfig.freshness_seconds / 3600)} onChange={(v) => setWfigsConfig({ ...wfigsConfig, freshness_seconds: v * 3600 })} min={0} helper="0 = always broadcast regardless of event age" />
|
||||||
</div>
|
</div>
|
||||||
|
<div className="border-t border-border pt-4 mt-2">
|
||||||
|
<div className="text-[10px] font-sans font-medium uppercase tracking-widest text-[#666] mb-3">Fire Digest</div>
|
||||||
|
<label className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-sans text-[#e0e0e0]">Enable daily digest</span>
|
||||||
|
<input type="checkbox" checked={firesConfig.digest_enabled}
|
||||||
|
onChange={(e) => setFiresConfig({ ...firesConfig, digest_enabled: e.target.checked })}
|
||||||
|
className="w-4 h-4 accent-[#f59e0b]" />
|
||||||
|
</label>
|
||||||
|
{firesConfig.digest_enabled && (
|
||||||
|
<div className="mt-3 space-y-3">
|
||||||
|
<ListInput label="Schedule (HH:MM)" value={firesConfig.digest_schedule}
|
||||||
|
onChange={(v) => setFiresConfig({ ...firesConfig, digest_schedule: v })}
|
||||||
|
helper="Digest times in HH:MM format, e.g. 06:00 and 18:00" />
|
||||||
|
<SelectInput label="Timezone" value={firesConfig.digest_timezone}
|
||||||
|
onChange={(v) => setFiresConfig({ ...firesConfig, digest_timezone: v })}
|
||||||
|
options={[
|
||||||
|
{ value: 'America/Boise', label: 'Mountain — America/Boise' },
|
||||||
|
{ value: 'America/Los_Angeles', label: 'Pacific — America/Los_Angeles' },
|
||||||
|
{ value: 'America/Denver', label: 'Mountain — America/Denver' },
|
||||||
|
{ value: 'America/Chicago', label: 'Central — America/Chicago' },
|
||||||
|
{ value: 'America/New_York', label: 'Eastern — America/New_York' },
|
||||||
|
{ value: 'UTC', label: 'UTC' },
|
||||||
|
]} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
case 'avalanche': return (
|
case 'avalanche': return (
|
||||||
|
|
@ -1168,17 +1358,21 @@ const save = async () => {
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Header + master enable + save bar */}
|
{/* Header + master enable + save bar */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<h1 className="text-xl font-semibold text-white">Environment</h1>
|
<h1 className="text-xl font-semibold text-white">Data Feeds</h1>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Toggle label="Feeds Enabled" checked={env.enabled} onChange={(v) => up({ enabled: v })} />
|
{pageTab === 'curated' && (
|
||||||
{hasChanges && (
|
|
||||||
<>
|
<>
|
||||||
<button onClick={discard} className="flex items-center gap-1 px-3 py-1.5 text-sm text-[#777] hover:text-white border border-border">
|
<Toggle label="Feeds Enabled" checked={env.enabled} onChange={(v) => up({ enabled: v })} />
|
||||||
<RotateCcw size={14} /> Discard
|
{hasChanges && (
|
||||||
</button>
|
<>
|
||||||
<button onClick={save} disabled={saving} className="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white disabled:opacity-50">
|
<button onClick={discard} className="flex items-center gap-1 px-3 py-1.5 text-sm text-[#777] hover:text-white border border-border">
|
||||||
<Save size={14} /> {saving ? 'Saving…' : 'Save'}
|
<RotateCcw size={14} /> Discard
|
||||||
</button>
|
</button>
|
||||||
|
<button onClick={save} disabled={saving} className="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white disabled:opacity-50">
|
||||||
|
<Save size={14} /> {saving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -1193,6 +1387,33 @@ const save = async () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Top-level tab bar: Data Feeds (curated) | Advanced (raw key/value editor) */}
|
||||||
|
<div className="flex gap-1 border-b border-border">
|
||||||
|
<button
|
||||||
|
onClick={() => setPageTab('curated')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${pageTab === 'curated' ? 'border-accent text-accent' : 'border-transparent text-[#777] hover:text-white'}`}>
|
||||||
|
<Cloud size={15} /> Data Feeds
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setPageTab('advanced')}
|
||||||
|
className={`flex items-center gap-2 px-4 py-2 text-sm border-b-2 -mb-px transition-colors ${pageTab === 'advanced' ? 'border-accent text-accent' : 'border-transparent text-[#777] hover:text-white'}`}>
|
||||||
|
<Sliders size={15} /> Advanced (raw)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Advanced tab: raw key/value editor, curated keys filtered out */}
|
||||||
|
{pageTab === 'advanced' && (
|
||||||
|
<div className="-mx-6">
|
||||||
|
<div className="px-6 pb-2 text-xs text-[#777]">
|
||||||
|
Curated keys (owned by the Data Feeds panels above) are hidden here.
|
||||||
|
Future or unknown keys from adapters will appear in this view.
|
||||||
|
</div>
|
||||||
|
<AdapterConfig excludeKeys={CURATED_KEYS} hideLlmToggle />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Curated panels — only shown in curated tab */}
|
||||||
|
{pageTab === 'curated' && <>
|
||||||
|
|
||||||
{/* Family tabs */}
|
{/* Family tabs */}
|
||||||
<div className="flex gap-1 border-b border-border overflow-x-auto">
|
<div className="flex gap-1 border-b border-border overflow-x-auto">
|
||||||
|
|
@ -1248,6 +1469,87 @@ const save = async () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Family Settings — notification gating per family ──────────────── */}
|
||||||
|
{family === 'family_settings' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<p className="text-xs text-[#777]">
|
||||||
|
Per-family gating: enable/disable each notification family, set its minimum severity threshold,
|
||||||
|
freshness window, and cooldown. Delivery routing (which mesh channels, email, webhook) is
|
||||||
|
configured on the <a href="/notifications" className="text-accent hover:underline">Meshtastic Routing</a> and{' '}
|
||||||
|
<a href="/meshcore/routing" className="text-accent hover:underline">MeshCore Routing</a> pages.
|
||||||
|
Regions filter is dormant and intentionally hidden.
|
||||||
|
</p>
|
||||||
|
{notifError && <div className="text-sm text-red-400 bg-red-500/10 p-3">{notifError}</div>}
|
||||||
|
{notifSuccess && <div className="text-sm text-green-400 bg-green-500/10 p-3">{notifSuccess}</div>}
|
||||||
|
{notifConfig === null ? (
|
||||||
|
<div className="text-xs text-[#666] italic">Loading family settings…</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
|
||||||
|
const t: NotificationToggle = notifToggles[key] || ({} as NotificationToggle)
|
||||||
|
return (
|
||||||
|
<div key={key} className="border border-border p-3 space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-[#e0e0e0]">
|
||||||
|
<Icon size={15} /> {label}
|
||||||
|
</div>
|
||||||
|
<Toggle label="" checked={!!t.enabled} onChange={(v) => updNotif(key, { enabled: v })} />
|
||||||
|
</div>
|
||||||
|
<div className={t.enabled ? 'space-y-3' : 'space-y-3 opacity-40 pointer-events-none select-none'}>
|
||||||
|
<SelectInput
|
||||||
|
label="Min Severity"
|
||||||
|
value={t.min_severity || 'priority'}
|
||||||
|
onChange={(v) => updNotif(key, { min_severity: v })}
|
||||||
|
options={[
|
||||||
|
{ value: 'routine', label: 'Routine — informational' },
|
||||||
|
{ value: 'priority', label: 'Priority — needs attention' },
|
||||||
|
{ value: 'immediate', label: 'Immediate — act now' },
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<NumberInput
|
||||||
|
label="Freshness (sec)"
|
||||||
|
value={t.freshness_seconds ?? 600}
|
||||||
|
onChange={(v) => updNotif(key, { freshness_seconds: v })}
|
||||||
|
min={0}
|
||||||
|
helper="Drop events older than this"
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Cooldown (sec)"
|
||||||
|
value={t.cooldown_seconds ?? 0}
|
||||||
|
onChange={(v) => updNotif(key, { cooldown_seconds: v })}
|
||||||
|
min={0}
|
||||||
|
helper="0 = no throttle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{notifHasChanges && (
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<button
|
||||||
|
onClick={discardNotif}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm text-[#777] hover:text-white border border-border"
|
||||||
|
>
|
||||||
|
<RotateCcw size={14} /> Discard
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveNotif}
|
||||||
|
disabled={notifSaving}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<Save size={14} /> {notifSaving ? 'Saving…' : 'Save'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Adapter sub-tabs + panel */}
|
{/* Adapter sub-tabs + panel */}
|
||||||
{fam.adapters.length > 0 && activeAdapter && (
|
{fam.adapters.length > 0 && activeAdapter && (
|
||||||
<>
|
<>
|
||||||
|
|
@ -1273,11 +1575,15 @@ const save = async () => {
|
||||||
hasKey={META[activeAdapter].hasKey}
|
hasKey={META[activeAdapter].hasKey}
|
||||||
health={healthFor(activeAdapter)}
|
health={healthFor(activeAdapter)}
|
||||||
events={eventsFor(activeAdapter)}
|
events={eventsFor(activeAdapter)}
|
||||||
|
llmContext={PANEL_META_KEY[activeAdapter] !== undefined ? (llmMeta[PANEL_META_KEY[activeAdapter]!] ?? true) : undefined}
|
||||||
|
onLlmContext={PANEL_META_KEY[activeAdapter] !== undefined ? (v) => saveLlmContext(PANEL_META_KEY[activeAdapter]!, v) : undefined}
|
||||||
>
|
>
|
||||||
{renderSettings(activeAdapter)}
|
{renderSettings(activeAdapter)}
|
||||||
</AdapterPanel>
|
</AdapterPanel>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
</> /* end curated tab */}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Link } from 'react-router-dom'
|
import { Link } from 'react-router-dom'
|
||||||
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
|
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
|
||||||
import { TextInput, NumberInput } from './Config'
|
import { TextInput, NumberInput, Toggle, ListInput } from './Config'
|
||||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||||
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
|
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, getMeshcoreChannels, sendTestMessage } from '@/lib/api'
|
||||||
import { useDirty } from '@/context/DirtyContext'
|
import { useDirty } from '@/context/DirtyContext'
|
||||||
|
|
@ -19,10 +19,24 @@ interface ConnectionConfig {
|
||||||
[key: string]: unknown
|
[key: string]: unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MeshCore-native "Bot behavior" config (section `meshcore_context`).
|
||||||
|
// observe_channels are channel NAMES; ignore_contacts are contact names or
|
||||||
|
// pubkey prefixes. Unknown fields are preserved on save via object spread.
|
||||||
|
interface MeshcoreContextCfg {
|
||||||
|
enable_passive_context?: boolean
|
||||||
|
observe_channels?: string[]
|
||||||
|
ignore_contacts?: string[]
|
||||||
|
respond_to_dms?: boolean
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export default function MeshCoreConnection() {
|
export default function MeshCoreConnection() {
|
||||||
const { setDirty } = useDirty()
|
const { setDirty } = useDirty()
|
||||||
const [config, setConfig] = useState<ConnectionConfig | null>(null)
|
const [config, setConfig] = useState<ConnectionConfig | null>(null)
|
||||||
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
|
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
|
||||||
|
// Bot behavior (section `meshcore_context`)
|
||||||
|
const [mcContext, setMcContext] = useState<MeshcoreContextCfg | null>(null)
|
||||||
|
const [originalMcContext, setOriginalMcContext] = useState<MeshcoreContextCfg | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
@ -40,9 +54,14 @@ export default function MeshCoreConnection() {
|
||||||
const fetchConfig = useCallback(async () => {
|
const fetchConfig = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = (await apiFetchConfig('connection')) as ConnectionConfig
|
const [data, mcCtx] = await Promise.all([
|
||||||
|
apiFetchConfig('connection') as Promise<ConnectionConfig>,
|
||||||
|
apiFetchConfig('meshcore_context') as Promise<MeshcoreContextCfg>,
|
||||||
|
])
|
||||||
setConfig(data)
|
setConfig(data)
|
||||||
setOriginalConfig(JSON.parse(JSON.stringify(data)))
|
setOriginalConfig(JSON.parse(JSON.stringify(data)))
|
||||||
|
setMcContext(mcCtx)
|
||||||
|
setOriginalMcContext(JSON.parse(JSON.stringify(mcCtx)))
|
||||||
setHasChanges(false)
|
setHasChanges(false)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -87,10 +106,13 @@ export default function MeshCoreConnection() {
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config && originalConfig) {
|
if (config && originalConfig && mcContext && originalMcContext) {
|
||||||
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
|
const changed =
|
||||||
|
JSON.stringify(config) !== JSON.stringify(originalConfig) ||
|
||||||
|
JSON.stringify(mcContext) !== JSON.stringify(originalMcContext)
|
||||||
|
setHasChanges(changed)
|
||||||
}
|
}
|
||||||
}, [config, originalConfig])
|
}, [config, originalConfig, mcContext, originalMcContext])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDirty(hasChanges)
|
setDirty(hasChanges)
|
||||||
|
|
@ -101,18 +123,23 @@ export default function MeshCoreConnection() {
|
||||||
setConfig((c) => (c ? { ...c, ...patch } : c))
|
setConfig((c) => (c ? { ...c, ...patch } : c))
|
||||||
|
|
||||||
const saveConfig = async () => {
|
const saveConfig = async () => {
|
||||||
if (!config) return
|
if (!config || !mcContext) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
try {
|
try {
|
||||||
// PUT the whole connection object so Meshtastic fields are preserved.
|
// PUT the whole objects so sibling fields (Meshtastic connection fields,
|
||||||
const result = await apiUpdateConfig('connection', config)
|
// any other meshcore_context keys) are preserved.
|
||||||
|
const results = await Promise.all([
|
||||||
|
apiUpdateConfig('connection', config),
|
||||||
|
apiUpdateConfig('meshcore_context', mcContext),
|
||||||
|
])
|
||||||
setOriginalConfig(JSON.parse(JSON.stringify(config)))
|
setOriginalConfig(JSON.parse(JSON.stringify(config)))
|
||||||
|
setOriginalMcContext(JSON.parse(JSON.stringify(mcContext)))
|
||||||
setHasChanges(false)
|
setHasChanges(false)
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
setSuccess('MeshCore connection saved successfully')
|
setSuccess('MeshCore connection saved successfully')
|
||||||
if (result.restart_required) {
|
if (results.some((r) => r.restart_required)) {
|
||||||
notifyRestartRequired([])
|
notifyRestartRequired([])
|
||||||
}
|
}
|
||||||
setTimeout(() => setSuccess(null), 3000)
|
setTimeout(() => setSuccess(null), 3000)
|
||||||
|
|
@ -124,10 +151,20 @@ export default function MeshCoreConnection() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const discardChanges = () => {
|
const discardChanges = () => {
|
||||||
if (originalConfig) {
|
if (originalConfig) setConfig(JSON.parse(JSON.stringify(originalConfig)))
|
||||||
setConfig(JSON.parse(JSON.stringify(originalConfig)))
|
if (originalMcContext) setMcContext(JSON.parse(JSON.stringify(originalMcContext)))
|
||||||
setHasChanges(false)
|
setHasChanges(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleObserveChannel = (name: string) => {
|
||||||
|
setMcContext((c) => {
|
||||||
|
if (!c) return c
|
||||||
|
const current = c.observe_channels ?? []
|
||||||
|
const next = current.includes(name)
|
||||||
|
? current.filter((n) => n !== name)
|
||||||
|
: [...current, name]
|
||||||
|
return { ...c, observe_channels: next }
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -228,6 +265,63 @@ export default function MeshCoreConnection() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bot behavior card — mirrors the Meshtastic Connection page, MeshCore-native */}
|
||||||
|
{mcContext && (
|
||||||
|
<div className="bg-bg-card border border-border p-6 space-y-4">
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wide">Bot behavior</div>
|
||||||
|
<Toggle
|
||||||
|
label="Enable Passive Context"
|
||||||
|
checked={!!mcContext.enable_passive_context}
|
||||||
|
onChange={(v) => setMcContext({ ...mcContext, enable_passive_context: v })}
|
||||||
|
helper="Listen to MeshCore channel traffic for context"
|
||||||
|
info="When enabled, the bot monitors MeshCore channels and includes recent messages in its context so it can reference what others said."
|
||||||
|
/>
|
||||||
|
{/* Observe MeshCore channels — multi-select of channel NAMES (empty = observe all) */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<label className="block text-xs text-slate-500 uppercase tracking-wide">Observe MeshCore Channels</label>
|
||||||
|
<div className="border border-[#1e2a3a] p-2 space-y-1">
|
||||||
|
{channels.map((ch) => {
|
||||||
|
const selected = (mcContext.observe_channels ?? []).includes(ch)
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={ch}
|
||||||
|
onClick={() => toggleObserveChannel(ch)}
|
||||||
|
className="flex items-center gap-2 p-2 rounded hover:bg-[#0a0e17] cursor-pointer"
|
||||||
|
>
|
||||||
|
<div className={`w-4 h-4 rounded border flex items-center justify-center ${
|
||||||
|
selected ? 'bg-accent border-accent' : 'border-slate-600'
|
||||||
|
}`}>
|
||||||
|
{selected && <Check size={12} className="text-white" />}
|
||||||
|
</div>
|
||||||
|
<span className="text-sm text-slate-200">{ch}</span>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{channels.length === 0 && (
|
||||||
|
<div className="text-sm text-slate-500 p-2">
|
||||||
|
No channels available{!channelsActive ? ' (MeshCore not connected)' : ''}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-600">Channels to monitor (none selected = observe all)</p>
|
||||||
|
</div>
|
||||||
|
<ListInput
|
||||||
|
label="Ignore MeshCore Contacts"
|
||||||
|
value={mcContext.ignore_contacts ?? []}
|
||||||
|
onChange={(v) => setMcContext({ ...mcContext, ignore_contacts: v })}
|
||||||
|
helper="Contact names or pubkey prefixes to exclude from context (comma-separated)"
|
||||||
|
info="Messages from these MeshCore contacts won't be included in passive context. Enter contact names or public-key prefixes."
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Respond to DMs"
|
||||||
|
checked={!!mcContext.respond_to_dms}
|
||||||
|
onChange={(v) => setMcContext({ ...mcContext, respond_to_dms: v })}
|
||||||
|
helper="Reply when someone sends a MeshCore direct message"
|
||||||
|
info="When enabled, the bot responds to MeshCore direct messages. When disabled, it only responds to channel messages that mention its name."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Send test message card */}
|
{/* Send test message card */}
|
||||||
<div className={`bg-bg-card border border-border p-6 space-y-4${!channelsActive ? ' opacity-60' : ''}`}>
|
<div className={`bg-bg-card border border-border p-6 space-y-4${!channelsActive ? ' opacity-60' : ''}`}>
|
||||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>
|
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Cross-link note: shared family settings live on the Meshtastic Routing page */}
|
{/* Cross-link note: gating on Data Feeds, MT+Other delivery on Meshtastic Routing */}
|
||||||
<div className="flex items-start gap-2 p-3 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-400">
|
<div className="flex items-start gap-2 p-3 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-400">
|
||||||
<ExternalLink size={16} className="text-accent mt-0.5 flex-shrink-0" />
|
<ExternalLink size={16} className="text-accent mt-0.5 flex-shrink-0" />
|
||||||
<div>
|
<div>
|
||||||
Shared per-family settings (enable, severity threshold, regions, freshness/cooldown, and
|
Family gating (enable, severity threshold, freshness/cooldown) is on{' '}
|
||||||
Meshtastic / email / webhook delivery) live on the{' '}
|
<Link to="/environment" className="text-accent hover:underline">
|
||||||
|
Data Feeds
|
||||||
|
</Link>
|
||||||
|
. Meshtastic and email/webhook/digest delivery is on{' '}
|
||||||
<Link to="/notifications" className="text-accent hover:underline">
|
<Link to="/notifications" className="text-accent hover:underline">
|
||||||
Meshtastic Routing
|
Meshtastic Routing
|
||||||
</Link>{' '}
|
</Link>
|
||||||
page. This page edits only the MeshCore delivery for each family.
|
. This page edits only the MeshCore delivery for each family.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -225,7 +228,7 @@ export default function MeshCoreRouting() {
|
||||||
<div className="bg-bg-card border border-border p-6 space-y-4">
|
<div className="bg-bg-card border border-border p-6 space-y-4">
|
||||||
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||||
MeshCore Delivery
|
MeshCore Delivery
|
||||||
<InfoButton info="For each notification family, choose which MeshCore channels fire at each severity, the MeshCore channel name to broadcast on, and the DM contacts to unicast to. Enabling a family and its severity threshold are set on the Meshtastic Routing page." />
|
<InfoButton info="For each notification family, choose which MeshCore channels fire at each severity, the MeshCore channel name to broadcast on, and the DM contacts to unicast to. Enabling a family and its severity threshold are configured on the Data Feeds page." />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
|
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,36 @@
|
||||||
import { useState, useEffect, useCallback } from 'react'
|
import { useState, useEffect, useCallback } from 'react'
|
||||||
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
|
import { Save, RotateCcw, RefreshCw, Check } from 'lucide-react'
|
||||||
import { ConnectionSection, TextInput, NumberInput, type ConnectionConfig } from './Config'
|
import { ConnectionSection, TextInput, NumberInput, Toggle, type ConnectionConfig } from './Config'
|
||||||
|
import ChannelPicker from '@/components/ChannelPicker'
|
||||||
|
import NodePicker from '@/components/NodePicker'
|
||||||
import { notifyRestartRequired } from '@/components/RestartBanner'
|
import { notifyRestartRequired } from '@/components/RestartBanner'
|
||||||
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api'
|
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig, sendTestMessage } from '@/lib/api'
|
||||||
import { useDirty } from '@/context/DirtyContext'
|
import { useDirty } from '@/context/DirtyContext'
|
||||||
|
|
||||||
|
// Only the fields the "Bot behavior" section edits are typed explicitly; the
|
||||||
|
// rest of each section (max_age, max_context_items, MQTT fields, bot name/owner,
|
||||||
|
// etc.) is preserved untouched on save via object spread.
|
||||||
|
interface ContextCfg {
|
||||||
|
enabled?: boolean
|
||||||
|
observe_channels?: number[]
|
||||||
|
ignore_nodes?: string[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BotCfg {
|
||||||
|
respond_to_dms?: boolean
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
export default function MeshtasticConnection() {
|
export default function MeshtasticConnection() {
|
||||||
const { setDirty } = useDirty()
|
const { setDirty } = useDirty()
|
||||||
const [config, setConfig] = useState<ConnectionConfig | null>(null)
|
const [config, setConfig] = useState<ConnectionConfig | null>(null)
|
||||||
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
|
const [originalConfig, setOriginalConfig] = useState<ConnectionConfig | null>(null)
|
||||||
|
// Bot behavior: passive-context (section `context`) + respond-to-DMs (section `bot`)
|
||||||
|
const [context, setContext] = useState<ContextCfg | null>(null)
|
||||||
|
const [originalContext, setOriginalContext] = useState<ContextCfg | null>(null)
|
||||||
|
const [bot, setBot] = useState<BotCfg | null>(null)
|
||||||
|
const [originalBot, setOriginalBot] = useState<BotCfg | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [saving, setSaving] = useState(false)
|
const [saving, setSaving] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
@ -41,9 +63,17 @@ export default function MeshtasticConnection() {
|
||||||
const fetchConfig = useCallback(async () => {
|
const fetchConfig = useCallback(async () => {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const data = (await apiFetchConfig('connection')) as ConnectionConfig
|
const [conn, ctx, botData] = await Promise.all([
|
||||||
setConfig(data)
|
apiFetchConfig('connection') as Promise<ConnectionConfig>,
|
||||||
setOriginalConfig(JSON.parse(JSON.stringify(data)))
|
apiFetchConfig('context') as Promise<ContextCfg>,
|
||||||
|
apiFetchConfig('bot') as Promise<BotCfg>,
|
||||||
|
])
|
||||||
|
setConfig(conn)
|
||||||
|
setOriginalConfig(JSON.parse(JSON.stringify(conn)))
|
||||||
|
setContext(ctx)
|
||||||
|
setOriginalContext(JSON.parse(JSON.stringify(ctx)))
|
||||||
|
setBot(botData)
|
||||||
|
setOriginalBot(JSON.parse(JSON.stringify(botData)))
|
||||||
setHasChanges(false)
|
setHasChanges(false)
|
||||||
setError(null)
|
setError(null)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
|
@ -59,10 +89,14 @@ export default function MeshtasticConnection() {
|
||||||
}, [fetchConfig])
|
}, [fetchConfig])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (config && originalConfig) {
|
if (config && originalConfig && context && originalContext && bot && originalBot) {
|
||||||
setHasChanges(JSON.stringify(config) !== JSON.stringify(originalConfig))
|
const changed =
|
||||||
|
JSON.stringify(config) !== JSON.stringify(originalConfig) ||
|
||||||
|
JSON.stringify(context) !== JSON.stringify(originalContext) ||
|
||||||
|
JSON.stringify(bot) !== JSON.stringify(originalBot)
|
||||||
|
setHasChanges(changed)
|
||||||
}
|
}
|
||||||
}, [config, originalConfig])
|
}, [config, originalConfig, context, originalContext, bot, originalBot])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDirty(hasChanges)
|
setDirty(hasChanges)
|
||||||
|
|
@ -70,18 +104,25 @@ export default function MeshtasticConnection() {
|
||||||
}, [hasChanges, setDirty])
|
}, [hasChanges, setDirty])
|
||||||
|
|
||||||
const saveConfig = async () => {
|
const saveConfig = async () => {
|
||||||
if (!config) return
|
if (!config || !context || !bot) return
|
||||||
setSaving(true)
|
setSaving(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
try {
|
try {
|
||||||
// PUT the whole connection object so MeshCore fields aren't clobbered.
|
// PUT the whole objects so sibling fields (MeshCore connection fields,
|
||||||
const result = await apiUpdateConfig('connection', config)
|
// context max_age/max_context_items, bot name/owner) aren't clobbered.
|
||||||
|
const results = await Promise.all([
|
||||||
|
apiUpdateConfig('connection', config),
|
||||||
|
apiUpdateConfig('context', context),
|
||||||
|
apiUpdateConfig('bot', bot),
|
||||||
|
])
|
||||||
setOriginalConfig(JSON.parse(JSON.stringify(config)))
|
setOriginalConfig(JSON.parse(JSON.stringify(config)))
|
||||||
|
setOriginalContext(JSON.parse(JSON.stringify(context)))
|
||||||
|
setOriginalBot(JSON.parse(JSON.stringify(bot)))
|
||||||
setHasChanges(false)
|
setHasChanges(false)
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
setSuccess('Meshtastic connection saved successfully')
|
setSuccess('Meshtastic connection saved successfully')
|
||||||
if (result.restart_required) {
|
if (results.some((r) => r.restart_required)) {
|
||||||
notifyRestartRequired([])
|
notifyRestartRequired([])
|
||||||
}
|
}
|
||||||
setTimeout(() => setSuccess(null), 3000)
|
setTimeout(() => setSuccess(null), 3000)
|
||||||
|
|
@ -93,10 +134,10 @@ export default function MeshtasticConnection() {
|
||||||
}
|
}
|
||||||
|
|
||||||
const discardChanges = () => {
|
const discardChanges = () => {
|
||||||
if (originalConfig) {
|
if (originalConfig) setConfig(JSON.parse(JSON.stringify(originalConfig)))
|
||||||
setConfig(JSON.parse(JSON.stringify(originalConfig)))
|
if (originalContext) setContext(JSON.parse(JSON.stringify(originalContext)))
|
||||||
setHasChanges(false)
|
if (originalBot) setBot(JSON.parse(JSON.stringify(originalBot)))
|
||||||
}
|
setHasChanges(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
|
|
@ -167,6 +208,42 @@ export default function MeshtasticConnection() {
|
||||||
<ConnectionSection data={config} onChange={setConfig} />
|
<ConnectionSection data={config} onChange={setConfig} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bot behavior card */}
|
||||||
|
{context && bot && (
|
||||||
|
<div className="bg-bg-card border border-border p-6 space-y-4">
|
||||||
|
<div className="text-xs text-slate-500 uppercase tracking-wide">Bot behavior</div>
|
||||||
|
<Toggle
|
||||||
|
label="Enable Passive Context"
|
||||||
|
checked={!!context.enabled}
|
||||||
|
onChange={(v) => setContext({ ...context, enabled: v })}
|
||||||
|
helper="Listen to channel traffic for context"
|
||||||
|
info="When enabled, the bot monitors mesh channels and includes recent messages in its context. This lets the bot reference things other people said on the channel."
|
||||||
|
/>
|
||||||
|
<ChannelPicker
|
||||||
|
label="Observe Channels"
|
||||||
|
value={context.observe_channels ?? []}
|
||||||
|
onChange={(v) => setContext({ ...context, observe_channels: v })}
|
||||||
|
helper="Channels to monitor (empty = all)"
|
||||||
|
info="Meshtastic channels to listen on. Leave empty to monitor all channels."
|
||||||
|
mode="multi"
|
||||||
|
/>
|
||||||
|
<NodePicker
|
||||||
|
label="Ignore Nodes"
|
||||||
|
value={context.ignore_nodes ?? []}
|
||||||
|
onChange={(v) => setContext({ ...context, ignore_nodes: v })}
|
||||||
|
helper="Nodes to exclude from context"
|
||||||
|
info="Messages from these nodes won't be included in passive context. Useful for filtering out noisy automated nodes."
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
label="Respond to DMs"
|
||||||
|
checked={!!bot.respond_to_dms}
|
||||||
|
onChange={(v) => setBot({ ...bot, respond_to_dms: v })}
|
||||||
|
helper="Reply when someone sends a direct message"
|
||||||
|
info="When enabled, the bot responds to direct messages from any node. When disabled, the bot only responds to channel messages that mention its name."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Send test message card */}
|
{/* Send test message card */}
|
||||||
<div className="bg-bg-card border border-border p-6 space-y-4">
|
<div className="bg-bg-card border border-border p-6 space-y-4">
|
||||||
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>
|
<div className="text-xs text-slate-500 uppercase tracking-wide">Send Test Message</div>
|
||||||
|
|
|
||||||
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'
|
} from 'lucide-react'
|
||||||
import ChannelPicker from '@/components/ChannelPicker'
|
import ChannelPicker from '@/components/ChannelPicker'
|
||||||
import NodePicker from '@/components/NodePicker'
|
import NodePicker from '@/components/NodePicker'
|
||||||
import { fetchConfig as apiFetchConfig, updateConfig as apiUpdateConfig } from '@/lib/api'
|
|
||||||
import { useDirty } from '@/context/DirtyContext'
|
import { useDirty } from '@/context/DirtyContext'
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
|
|
@ -365,7 +364,7 @@ export function InfoButton({ info }: { info: string }) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Form components
|
// Form components
|
||||||
function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '', info = '' }: {
|
export function TextInput({ label, value, onChange, type = 'text', placeholder = '', helper = '', info = '' }: {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
|
|
@ -406,7 +405,7 @@ function TextInput({ label, value, onChange, type = 'text', placeholder = '', he
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function NumberInput({ label, value, onChange, min, max, step = 1, helper = '', info = '' }: {
|
export function NumberInput({ label, value, onChange, min, max, step = 1, helper = '', info = '' }: {
|
||||||
label: string
|
label: string
|
||||||
value: number
|
value: number
|
||||||
onChange: (v: number) => void
|
onChange: (v: number) => void
|
||||||
|
|
@ -436,7 +435,7 @@ function NumberInput({ label, value, onChange, min, max, step = 1, helper = '',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Toggle({ label, checked, onChange, helper = '', info = '' }: {
|
export function Toggle({ label, checked, onChange, helper = '', info = '' }: {
|
||||||
label: string
|
label: string
|
||||||
checked: boolean
|
checked: boolean
|
||||||
onChange: (v: boolean) => void
|
onChange: (v: boolean) => void
|
||||||
|
|
@ -469,7 +468,7 @@ function Toggle({ label, checked, onChange, helper = '', info = '' }: {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function TimeInput({ label, value, onChange, helper = '', info = '' }: {
|
export function TimeInput({ label, value, onChange, helper = '', info = '' }: {
|
||||||
label: string
|
label: string
|
||||||
value: string
|
value: string
|
||||||
onChange: (v: string) => void
|
onChange: (v: string) => void
|
||||||
|
|
@ -1609,150 +1608,174 @@ export function SeverityChannelMatrix({
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function MasterToggles({ toggles, onChange }: {
|
// Merge only the MT+Other-channel-owned delivery fields of `mine` into `fresh`,
|
||||||
|
// preserving MeshCore entries (meshcore_*) and all gating fields
|
||||||
|
// (enabled/min_severity/freshness_seconds/cooldown_seconds/regions) that now live
|
||||||
|
// on the Data Feeds page.
|
||||||
|
function mergeMeshtasticAndOtherFields(
|
||||||
|
fresh: NotificationToggle | undefined,
|
||||||
|
mine: NotificationToggle,
|
||||||
|
key: string,
|
||||||
|
): NotificationToggle {
|
||||||
|
const base: NotificationToggle = fresh ? { ...fresh } : { ...mine, name: key }
|
||||||
|
base.name = base.name || key
|
||||||
|
|
||||||
|
// Merge severity_channels: keep meshcore_* from fresh, overlay non-meshcore from mine.
|
||||||
|
const freshSC = fresh?.severity_channels || {}
|
||||||
|
const mineSC = mine.severity_channels || {}
|
||||||
|
const severities = new Set([...Object.keys(freshSC), ...Object.keys(mineSC)])
|
||||||
|
const mergedSC: Record<string, string[]> = {}
|
||||||
|
severities.forEach((sev) => {
|
||||||
|
const meshcoreOnly = (freshSC[sev] || []).filter((c) => c.startsWith('meshcore_'))
|
||||||
|
const nonMeshcore = (mineSC[sev] || []).filter((c) => !c.startsWith('meshcore_'))
|
||||||
|
mergedSC[sev] = [...meshcoreOnly, ...nonMeshcore]
|
||||||
|
})
|
||||||
|
base.severity_channels = mergedSC
|
||||||
|
|
||||||
|
// Overlay MT delivery fields
|
||||||
|
base.broadcast_channel = mine.broadcast_channel
|
||||||
|
base.node_ids = mine.node_ids
|
||||||
|
// Overlay Other channels delivery fields
|
||||||
|
base.smtp_host = mine.smtp_host
|
||||||
|
base.smtp_port = mine.smtp_port
|
||||||
|
base.smtp_user = mine.smtp_user
|
||||||
|
base.smtp_password = mine.smtp_password
|
||||||
|
base.smtp_tls = mine.smtp_tls
|
||||||
|
base.from_address = mine.from_address
|
||||||
|
base.recipients = mine.recipients
|
||||||
|
base.webhook_url = mine.webhook_url
|
||||||
|
base.webhook_headers = mine.webhook_headers
|
||||||
|
|
||||||
|
// NOTE: do NOT overlay gating fields (enabled, min_severity, freshness_seconds,
|
||||||
|
// cooldown_seconds, regions) — those are managed by Data Feeds > Family Settings.
|
||||||
|
return base
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always-visible per-family Meshtastic delivery grid.
|
||||||
|
// Structure mirrors MeshCoreRouting.tsx: pure delivery, no enable toggle, no expander.
|
||||||
|
function MeshtasticDeliveryGrid({
|
||||||
|
toggles,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
toggles: Record<string, NotificationToggle>
|
toggles: Record<string, NotificationToggle>
|
||||||
onChange: (t: Record<string, NotificationToggle>) => void
|
onChange: (t: Record<string, NotificationToggle>) => void
|
||||||
}) {
|
}) {
|
||||||
const [expanded, setExpanded] = useState<string | null>(null)
|
|
||||||
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
|
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
|
||||||
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
|
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3 mb-8">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||||
Master Toggles
|
Meshtastic Delivery
|
||||||
<InfoButton info="Per-family notification policy: enable a family, set its severity threshold, choose which channels fire at each severity, and scope to regions (PagerDuty/Grafana-style)." />
|
<InfoButton info="Per-family Meshtastic delivery matrix. Choose which channels fire at each severity, the broadcast channel index, and DM node IDs. Family on/off and severity threshold are configured on the Data Feeds page." />
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
|
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
|
||||||
const t = (toggles[key] || ({} as NotificationToggle))
|
const t = toggles[key] || ({} as NotificationToggle)
|
||||||
const isOpen = expanded === key
|
|
||||||
const chanCount = Object.values(t.severity_channels || {}).reduce((n, arr) => n + ((arr as string[])?.length || 0), 0)
|
|
||||||
const regionCount = (t.regions || []).length
|
|
||||||
return (
|
return (
|
||||||
<div key={key} className="border border-[#1e2a3a] p-3">
|
<div key={key} className="border border-[#1e2a3a] p-3 space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2 text-sm text-slate-200">
|
||||||
<button type="button" onClick={() => setExpanded(isOpen ? null : key)}
|
<Icon size={15} /> {label}
|
||||||
className="flex items-center gap-2 text-sm text-slate-200">
|
|
||||||
<Icon size={15} /> {label}
|
|
||||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
|
||||||
</button>
|
|
||||||
<Toggle label="" checked={!!t.enabled} onChange={(v) => upd(key, { enabled: v })} />
|
|
||||||
</div>
|
</div>
|
||||||
{!isOpen && (
|
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
|
||||||
<div className="text-xs text-slate-600 mt-1">
|
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
|
||||||
{t.enabled
|
<Radio size={13} />
|
||||||
? `${regionCount || 'all'} region${regionCount === 1 ? '' : 's'}, ${chanCount} channel${chanCount === 1 ? '' : 's'} at ${t.min_severity || 'priority'}+`
|
Meshtastic
|
||||||
: 'OFF'}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
<SeverityChannelMatrix
|
||||||
{isOpen && (
|
channels={MT_CHANNELS}
|
||||||
<div className={`mt-3 space-y-4 ${t.enabled ? '' : 'opacity-40 pointer-events-none select-none'}`}>
|
severityChannels={t.severity_channels || {}}
|
||||||
|
onChange={(sc) => upd(key, { severity_channels: sc })}
|
||||||
|
/>
|
||||||
|
<NumberInput
|
||||||
|
label="Broadcast channel"
|
||||||
|
value={t.broadcast_channel ?? 0}
|
||||||
|
onChange={(v) => upd(key, { broadcast_channel: v })}
|
||||||
|
min={0}
|
||||||
|
helper="Meshtastic channel index (0 = LongFast primary)"
|
||||||
|
info="The Meshtastic channel index used for mesh_broadcast delivery. 0 = primary channel."
|
||||||
|
/>
|
||||||
|
<ListInput
|
||||||
|
label="DM node IDs"
|
||||||
|
value={t.node_ids || []}
|
||||||
|
onChange={(v) => upd(key, { node_ids: v })}
|
||||||
|
placeholder="!hex_id"
|
||||||
|
helper="Meshtastic DM recipients (hex node IDs)"
|
||||||
|
info="Hex node IDs for mesh_dm delivery (e.g. !a1b2c3d4). Used when mesh_dm is enabled for a severity."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
{/* ── General ──────────────────────────────────────────────── */}
|
// Per-family Other Channels (email / webhook / digest) delivery grid.
|
||||||
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
|
// Kept on the Meshtastic Routing page so all non-MeshCore delivery lives in one place.
|
||||||
<div className="text-xs text-slate-500 uppercase tracking-wide">General</div>
|
function OtherChannelsGrid({
|
||||||
<SeveritySelector value={t.min_severity || 'priority'} onChange={(v) => upd(key, { min_severity: v })} />
|
toggles,
|
||||||
<ListInput
|
onChange,
|
||||||
label="Regions (empty = all)"
|
}: {
|
||||||
value={t.regions || []}
|
toggles: Record<string, NotificationToggle>
|
||||||
onChange={(v) => upd(key, { regions: v })}
|
onChange: (t: Record<string, NotificationToggle>) => void
|
||||||
placeholder="Add region..."
|
}) {
|
||||||
/>
|
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
|
||||||
<div className="grid grid-cols-2 gap-3">
|
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
|
||||||
<NumberInput
|
|
||||||
label="Freshness (sec)"
|
|
||||||
value={t.freshness_seconds ?? 600}
|
|
||||||
onChange={(v) => upd(key, { freshness_seconds: v })}
|
|
||||||
min={0}
|
|
||||||
helper="Drop events older than this"
|
|
||||||
info="Events older than this window (seconds) are discarded at dispatcher entrance. 600 = 10 min."
|
|
||||||
/>
|
|
||||||
<NumberInput
|
|
||||||
label="Cooldown (sec)"
|
|
||||||
value={t.cooldown_seconds ?? 0}
|
|
||||||
onChange={(v) => upd(key, { cooldown_seconds: v })}
|
|
||||||
min={0}
|
|
||||||
helper="0 = no throttle"
|
|
||||||
info="Per (family, category, region) throttle window. Prevents repeat sends within this window."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* ── Meshtastic ───────────────────────────────────────────── */}
|
|
||||||
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
|
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
|
|
||||||
<Radio size={13} />
|
|
||||||
Meshtastic
|
|
||||||
</div>
|
|
||||||
<SeverityChannelMatrix
|
|
||||||
channels={MT_CHANNELS}
|
|
||||||
severityChannels={t.severity_channels || {}}
|
|
||||||
onChange={(sc) => upd(key, { severity_channels: sc })}
|
|
||||||
/>
|
|
||||||
<NumberInput
|
|
||||||
label="Broadcast channel"
|
|
||||||
value={t.broadcast_channel ?? 0}
|
|
||||||
onChange={(v) => upd(key, { broadcast_channel: v })}
|
|
||||||
min={0}
|
|
||||||
helper="Meshtastic channel index (0 = LongFast primary)"
|
|
||||||
info="The Meshtastic channel index used for mesh_broadcast delivery. 0 = primary channel."
|
|
||||||
/>
|
|
||||||
<ListInput
|
|
||||||
label="DM node IDs"
|
|
||||||
value={t.node_ids || []}
|
|
||||||
onChange={(v) => upd(key, { node_ids: v })}
|
|
||||||
placeholder="!hex_id"
|
|
||||||
helper="Meshtastic DM recipients (hex node IDs)"
|
|
||||||
info="Hex node IDs for mesh_dm delivery (e.g. !a1b2c3d4). Used when mesh_dm is enabled for a severity."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* MeshCore delivery controls moved to the dedicated
|
|
||||||
MeshCore -> Routing page (/meshcore/routing). Shared
|
|
||||||
family settings (enable/severity/regions) stay here. */}
|
|
||||||
|
|
||||||
{/* ── Other channels ───────────────────────────────────────── */}
|
|
||||||
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
|
|
||||||
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
|
|
||||||
<Mail size={13} />
|
|
||||||
Other channels
|
|
||||||
</div>
|
|
||||||
<SeverityChannelMatrix
|
|
||||||
channels={OTHER_CHANNELS}
|
|
||||||
severityChannels={t.severity_channels || {}}
|
|
||||||
onChange={(sc) => upd(key, { severity_channels: sc })}
|
|
||||||
/>
|
|
||||||
<ListInput
|
|
||||||
label="Email recipients"
|
|
||||||
value={t.recipients || []}
|
|
||||||
onChange={(v) => upd(key, { recipients: v })}
|
|
||||||
placeholder="ops@example.com"
|
|
||||||
/>
|
|
||||||
<details className="group">
|
|
||||||
<summary className="flex items-center gap-2 cursor-pointer text-xs text-slate-400 hover:text-slate-200">
|
|
||||||
<ChevronRight size={12} className="group-open:rotate-90 transition-transform" />
|
|
||||||
SMTP settings
|
|
||||||
</summary>
|
|
||||||
<div className="mt-2 space-y-2 pl-4 border-l border-[#1e2a3a]">
|
|
||||||
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
|
|
||||||
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
|
|
||||||
<TextInput label="Username" value={t.smtp_user || ''} onChange={(v) => upd(key, { smtp_user: v })} />
|
|
||||||
<TextInput label="Password" value={t.smtp_password || ''} onChange={(v) => upd(key, { smtp_password: v })} type="password" />
|
|
||||||
<Toggle label="Use TLS" checked={t.smtp_tls ?? true} onChange={(v) => upd(key, { smtp_tls: v })} />
|
|
||||||
<TextInput label="From address" value={t.from_address || ''} onChange={(v) => upd(key, { from_address: v })} placeholder="alerts@example.com" />
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
<TextInput
|
|
||||||
label="Webhook URL"
|
|
||||||
value={t.webhook_url || ''}
|
|
||||||
onChange={(v) => upd(key, { webhook_url: v })}
|
|
||||||
placeholder="https://..."
|
|
||||||
helper="POST alert as JSON"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||||
|
Other Channels (Email / Webhook / Digest)
|
||||||
|
<InfoButton info="Per-family delivery via email, webhook, and digest. The severity matrix selects which of these channels fire at each level. Email SMTP settings are behind the collapsible below each family." />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
|
||||||
|
const t = toggles[key] || ({} as NotificationToggle)
|
||||||
|
return (
|
||||||
|
<div key={key} className="border border-[#1e2a3a] p-3 space-y-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm text-slate-200">
|
||||||
|
<Icon size={15} /> {label}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
|
||||||
|
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
|
||||||
|
<Mail size={13} />
|
||||||
|
Other channels
|
||||||
</div>
|
</div>
|
||||||
)}
|
<SeverityChannelMatrix
|
||||||
|
channels={OTHER_CHANNELS}
|
||||||
|
severityChannels={t.severity_channels || {}}
|
||||||
|
onChange={(sc) => upd(key, { severity_channels: sc })}
|
||||||
|
/>
|
||||||
|
<ListInput
|
||||||
|
label="Email recipients"
|
||||||
|
value={t.recipients || []}
|
||||||
|
onChange={(v) => upd(key, { recipients: v })}
|
||||||
|
placeholder="ops@example.com"
|
||||||
|
/>
|
||||||
|
<details className="group">
|
||||||
|
<summary className="flex items-center gap-2 cursor-pointer text-xs text-slate-400 hover:text-slate-200">
|
||||||
|
<ChevronRight size={12} className="group-open:rotate-90 transition-transform" />
|
||||||
|
SMTP settings
|
||||||
|
</summary>
|
||||||
|
<div className="mt-2 space-y-2 pl-4 border-l border-[#1e2a3a]">
|
||||||
|
<TextInput label="SMTP host" value={t.smtp_host || ''} onChange={(v) => upd(key, { smtp_host: v })} placeholder="smtp.example.com" />
|
||||||
|
<NumberInput label="SMTP port" value={t.smtp_port ?? 587} onChange={(v) => upd(key, { smtp_port: v })} />
|
||||||
|
<TextInput label="Username" value={t.smtp_user || ''} onChange={(v) => upd(key, { smtp_user: v })} />
|
||||||
|
<TextInput label="Password" value={t.smtp_password || ''} onChange={(v) => upd(key, { smtp_password: v })} type="password" />
|
||||||
|
<Toggle label="Use TLS" checked={t.smtp_tls ?? true} onChange={(v) => upd(key, { smtp_tls: v })} />
|
||||||
|
<TextInput label="From address" value={t.from_address || ''} onChange={(v) => upd(key, { from_address: v })} placeholder="alerts@example.com" />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<TextInput
|
||||||
|
label="Webhook URL"
|
||||||
|
value={t.webhook_url || ''}
|
||||||
|
onChange={(v) => upd(key, { webhook_url: v })}
|
||||||
|
placeholder="https://..."
|
||||||
|
helper="POST alert as JSON"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -1826,10 +1849,34 @@ export default function Notifications() {
|
||||||
setSuccess(null)
|
setSuccess(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Re-fetch the live config and merge ONLY MT+Other delivery fields so we never
|
||||||
|
// clobber gating fields (enabled/min_severity/freshness/cooldown) managed by
|
||||||
|
// Data Feeds, nor MeshCore fields managed by the MeshCore Routing page.
|
||||||
|
const freshRes = await fetch('/api/config/notifications')
|
||||||
|
if (!freshRes.ok) throw new Error('Failed to re-fetch notifications config')
|
||||||
|
const fresh: NotificationsConfig = await freshRes.json()
|
||||||
|
|
||||||
|
const merged: NotificationsConfig = {
|
||||||
|
...fresh,
|
||||||
|
enabled: config.enabled, // global master switch lives on this page
|
||||||
|
rules: config.rules, // rules are only edited on this page
|
||||||
|
toggles: { ...(fresh.toggles || {}) },
|
||||||
|
}
|
||||||
|
const myToggles = config.toggles || {}
|
||||||
|
for (const { key } of TOGGLE_FAMILY_META) {
|
||||||
|
const mine = myToggles[key]
|
||||||
|
if (!mine) continue
|
||||||
|
merged.toggles![key] = mergeMeshtasticAndOtherFields(
|
||||||
|
(fresh.toggles || {})[key],
|
||||||
|
mine,
|
||||||
|
key,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch('/api/config/notifications', {
|
const res = await fetch('/api/config/notifications', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify(config),
|
body: JSON.stringify(merged),
|
||||||
})
|
})
|
||||||
|
|
||||||
const result = await res.json()
|
const result = await res.json()
|
||||||
|
|
@ -1838,10 +1885,11 @@ export default function Notifications() {
|
||||||
throw new Error(result.detail || 'Save failed')
|
throw new Error(result.detail || 'Save failed')
|
||||||
}
|
}
|
||||||
|
|
||||||
setSuccess('Notifications config saved successfully')
|
setConfig(merged)
|
||||||
setOriginalConfig(JSON.parse(JSON.stringify(config)))
|
setOriginalConfig(JSON.parse(JSON.stringify(merged)))
|
||||||
setHasChanges(false)
|
setHasChanges(false)
|
||||||
setDirty(false)
|
setDirty(false)
|
||||||
|
setSuccess('Meshtastic routing saved successfully')
|
||||||
setTimeout(() => setSuccess(null), 3000)
|
setTimeout(() => setSuccess(null), 3000)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err instanceof Error ? err.message : 'Save failed')
|
setError(err instanceof Error ? err.message : 'Save failed')
|
||||||
|
|
@ -2145,7 +2193,7 @@ export default function Notifications() {
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-sm text-slate-500">
|
<p className="text-sm text-slate-500">
|
||||||
Alert delivery and scheduled reports. Rules define what triggers a notification and where it gets sent.
|
Per-family Meshtastic delivery and other-channel delivery. Family gating (enable, severity threshold, freshness/cooldown) is on <a href="/environment" className="text-accent hover:underline">Data Feeds</a>. MeshCore delivery is on the <a href="/meshcore/routing" className="text-accent hover:underline">MeshCore Routing</a> page.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -2198,80 +2246,22 @@ export default function Notifications() {
|
||||||
info="When disabled, no alerts or scheduled messages will be delivered. Alerts still get recorded to history."
|
info="When disabled, no alerts or scheduled messages will be delivered. Alerts still get recorded to history."
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* Meshtastic delivery grids — always visible regardless of master switch */}
|
||||||
|
{config.toggles && (
|
||||||
|
<>
|
||||||
|
<MeshtasticDeliveryGrid
|
||||||
|
toggles={config.toggles}
|
||||||
|
onChange={(t) => setConfig({ ...config, toggles: t })}
|
||||||
|
/>
|
||||||
|
<OtherChannelsGrid
|
||||||
|
toggles={config.toggles}
|
||||||
|
onChange={(t) => setConfig({ ...config, toggles: t })}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{config.enabled && (
|
{config.enabled && (
|
||||||
<> {/* Cold-start grace -- v0.5.8b */}
|
<>
|
||||||
<div className="space-y-3 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Cold-start grace</label>
|
|
||||||
</div>
|
|
||||||
<NumberInput
|
|
||||||
label="Grace period (seconds)"
|
|
||||||
value={config.cold_start_grace_seconds ?? 60}
|
|
||||||
onChange={(v) => setConfig({ ...config, cold_start_grace_seconds: v })}
|
|
||||||
min={0}
|
|
||||||
max={600}
|
|
||||||
helper="Suppress broadcasts for this many seconds after the first event arrives"
|
|
||||||
info="When meshai starts seeing events for the first time, suppress mesh broadcasts for this many seconds to absorb any JetStream backlog. Persistence rows still get written; only broadcasts are suppressed."
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Band Conditions -- v0.5.11 */}
|
|
||||||
<div className="space-y-3 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-xs text-slate-500 uppercase tracking-wide">Band Conditions (HF propagation)</label>
|
|
||||||
</div>
|
|
||||||
<Toggle
|
|
||||||
label="Enable scheduled band-conditions broadcasts"
|
|
||||||
checked={config.band_conditions_enabled ?? true}
|
|
||||||
onChange={(v) => setConfig({ ...config, band_conditions_enabled: v })}
|
|
||||||
helper="3x/day HF propagation summary (Day/Night ratings per band group). The daily fire digest (twice-daily LLM summary of active fires + the last 24h of growth/spotting) is configured separately under Adapter Config -> fires.digest_*. See Reference -> Fire Tracker (Fusion) and Reference -> Broadcast Types for the New/Update/Active prefix system."
|
|
||||||
info="Source priority: (1) recent SWPC readings persisted locally; (2) HamQSL.com fallback; (3) silent skip if both fail. Persistence rows are written either way for an audit trail."
|
|
||||||
/>
|
|
||||||
{(config.band_conditions_enabled ?? true) && (
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
<TimeInput
|
|
||||||
label="Slot 1"
|
|
||||||
value={(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])[0] || '06:00'}
|
|
||||||
onChange={(v) => {
|
|
||||||
const s = [...(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])]
|
|
||||||
s[0] = v
|
|
||||||
setConfig({ ...config, band_conditions_schedule: s })
|
|
||||||
}}
|
|
||||||
helper="Morning (default 06:00 MT)"
|
|
||||||
/>
|
|
||||||
<TimeInput
|
|
||||||
label="Slot 2"
|
|
||||||
value={(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])[1] || '14:00'}
|
|
||||||
onChange={(v) => {
|
|
||||||
const s = [...(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])]
|
|
||||||
s[1] = v
|
|
||||||
setConfig({ ...config, band_conditions_schedule: s })
|
|
||||||
}}
|
|
||||||
helper="Afternoon (default 14:00 MT)"
|
|
||||||
/>
|
|
||||||
<TimeInput
|
|
||||||
label="Slot 3"
|
|
||||||
value={(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])[2] || '22:00'}
|
|
||||||
onChange={(v) => {
|
|
||||||
const s = [...(config.band_conditions_schedule ?? ['06:00','14:00','22:00'])]
|
|
||||||
s[2] = v
|
|
||||||
setConfig({ ...config, band_conditions_schedule: s })
|
|
||||||
}}
|
|
||||||
helper="Night (default 22:00 MT)"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="text-xs text-slate-600">All times are Mountain Time (America/Boise). DST handled automatically.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Master Toggles */}
|
|
||||||
{config.toggles && (
|
|
||||||
<MasterToggles
|
|
||||||
toggles={config.toggles}
|
|
||||||
onChange={(t) => setConfig({ ...config, toggles: t })}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Rules Section */}
|
{/* Rules Section */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
|
|
@ -2344,451 +2334,7 @@ export default function Notifications() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Danger Zones — self-contained panel (own GET/PUT for the isolated
|
|
||||||
`danger_zones` config section; never entangled with the notifications
|
|
||||||
save above). Additive only. */}
|
|
||||||
<DangerZonesPanel />
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// Danger Zones panel — fully isolated, additive feature.
|
|
||||||
// Loads/saves the standalone `danger_zones` config section via the generic
|
|
||||||
// /api/config helpers. Has its OWN state, fetch (on mount), and save. It is
|
|
||||||
// intentionally decoupled from the page's `notifications` config so it can
|
|
||||||
// never be tangled with the existing save logic.
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
const DZ_MONITOR_ROLES = ['CLIENT_BASE', 'ROUTER', 'ROUTER_LATE'] as const
|
|
||||||
|
|
||||||
const DZ_DELIVERY_OPTIONS = [
|
|
||||||
{ value: 'mesh_dm', label: 'Mesh DM (unicast to nodes)' },
|
|
||||||
{ value: 'mesh_broadcast', label: 'Mesh Broadcast (channel)' },
|
|
||||||
{ value: 'email', label: 'Email' },
|
|
||||||
{ value: 'webhook', label: 'Webhook' },
|
|
||||||
{ value: 'none', label: '(None / log only)' },
|
|
||||||
]
|
|
||||||
|
|
||||||
// Per-family rows. snow is a sub-gate of weather; flood a sub-gate of seismic.
|
|
||||||
const DZ_FAMILIES: {
|
|
||||||
key: string
|
|
||||||
label: string
|
|
||||||
description: string
|
|
||||||
Icon: typeof Activity
|
|
||||||
showAcres?: boolean
|
|
||||||
tabled?: boolean
|
|
||||||
}[] = [
|
|
||||||
{ key: 'fire', label: 'Fire', description: 'Active wildfires (radius from fire perimeter).', Icon: Flame, showAcres: true },
|
|
||||||
{ key: 'weather', label: 'Weather', description: 'Severe weather warnings near a node.', Icon: Cloud },
|
|
||||||
{ key: 'snow', label: 'Snow (sub-gate of Weather)', description: 'Snow-category weather events.', Icon: Snowflake, tabled: true },
|
|
||||||
{ key: 'flood', label: 'Flood (sub-gate of Seismic)', description: 'Stream/flood gauge events.', Icon: Activity },
|
|
||||||
{ key: 'avalanche', label: 'Avalanche', description: 'Avalanche advisories near a node.', Icon: Mountain },
|
|
||||||
{ key: 'seismic', label: 'Seismic', description: 'Earthquakes and seismic events near a node.', Icon: Mountain },
|
|
||||||
]
|
|
||||||
|
|
||||||
interface DangerZoneHazardConfig {
|
|
||||||
enabled: boolean
|
|
||||||
buffer_mi: number
|
|
||||||
min_acres: number
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DangerZonesConfig {
|
|
||||||
enabled: boolean
|
|
||||||
dry_run: boolean
|
|
||||||
monitor_roles: string[]
|
|
||||||
default_buffer_mi: number
|
|
||||||
cooldown_minutes: number
|
|
||||||
fire: DangerZoneHazardConfig
|
|
||||||
weather: DangerZoneHazardConfig
|
|
||||||
snow: DangerZoneHazardConfig
|
|
||||||
flood: DangerZoneHazardConfig
|
|
||||||
avalanche: DangerZoneHazardConfig
|
|
||||||
seismic: DangerZoneHazardConfig
|
|
||||||
delivery_type: string
|
|
||||||
node_ids: string[]
|
|
||||||
broadcast_channel: number | null
|
|
||||||
webhook_url: string
|
|
||||||
webhook_headers: Record<string, string>
|
|
||||||
}
|
|
||||||
|
|
||||||
function dzDefaultHazard(): DangerZoneHazardConfig {
|
|
||||||
return { enabled: false, buffer_mi: 5.0, min_acres: 0 }
|
|
||||||
}
|
|
||||||
|
|
||||||
// New-object default. NOTE: delivery_type defaults to mesh_dm (do NOT copy the
|
|
||||||
// page's mesh_broadcast new-rule default).
|
|
||||||
function dzDefault(): DangerZonesConfig {
|
|
||||||
return {
|
|
||||||
enabled: false,
|
|
||||||
dry_run: true,
|
|
||||||
monitor_roles: ['ROUTER', 'ROUTER_LATE', 'CLIENT_BASE'],
|
|
||||||
default_buffer_mi: 5.0,
|
|
||||||
cooldown_minutes: 360,
|
|
||||||
fire: dzDefaultHazard(),
|
|
||||||
weather: dzDefaultHazard(),
|
|
||||||
snow: dzDefaultHazard(),
|
|
||||||
flood: dzDefaultHazard(),
|
|
||||||
avalanche: dzDefaultHazard(),
|
|
||||||
seismic: dzDefaultHazard(),
|
|
||||||
delivery_type: 'mesh_dm',
|
|
||||||
node_ids: [],
|
|
||||||
broadcast_channel: null,
|
|
||||||
webhook_url: '',
|
|
||||||
webhook_headers: {},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Minimal local select — SelectInput lives in Config.tsx (which we must not
|
|
||||||
// touch/entangle), so this panel keeps its own tiny equivalent.
|
|
||||||
function DZSelect({ label, value, onChange, options, info = '' }: {
|
|
||||||
label: string
|
|
||||||
value: string
|
|
||||||
onChange: (v: string) => void
|
|
||||||
options: { value: string; label: string }[]
|
|
||||||
info?: string
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-1">
|
|
||||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
|
||||||
{label}
|
|
||||||
{info && <InfoButton info={info} />}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={value}
|
|
||||||
onChange={(e) => onChange(e.target.value)}
|
|
||||||
className="w-full px-3 py-2 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-200 focus:outline-none focus:border-accent"
|
|
||||||
>
|
|
||||||
{options.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>{opt.label}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Per-family row — mirrors the AlertRuleToggle (toggle + thresholds) pattern.
|
|
||||||
// AlertRuleToggle itself lives in Config.tsx and is NOT exported, so this is a
|
|
||||||
// local equivalent purpose-built for the per-family hazard config.
|
|
||||||
function DZFamilyRow({ meta, cfg, onChange }: {
|
|
||||||
meta: { key: string; label: string; description: string; Icon: typeof Activity; showAcres?: boolean; tabled?: boolean }
|
|
||||||
cfg: DangerZoneHazardConfig
|
|
||||||
onChange: (c: DangerZoneHazardConfig) => void
|
|
||||||
}) {
|
|
||||||
const { Icon } = meta
|
|
||||||
return (
|
|
||||||
<div className={`border border-[#1e2a3a] p-3 space-y-2 ${meta.tabled ? 'opacity-50' : ''}`}>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex items-start gap-2 flex-1">
|
|
||||||
<Icon size={15} className="text-slate-400 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="flex-1">
|
|
||||||
<span className="text-sm text-slate-300">{meta.label}</span>
|
|
||||||
<p className="text-xs text-slate-600">{meta.description}</p>
|
|
||||||
{meta.tabled && (
|
|
||||||
<span className="inline-block mt-1 px-2 py-0.5 text-[10px] uppercase tracking-wide rounded bg-slate-700 text-slate-300">
|
|
||||||
Tabled — needs snowfall + elevation pipeline
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled={meta.tabled}
|
|
||||||
onClick={() => { if (!meta.tabled) onChange({ ...cfg, enabled: !cfg.enabled }) }}
|
|
||||||
className={`relative w-11 h-6 rounded-full transition-colors flex-shrink-0 ml-3 ${
|
|
||||||
cfg.enabled ? 'bg-accent' : 'bg-[#1e2a3a]'
|
|
||||||
} ${meta.tabled ? 'cursor-not-allowed' : ''}`}
|
|
||||||
>
|
|
||||||
<span
|
|
||||||
className={`absolute top-1 left-1 w-4 h-4 rounded-full bg-white transition-transform ${
|
|
||||||
cfg.enabled ? 'translate-x-5' : ''
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{cfg.enabled && !meta.tabled && (
|
|
||||||
<div className={`grid gap-3 pt-2 border-t border-[#1e2a3a] ${meta.showAcres ? 'grid-cols-2' : 'grid-cols-1'}`}>
|
|
||||||
<NumberInput
|
|
||||||
label="Buffer (mi)"
|
|
||||||
value={cfg.buffer_mi ?? 0}
|
|
||||||
onChange={(v) => onChange({ ...cfg, buffer_mi: v })}
|
|
||||||
min={0}
|
|
||||||
step={0.5}
|
|
||||||
/>
|
|
||||||
{meta.showAcres && (
|
|
||||||
<NumberInput
|
|
||||||
label="Min Acres"
|
|
||||||
value={cfg.min_acres ?? 0}
|
|
||||||
onChange={(v) => onChange({ ...cfg, min_acres: v })}
|
|
||||||
min={0}
|
|
||||||
step={1}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function DangerZonesPanel() {
|
|
||||||
const [expanded, setExpanded] = useState(false)
|
|
||||||
const [cfg, setCfg] = useState<DangerZonesConfig | null>(null)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
const [saving, setSaving] = useState(false)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
const [success, setSuccess] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
|
||||||
setLoading(true)
|
|
||||||
setError(null)
|
|
||||||
try {
|
|
||||||
const raw = (await apiFetchConfig('danger_zones')) as Partial<DangerZonesConfig>
|
|
||||||
// Merge over defaults so missing/new fields are always present.
|
|
||||||
const d = dzDefault()
|
|
||||||
setCfg({
|
|
||||||
...d,
|
|
||||||
...raw,
|
|
||||||
fire: { ...d.fire, ...(raw.fire || {}) },
|
|
||||||
weather: { ...d.weather, ...(raw.weather || {}) },
|
|
||||||
snow: { ...d.snow, ...(raw.snow || {}) },
|
|
||||||
flood: { ...d.flood, ...(raw.flood || {}) },
|
|
||||||
avalanche: { ...d.avalanche, ...(raw.avalanche || {}) },
|
|
||||||
seismic: { ...d.seismic, ...(raw.seismic || {}) },
|
|
||||||
monitor_roles: raw.monitor_roles ?? d.monitor_roles,
|
|
||||||
node_ids: raw.node_ids ?? d.node_ids,
|
|
||||||
webhook_headers: raw.webhook_headers ?? d.webhook_headers,
|
|
||||||
})
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Failed to load danger zones config')
|
|
||||||
setCfg(dzDefault())
|
|
||||||
} finally {
|
|
||||||
setLoading(false)
|
|
||||||
}
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => { load() }, [load])
|
|
||||||
|
|
||||||
const save = async () => {
|
|
||||||
if (!cfg) return
|
|
||||||
setSaving(true)
|
|
||||||
setError(null)
|
|
||||||
setSuccess(null)
|
|
||||||
try {
|
|
||||||
await apiUpdateConfig('danger_zones', cfg)
|
|
||||||
setSuccess('Danger Zones config saved')
|
|
||||||
setTimeout(() => setSuccess(null), 3000)
|
|
||||||
} catch (err) {
|
|
||||||
setError(err instanceof Error ? err.message : 'Save failed')
|
|
||||||
} finally {
|
|
||||||
setSaving(false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const upd = (patch: Partial<DangerZonesConfig>) => setCfg(c => (c ? { ...c, ...patch } : c))
|
|
||||||
|
|
||||||
const toggleRole = (role: string) => {
|
|
||||||
if (!cfg) return
|
|
||||||
const cur = cfg.monitor_roles || []
|
|
||||||
upd({ monitor_roles: cur.includes(role) ? cur.filter(r => r !== role) : [...cur, role] })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="bg-bg-card border border-border">
|
|
||||||
{/* Collapsible header */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setExpanded(e => !e)}
|
|
||||||
className="w-full flex items-center justify-between p-4 text-left"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<AlertTriangle size={18} className="text-amber-400" />
|
|
||||||
<div>
|
|
||||||
<div className="text-sm font-medium text-slate-200">Danger Zones</div>
|
|
||||||
<div className="text-xs text-slate-500">
|
|
||||||
Alert when monitored infrastructure nodes are in/near a hazard
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{cfg && (
|
|
||||||
<span className={`text-xs px-2 py-0.5 rounded ${
|
|
||||||
cfg.enabled
|
|
||||||
? (cfg.dry_run ? 'bg-yellow-500/10 text-yellow-400' : 'bg-green-500/10 text-green-400')
|
|
||||||
: 'bg-slate-800 text-slate-500'
|
|
||||||
}`}>
|
|
||||||
{cfg.enabled ? (cfg.dry_run ? 'Dry-run' : 'Live') : 'Disabled'}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{expanded ? <ChevronDown size={18} className="text-slate-500" /> : <ChevronRight size={18} className="text-slate-500" />}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{expanded && (
|
|
||||||
<div className="p-6 pt-0 space-y-6">
|
|
||||||
{/* Safety copy */}
|
|
||||||
<div className="flex items-start gap-2 p-3 bg-amber-500/10 border border-amber-500/20">
|
|
||||||
<AlertCircle size={16} className="text-amber-400 mt-0.5 flex-shrink-0" />
|
|
||||||
<div className="text-xs text-amber-200/90 leading-relaxed">
|
|
||||||
Ships disabled; when enabled, defaults to dry-run / log-only — no mesh traffic
|
|
||||||
until you turn dry-run off. Requires <span className="font-medium">Enable Notifications</span> (above)
|
|
||||||
and environmental feeds to be on, since hazard events only flow when those are active.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Status messages */}
|
|
||||||
{error && (
|
|
||||||
<div className="p-3 text-sm bg-red-500/10 text-red-400 border border-red-500/20">{error}</div>
|
|
||||||
)}
|
|
||||||
{success && (
|
|
||||||
<div className="p-3 text-sm bg-green-500/10 text-green-400 border border-green-500/20">
|
|
||||||
<Check size={14} className="inline mr-2" />{success}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading || !cfg ? (
|
|
||||||
<div className="text-sm text-slate-500">Loading danger zones config...</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Toggle
|
|
||||||
label="Enable Danger Zones"
|
|
||||||
checked={cfg.enabled}
|
|
||||||
onChange={(v) => upd({ enabled: v })}
|
|
||||||
helper="Master switch for the infrastructure danger-zone correlator"
|
|
||||||
/>
|
|
||||||
<Toggle
|
|
||||||
label="Dry-run (log only)"
|
|
||||||
checked={cfg.dry_run}
|
|
||||||
onChange={(v) => upd({ dry_run: v })}
|
|
||||||
helper="When on, matches are logged but nothing is sent to the mesh. Turn off only after verifying dry-run output."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Monitored roles */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
|
||||||
Monitored Roles
|
|
||||||
<InfoButton info="Which Meshtastic node roles to correlate against hazards. Only nodes that have a GPS position are scanned." />
|
|
||||||
</label>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{DZ_MONITOR_ROLES.map(role => {
|
|
||||||
const on = (cfg.monitor_roles || []).includes(role)
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={role}
|
|
||||||
type="button"
|
|
||||||
onClick={() => toggleRole(role)}
|
|
||||||
className={`px-3 py-1.5 rounded text-sm transition-colors ${
|
|
||||||
on ? 'bg-accent text-white' : 'bg-[#1e2a3a] text-slate-400 hover:text-slate-200'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{role}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Global numeric settings */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<NumberInput
|
|
||||||
label="Default Buffer (mi)"
|
|
||||||
value={cfg.default_buffer_mi}
|
|
||||||
onChange={(v) => upd({ default_buffer_mi: v })}
|
|
||||||
min={0}
|
|
||||||
step={0.5}
|
|
||||||
helper="Buffer used when a family has none set"
|
|
||||||
/>
|
|
||||||
<NumberInput
|
|
||||||
label="Cooldown (min)"
|
|
||||||
value={cfg.cooldown_minutes}
|
|
||||||
onChange={(v) => upd({ cooldown_minutes: v })}
|
|
||||||
min={0}
|
|
||||||
helper="Min time between repeat alerts per node+family"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Per-family hazard config */}
|
|
||||||
<div className="space-y-3">
|
|
||||||
<label className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
|
||||||
Hazard Families
|
|
||||||
<InfoButton info="Enable each hazard family to monitor, with its own buffer distance and severity threshold. Snow is a sub-gate of Weather; Flood a sub-gate of Seismic." />
|
|
||||||
</label>
|
|
||||||
{DZ_FAMILIES.map(meta => (
|
|
||||||
<DZFamilyRow
|
|
||||||
key={meta.key}
|
|
||||||
meta={meta}
|
|
||||||
cfg={cfg[meta.key as keyof DangerZonesConfig] as DangerZoneHazardConfig}
|
|
||||||
onChange={(c) => upd({ [meta.key]: c } as Partial<DangerZonesConfig>)}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Delivery */}
|
|
||||||
<div className="space-y-4 p-4 bg-[#0a0e17] border border-[#1e2a3a]">
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium text-slate-300">
|
|
||||||
<Send size={14} />
|
|
||||||
DELIVERY
|
|
||||||
</div>
|
|
||||||
<DZSelect
|
|
||||||
label="Delivery Method"
|
|
||||||
value={cfg.delivery_type || 'mesh_dm'}
|
|
||||||
onChange={(v) => upd({ delivery_type: v })}
|
|
||||||
options={DZ_DELIVERY_OPTIONS}
|
|
||||||
info="Where danger-zone alerts get delivered. Mesh DM unicasts to specific nodes; broadcast sends to a channel. Has no effect while dry-run is on."
|
|
||||||
/>
|
|
||||||
|
|
||||||
{cfg.delivery_type === 'mesh_dm' && (
|
|
||||||
<NodePicker
|
|
||||||
label="Recipient Nodes"
|
|
||||||
value={cfg.node_ids || []}
|
|
||||||
onChange={(v) => upd({ node_ids: v })}
|
|
||||||
helper="Nodes that receive direct messages"
|
|
||||||
valueType="node_id_hex"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{cfg.delivery_type === 'mesh_broadcast' && (
|
|
||||||
<ChannelPicker
|
|
||||||
label="Broadcast Channel"
|
|
||||||
value={cfg.broadcast_channel ?? 0}
|
|
||||||
onChange={(v) => upd({ broadcast_channel: v })}
|
|
||||||
helper="Select the mesh radio channel"
|
|
||||||
mode="single"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{cfg.delivery_type === 'webhook' && (
|
|
||||||
<TextInput
|
|
||||||
label="Webhook URL"
|
|
||||||
value={cfg.webhook_url || ''}
|
|
||||||
onChange={(v) => upd({ webhook_url: v })}
|
|
||||||
placeholder="https://discord.com/api/webhooks/..."
|
|
||||||
helper="POST alert as JSON"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{cfg.delivery_type === 'email' && (
|
|
||||||
<p className="text-xs text-slate-600">
|
|
||||||
Email delivery uses the SMTP settings configured for notification rules.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Save */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={save}
|
|
||||||
disabled={saving}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-accent hover:bg-accent/80 disabled:bg-slate-700 disabled:cursor-not-allowed rounded text-white transition-colors"
|
|
||||||
>
|
|
||||||
<Save size={16} />
|
|
||||||
{saving ? 'Saving...' : 'Save Danger Zones'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
|
||||||
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>
|
<SectionHeader>Conversational</SectionHeader>
|
||||||
<p>
|
<p>
|
||||||
Bang commands are the short, predictable interface. For anything that
|
Bang commands are the short, predictable interface. For anything that
|
||||||
|
|
|
||||||
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 .config import AlertRulesConfig, MeshIntelligenceConfig
|
||||||
from .mesh_health import MeshHealthEngine
|
from .mesh_health import MeshHealthEngine
|
||||||
from .mesh_reporter import MeshReporter
|
from .mesh_reporter import MeshReporter
|
||||||
from .subscriptions import SubscriptionManager
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
@ -65,14 +64,12 @@ class AlertEngine:
|
||||||
self,
|
self,
|
||||||
health_engine: "MeshHealthEngine",
|
health_engine: "MeshHealthEngine",
|
||||||
reporter: "MeshReporter",
|
reporter: "MeshReporter",
|
||||||
subscription_manager: "SubscriptionManager",
|
|
||||||
config: "MeshIntelligenceConfig",
|
config: "MeshIntelligenceConfig",
|
||||||
db_path: str = "",
|
db_path: str = "",
|
||||||
timezone: str = "America/Boise",
|
timezone: str = "America/Boise",
|
||||||
):
|
):
|
||||||
self._health = health_engine
|
self._health = health_engine
|
||||||
self._reporter = reporter
|
self._reporter = reporter
|
||||||
self._subs = subscription_manager
|
|
||||||
self._rules = config.alert_rules
|
self._rules = config.alert_rules
|
||||||
self._critical_nodes = set(n.upper() for n in (config.critical_nodes or []))
|
self._critical_nodes = set(n.upper() for n in (config.critical_nodes or []))
|
||||||
self._db_path = db_path
|
self._db_path = db_path
|
||||||
|
|
@ -580,14 +577,6 @@ class AlertEngine:
|
||||||
def clear_pending(self):
|
def clear_pending(self):
|
||||||
self._pending_alerts = []
|
self._pending_alerts = []
|
||||||
|
|
||||||
def get_subscribers_for_alert(self, alert: dict) -> list[dict]:
|
|
||||||
if not self._subs:
|
|
||||||
return []
|
|
||||||
return self._subs.get_alert_subscribers(
|
|
||||||
scope_type=alert.get("scope_type"),
|
|
||||||
scope_value=alert.get("scope_value"),
|
|
||||||
)
|
|
||||||
|
|
||||||
def check_environmental(self, env_store) -> list[dict]:
|
def check_environmental(self, env_store) -> list[dict]:
|
||||||
"""Check environmental feeds for alertable conditions.
|
"""Check environmental feeds for alertable conditions.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -160,9 +160,7 @@ def create_dispatcher(
|
||||||
mesh_reporter=None,
|
mesh_reporter=None,
|
||||||
data_store=None,
|
data_store=None,
|
||||||
health_engine=None,
|
health_engine=None,
|
||||||
subscription_manager=None,
|
|
||||||
env_store=None,
|
env_store=None,
|
||||||
notification_router=None,
|
|
||||||
) -> CommandDispatcher:
|
) -> CommandDispatcher:
|
||||||
"""Create and populate command dispatcher with default commands.
|
"""Create and populate command dispatcher with default commands.
|
||||||
|
|
||||||
|
|
@ -173,7 +171,6 @@ def create_dispatcher(
|
||||||
mesh_reporter: MeshReporter instance for health commands
|
mesh_reporter: MeshReporter instance for health commands
|
||||||
data_store: MeshDataStore for neighbor data
|
data_store: MeshDataStore for neighbor data
|
||||||
health_engine: MeshHealthEngine for infrastructure detection
|
health_engine: MeshHealthEngine for infrastructure detection
|
||||||
subscription_manager: SubscriptionManager for subscription commands
|
|
||||||
env_store: EnvironmentalStore for weather/propagation commands
|
env_store: EnvironmentalStore for weather/propagation commands
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
|
|
@ -186,7 +183,6 @@ def create_dispatcher(
|
||||||
from .status import StatusCommand
|
from .status import StatusCommand
|
||||||
from .weather import WeatherCommand
|
from .weather import WeatherCommand
|
||||||
from .health import HealthCommand, RegionCommand, NeighborCommand
|
from .health import HealthCommand, RegionCommand, NeighborCommand
|
||||||
from .subscribe import SubCommand, UnsubCommand, MySubsCommand
|
|
||||||
|
|
||||||
dispatcher = CommandDispatcher(prefix=prefix, disabled_commands=disabled_commands)
|
dispatcher = CommandDispatcher(prefix=prefix, disabled_commands=disabled_commands)
|
||||||
|
|
||||||
|
|
@ -224,28 +220,6 @@ def create_dispatcher(
|
||||||
alias_handler.name = alias
|
alias_handler.name = alias
|
||||||
dispatcher.register(alias_handler)
|
dispatcher.register(alias_handler)
|
||||||
|
|
||||||
# Register subscription commands
|
|
||||||
sub_cmd = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
|
|
||||||
dispatcher.register(sub_cmd)
|
|
||||||
for alias in getattr(sub_cmd, 'aliases', []):
|
|
||||||
alias_handler = SubCommand(subscription_manager, mesh_reporter, data_store, notification_router)
|
|
||||||
alias_handler.name = alias
|
|
||||||
dispatcher.register(alias_handler)
|
|
||||||
|
|
||||||
unsub_cmd = UnsubCommand(subscription_manager, notification_router)
|
|
||||||
dispatcher.register(unsub_cmd)
|
|
||||||
for alias in getattr(unsub_cmd, 'aliases', []):
|
|
||||||
alias_handler = UnsubCommand(subscription_manager, notification_router)
|
|
||||||
alias_handler.name = alias
|
|
||||||
dispatcher.register(alias_handler)
|
|
||||||
|
|
||||||
mysubs_cmd = MySubsCommand(subscription_manager, notification_router)
|
|
||||||
dispatcher.register(mysubs_cmd)
|
|
||||||
for alias in getattr(mysubs_cmd, 'aliases', []):
|
|
||||||
alias_handler = MySubsCommand(subscription_manager, notification_router)
|
|
||||||
alias_handler.name = alias
|
|
||||||
dispatcher.register(alias_handler)
|
|
||||||
|
|
||||||
# Register environmental commands
|
# Register environmental commands
|
||||||
if env_store:
|
if env_store:
|
||||||
from .alerts_cmd import AlertsCommand
|
from .alerts_cmd import AlertsCommand
|
||||||
|
|
|
||||||
|
|
@ -32,11 +32,9 @@ class HelpCommand(CommandHandler):
|
||||||
|
|
||||||
# Group by category
|
# Group by category
|
||||||
health_names = {"health", "region", "neighbors"}
|
health_names = {"health", "region", "neighbors"}
|
||||||
sub_names = {"sub", "unsub", "mysubs"}
|
|
||||||
|
|
||||||
health_cmds = [c for c in unique if c.name.lower() in health_names]
|
health_cmds = [c for c in unique if c.name.lower() in health_names]
|
||||||
sub_cmds = [c for c in unique if c.name.lower() in sub_names]
|
other_cmds = [c for c in unique if c.name.lower() not in health_names and c.name.lower() != "help"]
|
||||||
other_cmds = [c for c in unique if c.name.lower() not in health_names and c.name.lower() not in sub_names and c.name.lower() != "help"]
|
|
||||||
|
|
||||||
lines = ["Commands:"]
|
lines = ["Commands:"]
|
||||||
|
|
||||||
|
|
@ -46,12 +44,6 @@ class HelpCommand(CommandHandler):
|
||||||
for c in sorted(health_cmds, key=lambda x: x.name):
|
for c in sorted(health_cmds, key=lambda x: x.name):
|
||||||
lines.append(f" !{c.name} - {c.description}")
|
lines.append(f" !{c.name} - {c.description}")
|
||||||
|
|
||||||
if sub_cmds:
|
|
||||||
lines.append("")
|
|
||||||
lines.append("Subscriptions:")
|
|
||||||
for c in sorted(sub_cmds, key=lambda x: x.name):
|
|
||||||
lines.append(f" !{c.name} - {c.description}")
|
|
||||||
|
|
||||||
if other_cmds:
|
if other_cmds:
|
||||||
lines.append("")
|
lines.append("")
|
||||||
lines.append("Other:")
|
lines.append("Other:")
|
||||||
|
|
@ -67,9 +59,6 @@ class HelpCommand(CommandHandler):
|
||||||
def _command_help(self, cmd_name: str) -> str:
|
def _command_help(self, cmd_name: str) -> str:
|
||||||
"""Detailed help for a specific command."""
|
"""Detailed help for a specific command."""
|
||||||
aliases = {
|
aliases = {
|
||||||
"sub": "sub", "subscribe": "sub", "subscription": "sub", "subscriptions": "sub",
|
|
||||||
"unsub": "unsub", "unsubscribe": "unsub",
|
|
||||||
"mysubs": "mysubs", "subs": "mysubs",
|
|
||||||
"health": "health", "mesh": "health",
|
"health": "health", "mesh": "health",
|
||||||
"region": "region", "reg": "region",
|
"region": "region", "reg": "region",
|
||||||
"neighbors": "neighbors", "nbr": "neighbors", "nb": "neighbors",
|
"neighbors": "neighbors", "nbr": "neighbors", "nb": "neighbors",
|
||||||
|
|
@ -81,32 +70,6 @@ class HelpCommand(CommandHandler):
|
||||||
registered = {c.name.lower() for c in self._dispatcher.get_commands()}
|
registered = {c.name.lower() for c in self._dispatcher.get_commands()}
|
||||||
|
|
||||||
texts = {
|
texts = {
|
||||||
"sub": (
|
|
||||||
"Subscribe to Reports & Alerts\n\n"
|
|
||||||
"Daily report:\n"
|
|
||||||
" !sub daily 6pm\n"
|
|
||||||
" !sub daily 7:30am region SCID\n"
|
|
||||||
" !sub daily 6pm node MHR\n\n"
|
|
||||||
"Weekly digest:\n"
|
|
||||||
" !sub weekly 8am sun\n\n"
|
|
||||||
"Alerts (instant DM on issues):\n"
|
|
||||||
" !sub alerts\n"
|
|
||||||
" !sub alerts region Wood River\n\n"
|
|
||||||
"Time: 6pm, 6:30pm, 1830, 18:30\n"
|
|
||||||
"Regions: SCID, SWID, Magic Valley, Twin Falls\n\n"
|
|
||||||
"Manage:\n"
|
|
||||||
" !mysubs - list yours\n"
|
|
||||||
" !unsub daily - remove daily\n"
|
|
||||||
" !unsub all - remove everything"
|
|
||||||
),
|
|
||||||
"unsub": (
|
|
||||||
"Unsubscribe\n\n"
|
|
||||||
" !unsub daily - remove daily report\n"
|
|
||||||
" !unsub weekly - remove weekly digest\n"
|
|
||||||
" !unsub alerts - remove alerts\n"
|
|
||||||
" !unsub all - remove everything"
|
|
||||||
),
|
|
||||||
"mysubs": "!mysubs - list your active subscriptions",
|
|
||||||
"health": (
|
"health": (
|
||||||
"Mesh Health\n\n"
|
"Mesh Health\n\n"
|
||||||
" !health - 5-pillar health summary\n"
|
" !health - 5-pillar health summary\n"
|
||||||
|
|
|
||||||
|
|
@ -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
|
max_context_items: int = 20 # Max observations injected into LLM context
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class MeshCoreContextConfig:
|
||||||
|
"""MeshCore passive-context / bot-behavior settings (MeshCore-native)."""
|
||||||
|
enable_passive_context: bool = True
|
||||||
|
observe_channels: list[str] = field(default_factory=list) # channel NAMES, empty = all
|
||||||
|
ignore_contacts: list[str] = field(default_factory=list) # contact names or pubkey prefixes
|
||||||
|
respond_to_dms: bool = True
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class CommandsConfig:
|
class CommandsConfig:
|
||||||
"""Command settings."""
|
"""Command settings."""
|
||||||
|
|
@ -782,6 +791,7 @@ class Config:
|
||||||
history: HistoryConfig = field(default_factory=HistoryConfig)
|
history: HistoryConfig = field(default_factory=HistoryConfig)
|
||||||
memory: MemoryConfig = field(default_factory=MemoryConfig)
|
memory: MemoryConfig = field(default_factory=MemoryConfig)
|
||||||
context: ContextConfig = field(default_factory=ContextConfig)
|
context: ContextConfig = field(default_factory=ContextConfig)
|
||||||
|
meshcore_context: MeshCoreContextConfig = field(default_factory=MeshCoreContextConfig)
|
||||||
commands: CommandsConfig = field(default_factory=CommandsConfig)
|
commands: CommandsConfig = field(default_factory=CommandsConfig)
|
||||||
llm: LLMConfig = field(default_factory=LLMConfig)
|
llm: LLMConfig = field(default_factory=LLMConfig)
|
||||||
weather: WeatherConfig = field(default_factory=WeatherConfig)
|
weather: WeatherConfig = field(default_factory=WeatherConfig)
|
||||||
|
|
|
||||||
|
|
@ -56,31 +56,29 @@ async def get_alert_history(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@router.get("/subscriptions")
|
@router.get("/activity")
|
||||||
async def get_subscriptions(request: Request):
|
async def get_activity(
|
||||||
"""Get all alert subscriptions."""
|
request: Request,
|
||||||
subscription_manager = getattr(request.app.state, "subscription_manager", None)
|
limit: int = Query(100, ge=1, le=500),
|
||||||
|
):
|
||||||
|
"""Activity Log: most recent outbound mesh broadcasts, newest first.
|
||||||
|
|
||||||
if not subscription_manager:
|
Reads mesh_broadcasts_out from the persistence/migration DB (get_db) and
|
||||||
return []
|
returns every column as a plain dict. Legacy rows keep NULL
|
||||||
|
transport/success. If the table doesn't exist yet, returns [].
|
||||||
|
"""
|
||||||
|
from meshai.persistence import get_db
|
||||||
|
|
||||||
try:
|
try:
|
||||||
subs = subscription_manager.get_all_subs()
|
conn = get_db()
|
||||||
return [
|
rows = conn.execute(
|
||||||
{
|
"SELECT * FROM mesh_broadcasts_out "
|
||||||
"id": sub["id"],
|
"ORDER BY sent_at DESC, id DESC LIMIT ?",
|
||||||
"user_id": sub["user_id"],
|
(limit,),
|
||||||
"sub_type": sub["sub_type"],
|
).fetchall()
|
||||||
"schedule_time": sub.get("schedule_time"),
|
|
||||||
"schedule_day": sub.get("schedule_day"),
|
|
||||||
"scope_type": sub.get("scope_type", "mesh"),
|
|
||||||
"scope_value": sub.get("scope_value"),
|
|
||||||
"enabled": sub.get("enabled", 1) == 1,
|
|
||||||
}
|
|
||||||
for sub in subs
|
|
||||||
]
|
|
||||||
except Exception:
|
except Exception:
|
||||||
return []
|
return []
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def _map_severity(alert: dict) -> str:
|
def _map_severity(alert: dict) -> str:
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,7 @@ VALID_SECTIONS = {
|
||||||
"history",
|
"history",
|
||||||
"memory",
|
"memory",
|
||||||
"context",
|
"context",
|
||||||
|
"meshcore_context",
|
||||||
"commands",
|
"commands",
|
||||||
"llm",
|
"llm",
|
||||||
"weather",
|
"weather",
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,6 @@ async def start_dashboard(meshai_instance: "MeshAI") -> DashboardBroadcaster:
|
||||||
app.state.health_engine = meshai_instance.health_engine
|
app.state.health_engine = meshai_instance.health_engine
|
||||||
app.state.alert_engine = getattr(meshai_instance, "alert_engine", None)
|
app.state.alert_engine = getattr(meshai_instance, "alert_engine", None)
|
||||||
app.state.env_store = getattr(meshai_instance, "env_store", None)
|
app.state.env_store = getattr(meshai_instance, "env_store", None)
|
||||||
app.state.subscription_manager = meshai_instance.subscription_manager
|
|
||||||
app.state.notification_router = getattr(meshai_instance, "notification_router", None)
|
app.state.notification_router = getattr(meshai_instance, "notification_router", None)
|
||||||
app.state.connector = meshai_instance.connector
|
app.state.connector = meshai_instance.connector
|
||||||
app.state.bus = getattr(meshai_instance, "event_bus", None)
|
app.state.bus = getattr(meshai_instance, "event_bus", None)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,6 @@ class MeshAI:
|
||||||
self.data_store = None # Replaces source_manager
|
self.data_store = None # Replaces source_manager
|
||||||
self.health_engine = None
|
self.health_engine = None
|
||||||
self.mesh_reporter = None
|
self.mesh_reporter = None
|
||||||
self.subscription_manager = None
|
|
||||||
self.alert_engine = None
|
self.alert_engine = None
|
||||||
self.notification_router = None
|
self.notification_router = None
|
||||||
self.event_bus = None # Notification pipeline EventBus (v0.3)
|
self.event_bus = None # Notification pipeline EventBus (v0.3)
|
||||||
|
|
@ -53,7 +52,6 @@ class MeshAI:
|
||||||
self.env_store = None # Environmental feeds store
|
self.env_store = None # Environmental feeds store
|
||||||
self._central_consumer = None # Central NATS consumer (v0.4)
|
self._central_consumer = None # Central NATS consumer (v0.4)
|
||||||
self._fire_pacer = None # FirePacer for rate-limited fire broadcasts
|
self._fire_pacer = None # FirePacer for rate-limited fire broadcasts
|
||||||
self._last_sub_check: float = 0.0
|
|
||||||
self.router: Optional[MessageRouter] = None
|
self.router: Optional[MessageRouter] = None
|
||||||
self.responder: Optional[Responder] = None
|
self.responder: Optional[Responder] = None
|
||||||
self._running = False
|
self._running = False
|
||||||
|
|
@ -223,12 +221,6 @@ class MeshAI:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.debug("Env refresh error: %s", e)
|
logger.debug("Env refresh error: %s", e)
|
||||||
|
|
||||||
# Check scheduled subscriptions (every 60 seconds)
|
|
||||||
if self.subscription_manager and self.mesh_reporter:
|
|
||||||
if time.time() - self._last_sub_check >= 60:
|
|
||||||
await self._check_scheduled_subs()
|
|
||||||
self._last_sub_check = time.time()
|
|
||||||
|
|
||||||
# Periodic cleanup
|
# Periodic cleanup
|
||||||
if time.time() - self._last_cleanup >= 3600:
|
if time.time() - self._last_cleanup >= 3600:
|
||||||
await self.history.cleanup_expired()
|
await self.history.cleanup_expired()
|
||||||
|
|
@ -326,8 +318,6 @@ class MeshAI:
|
||||||
if self.data_store:
|
if self.data_store:
|
||||||
await self.data_store.stop_mqtt_sources()
|
await self.data_store.stop_mqtt_sources()
|
||||||
self.data_store.close()
|
self.data_store.close()
|
||||||
if self.subscription_manager:
|
|
||||||
self.subscription_manager.close()
|
|
||||||
|
|
||||||
self._remove_pid()
|
self._remove_pid()
|
||||||
logger.info("MeshAI stopped")
|
logger.info("MeshAI stopped")
|
||||||
|
|
@ -395,7 +385,10 @@ class MeshAI:
|
||||||
await self._load_summaries()
|
await self._load_summaries()
|
||||||
|
|
||||||
# Transport connector (factory derives backend from config.connection.meshcore_host)
|
# Transport connector (factory derives backend from config.connection.meshcore_host)
|
||||||
self.connector = build_transport(self.config.connection)
|
self.connector = build_transport(
|
||||||
|
self.config.connection,
|
||||||
|
meshcore_context=self.config.meshcore_context,
|
||||||
|
)
|
||||||
|
|
||||||
# Fit every broadcast handler's one-packet formatter to the active mesh
|
# Fit every broadcast handler's one-packet formatter to the active mesh
|
||||||
# transport's budget (LoRa max_chars, 140). Durable across adapter_config
|
# transport's budget (LoRa max_chars, 140). Durable across adapter_config
|
||||||
|
|
@ -494,22 +487,13 @@ class MeshAI:
|
||||||
else:
|
else:
|
||||||
self.mesh_reporter = None
|
self.mesh_reporter = None
|
||||||
|
|
||||||
# Subscription manager (uses same db as data_store)
|
# Alert engine (needs health engine and reporter)
|
||||||
if self.data_store:
|
if self.health_engine and self.mesh_reporter:
|
||||||
from .subscriptions import SubscriptionManager
|
|
||||||
self.subscription_manager = SubscriptionManager(db_path="/data/mesh_history.db")
|
|
||||||
logger.info("Subscription manager enabled")
|
|
||||||
else:
|
|
||||||
self.subscription_manager = None
|
|
||||||
|
|
||||||
# Alert engine (needs health engine, reporter, and subscription manager)
|
|
||||||
if self.health_engine and self.mesh_reporter and self.subscription_manager:
|
|
||||||
from .alert_engine import AlertEngine
|
from .alert_engine import AlertEngine
|
||||||
mi = self.config.mesh_intelligence
|
mi = self.config.mesh_intelligence
|
||||||
self.alert_engine = AlertEngine(
|
self.alert_engine = AlertEngine(
|
||||||
health_engine=self.health_engine,
|
health_engine=self.health_engine,
|
||||||
reporter=self.mesh_reporter,
|
reporter=self.mesh_reporter,
|
||||||
subscription_manager=self.subscription_manager,
|
|
||||||
config=mi,
|
config=mi,
|
||||||
db_path="/data/mesh_history.db",
|
db_path="/data/mesh_history.db",
|
||||||
timezone=self.config.timezone,
|
timezone=self.config.timezone,
|
||||||
|
|
@ -610,9 +594,7 @@ class MeshAI:
|
||||||
mesh_reporter=self.mesh_reporter,
|
mesh_reporter=self.mesh_reporter,
|
||||||
data_store=self.data_store,
|
data_store=self.data_store,
|
||||||
health_engine=self.health_engine,
|
health_engine=self.health_engine,
|
||||||
subscription_manager=self.subscription_manager,
|
|
||||||
env_store=self.env_store,
|
env_store=self.env_store,
|
||||||
notification_router=self.notification_router,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Message router
|
# Message router
|
||||||
|
|
@ -796,94 +778,9 @@ class MeshAI:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Failed to send channel alert: {e}")
|
logger.error(f"Failed to send channel alert: {e}")
|
||||||
|
|
||||||
# Fallback: Send DMs to matching subscribers
|
|
||||||
if self.alert_engine and self.subscription_manager:
|
|
||||||
subscribers = self.alert_engine.get_subscribers_for_alert(alert)
|
|
||||||
for sub in subscribers:
|
|
||||||
user_id = sub["user_id"]
|
|
||||||
try:
|
|
||||||
await self._send_sub_dm(user_id, message)
|
|
||||||
logger.info(f"Alert DM sent to {user_id}: {alert['type']}")
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Failed to send alert DM to {user_id}: {e}")
|
|
||||||
|
|
||||||
if self.alert_engine:
|
if self.alert_engine:
|
||||||
self.alert_engine.clear_pending()
|
self.alert_engine.clear_pending()
|
||||||
|
|
||||||
async def _check_scheduled_subs(self) -> None:
|
|
||||||
"""Check for and deliver due scheduled reports."""
|
|
||||||
from datetime import datetime
|
|
||||||
from zoneinfo import ZoneInfo
|
|
||||||
|
|
||||||
tz = ZoneInfo(self.config.timezone)
|
|
||||||
now = datetime.now(tz)
|
|
||||||
current_hhmm = now.strftime("%H%M")
|
|
||||||
current_day = now.strftime("%a").lower()
|
|
||||||
|
|
||||||
due_subs = self.subscription_manager.get_due_subscriptions(current_hhmm, current_day)
|
|
||||||
|
|
||||||
for sub in due_subs:
|
|
||||||
try:
|
|
||||||
# Generate report based on scope
|
|
||||||
report = self._generate_sub_report(sub)
|
|
||||||
if not report:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Send DM to subscriber
|
|
||||||
user_id = sub["user_id"]
|
|
||||||
await self._send_sub_dm(user_id, report)
|
|
||||||
|
|
||||||
# Mark as sent
|
|
||||||
self.subscription_manager.mark_sent(sub["id"])
|
|
||||||
logger.info(f"Delivered {sub['sub_type']} report to {user_id}")
|
|
||||||
|
|
||||||
except Exception as e:
|
|
||||||
logger.error(f"Error delivering subscription {sub['id']}: {e}")
|
|
||||||
|
|
||||||
def _generate_sub_report(self, sub: dict) -> str:
|
|
||||||
"""Generate report content for a subscription."""
|
|
||||||
if not self.mesh_reporter:
|
|
||||||
return None
|
|
||||||
|
|
||||||
sub_type = sub["sub_type"]
|
|
||||||
scope_type = sub.get("scope_type", "mesh")
|
|
||||||
scope_value = sub.get("scope_value")
|
|
||||||
|
|
||||||
if scope_type == "region" and scope_value:
|
|
||||||
# Region-scoped report
|
|
||||||
region = self.mesh_reporter._find_region(scope_value)
|
|
||||||
if region:
|
|
||||||
return self.mesh_reporter.build_region_compact(region.name)
|
|
||||||
return None
|
|
||||||
elif scope_type == "node" and scope_value:
|
|
||||||
# Node-scoped report
|
|
||||||
return self.mesh_reporter.build_node_compact(scope_value)
|
|
||||||
else:
|
|
||||||
# Mesh-wide report
|
|
||||||
return self.mesh_reporter.build_lora_compact(scope="mesh")
|
|
||||||
|
|
||||||
async def _send_sub_dm(self, node_num: str, message: str) -> None:
|
|
||||||
"""Send a subscription DM to a node."""
|
|
||||||
if not self.connector:
|
|
||||||
return
|
|
||||||
|
|
||||||
# Convert node_num to destination format
|
|
||||||
try:
|
|
||||||
dest = int(node_num)
|
|
||||||
except ValueError:
|
|
||||||
dest = node_num
|
|
||||||
|
|
||||||
# Send via responder for proper chunking
|
|
||||||
if self.responder:
|
|
||||||
await self.responder.send_response(
|
|
||||||
message,
|
|
||||||
destination=dest,
|
|
||||||
channel=0, # DM channel
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
# Fallback to direct send
|
|
||||||
self.connector.send_message(message, destination=dest)
|
|
||||||
|
|
||||||
|
|
||||||
def setup_logging(verbose: bool = False) -> None:
|
def setup_logging(verbose: bool = False) -> None:
|
||||||
"""Configure logging."""
|
"""Configure logging."""
|
||||||
|
|
|
||||||
|
|
@ -445,6 +445,8 @@ class Dispatcher:
|
||||||
|
|
||||||
delivered_any = False
|
delivered_any = False
|
||||||
for ch_type in ch_types:
|
for ch_type in ch_types:
|
||||||
|
rule = None
|
||||||
|
payload = None
|
||||||
try:
|
try:
|
||||||
rule = self._toggle_to_rule(tog, ch_type, event)
|
rule = self._toggle_to_rule(tog, ch_type, event)
|
||||||
channel = self._channel_factory(rule, self._connector)
|
channel = self._channel_factory(rule, self._connector)
|
||||||
|
|
@ -458,15 +460,20 @@ class Dispatcher:
|
||||||
if success:
|
if success:
|
||||||
delivered_any = True
|
delivered_any = True
|
||||||
self._logger.info(f"Dispatched event {event.id} via toggle {fam}/{ch_type}")
|
self._logger.info(f"Dispatched event {event.id} via toggle {fam}/{ch_type}")
|
||||||
# v0.5.8b post-broadcast commit. Persistence-side
|
|
||||||
# bookkeeping that should only happen when a delivery
|
|
||||||
# actually went out: mesh_broadcasts_out audit row +
|
|
||||||
# handler-supplied last_broadcast_* UPDATE callback.
|
|
||||||
self._post_broadcast_commit(event, payload, rule, ch_type)
|
|
||||||
else:
|
else:
|
||||||
self._logger.warning(f"Toggle channel delivery returned False for {fam}/{ch_type}")
|
self._logger.warning(f"Toggle channel delivery returned False for {fam}/{ch_type}")
|
||||||
|
# v0.5.8b post-broadcast commit -> v20 per-mesh audit.
|
||||||
|
# Written ONCE PER MESH CHANNEL with its own transport+success,
|
||||||
|
# so a fan-out to both meshes yields two rows and a skip
|
||||||
|
# (deliver()==False) is still visible as success=0. The
|
||||||
|
# last_broadcast_* callback fires only when success is truthy.
|
||||||
|
self._post_broadcast_commit(event, payload, rule, ch_type,
|
||||||
|
success=bool(success))
|
||||||
except Exception:
|
except Exception:
|
||||||
self._logger.exception(f"Toggle channel delivery failed for {fam}/{ch_type}")
|
self._logger.exception(f"Toggle channel delivery failed for {fam}/{ch_type}")
|
||||||
|
# A crashed delivery is still a failed send -> success=0 row.
|
||||||
|
self._post_broadcast_commit(event, payload, rule, ch_type,
|
||||||
|
success=False)
|
||||||
|
|
||||||
# ---------- Section 6 — guard commit (v0.6-4, B13 fix) ----------
|
# ---------- Section 6 — guard commit (v0.6-4, B13 fix) ----------
|
||||||
# Cooldown arming + dedup recording happen ONLY after at least one
|
# Cooldown arming + dedup recording happen ONLY after at least one
|
||||||
|
|
@ -600,39 +607,75 @@ class Dispatcher:
|
||||||
success = await channel.deliver(payload, rule)
|
success = await channel.deliver(payload, rule)
|
||||||
except Exception:
|
except Exception:
|
||||||
self._logger.exception(
|
self._logger.exception(
|
||||||
"scheduled-broadcast: delivery raised for %s; skipping", ch_type)
|
"scheduled-broadcast: delivery raised for %s", ch_type)
|
||||||
continue
|
success = False
|
||||||
|
|
||||||
if success:
|
if success:
|
||||||
delivered_any = True
|
delivered_any = True
|
||||||
# Audit row -- mirrors _post_broadcast_commit for scheduled.
|
|
||||||
try:
|
# v20 per-mesh audit row. Written once per mesh channel with its
|
||||||
from meshai.persistence import get_db
|
# own transport+success, so a fan-out to both meshes yields two
|
||||||
conn = get_db()
|
# rows and a skip (deliver()==False) is visible as success=0.
|
||||||
bytes_sent = len(text.encode("utf-8")) if text else 0
|
try:
|
||||||
conn.execute(
|
from meshai.persistence import get_db
|
||||||
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, "
|
conn = get_db()
|
||||||
"channel, text, source_event_table, source_event_pk, "
|
bytes_sent = len(text.encode("utf-8")) if text else 0
|
||||||
"bytes_sent, ack_received) VALUES (?,?,?,?,?,?,?,?)",
|
transport, channel_id, recipient = self._audit_route(rule, ch_type)
|
||||||
(int(time.time()), "broadcast",
|
conn.execute(
|
||||||
rf.broadcast_channel, text,
|
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, "
|
||||||
source_event_table, str(source_event_pk),
|
"channel, text, source_event_table, source_event_pk, "
|
||||||
bytes_sent, 0),
|
"bytes_sent, ack_received, transport, success) "
|
||||||
)
|
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||||
except Exception:
|
(int(time.time()), recipient,
|
||||||
self._logger.exception(
|
channel_id, text,
|
||||||
"scheduled-broadcast: audit row insert failed for %s", ch_type)
|
source_event_table, str(source_event_pk),
|
||||||
|
bytes_sent, 0,
|
||||||
|
transport, 1 if success else 0),
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
self._logger.exception(
|
||||||
|
"scheduled-broadcast: audit row insert failed for %s", ch_type)
|
||||||
return delivered_any
|
return delivered_any
|
||||||
|
|
||||||
def _post_broadcast_commit(self, event, payload, rule, ch_type: str) -> None:
|
@staticmethod
|
||||||
"""Persistence side-effects of an actually-successful broadcast.
|
def _audit_route(rule, ch_type: str):
|
||||||
|
"""Resolve (transport, channel_id, recipient) for a mesh delivery.
|
||||||
|
|
||||||
Inserts the mesh_broadcasts_out audit row when the handler signalled
|
transport is the mesh family the row belongs to ("meshtastic" /
|
||||||
it wants one via `event.data["_broadcast_audit"]`, then invokes the
|
"meshcore"); channel_id is the Meshtastic channel INDEX or the
|
||||||
handler-supplied `_on_broadcast_committed` callback so the handler
|
MeshCore channel NAME; recipient is 'broadcast' or the DM target
|
||||||
can refresh its own last_broadcast_* bookkeeping. Both calls are
|
list. Mirrors create_channel()'s delivery_type routing.
|
||||||
wrapped: a bookkeeping failure must NOT undo the actual broadcast
|
"""
|
||||||
nor break dispatch for sibling toggles.
|
if ch_type == "mesh_broadcast":
|
||||||
|
return "meshtastic", getattr(rule, "broadcast_channel", None), "broadcast"
|
||||||
|
if ch_type == "meshcore_broadcast":
|
||||||
|
return "meshcore", getattr(rule, "meshcore_channel", None), "broadcast"
|
||||||
|
if ch_type == "mesh_dm":
|
||||||
|
node_ids = list(getattr(rule, "node_ids", []) or [])
|
||||||
|
return "meshtastic", None, (",".join(map(str, node_ids)) or "dm")
|
||||||
|
if ch_type == "meshcore_dm":
|
||||||
|
contacts = list(getattr(rule, "meshcore_dm_contacts", []) or [])
|
||||||
|
return "meshcore", None, (",".join(map(str, contacts)) or "meshcore_dm")
|
||||||
|
# Unknown / non-mesh: leave transport NULL, fall back to legacy channel.
|
||||||
|
return None, getattr(rule, "broadcast_channel", None), "broadcast"
|
||||||
|
|
||||||
|
def _post_broadcast_commit(self, event, payload, rule, ch_type: str,
|
||||||
|
*, success: bool = True) -> None:
|
||||||
|
"""Persistence side-effects of a per-mesh broadcast delivery.
|
||||||
|
|
||||||
|
Called ONCE PER MESH CHANNEL (one per delivery_type family), so a
|
||||||
|
broadcast that fans to both meshes writes TWO mesh_broadcasts_out
|
||||||
|
rows -- each carrying its own `transport` + `success` flag. The row
|
||||||
|
is written whenever the handler signalled it wants an audit trail
|
||||||
|
via `event.data["_broadcast_audit"]`, REGARDLESS of success, so a
|
||||||
|
skip/failure (e.g. MeshCore channel-not-found -> deliver()==False)
|
||||||
|
is still visible as success=0.
|
||||||
|
|
||||||
|
The handler-supplied `_on_broadcast_committed` callback (which
|
||||||
|
refreshes last_broadcast_* bookkeeping) fires ONLY when the send
|
||||||
|
actually landed (success is truthy). Both calls are wrapped: a
|
||||||
|
bookkeeping failure must NOT undo the actual broadcast nor break
|
||||||
|
dispatch for sibling toggles.
|
||||||
"""
|
"""
|
||||||
data = getattr(event, "data", None) or {}
|
data = getattr(event, "data", None) or {}
|
||||||
if not data:
|
if not data:
|
||||||
|
|
@ -646,23 +689,17 @@ class Dispatcher:
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
text = payload.message if payload is not None else (event.title or "")
|
text = payload.message if payload is not None else (event.title or "")
|
||||||
bytes_sent = len(text.encode("utf-8")) if text else 0
|
bytes_sent = len(text.encode("utf-8")) if text else 0
|
||||||
if ch_type == "mesh_dm":
|
transport, channel, recipient = self._audit_route(rule, ch_type)
|
||||||
node_ids = list(getattr(rule, "node_ids", []) or [])
|
|
||||||
recipient = ",".join(map(str, node_ids)) or "dm"
|
|
||||||
elif ch_type == "meshcore_dm":
|
|
||||||
contacts = list(getattr(rule, "meshcore_dm_contacts", []) or [])
|
|
||||||
recipient = ",".join(map(str, contacts)) or "meshcore_dm"
|
|
||||||
else:
|
|
||||||
recipient = "broadcast"
|
|
||||||
channel = getattr(rule, "broadcast_channel", None)
|
|
||||||
conn.execute(
|
conn.execute(
|
||||||
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, channel, "
|
"INSERT INTO mesh_broadcasts_out(sent_at, recipient, channel, "
|
||||||
"text, source_event_table, source_event_pk, bytes_sent, "
|
"text, source_event_table, source_event_pk, bytes_sent, "
|
||||||
"ack_received) VALUES (?,?,?,?,?,?,?,?)",
|
"ack_received, transport, success) "
|
||||||
|
"VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||||
(
|
(
|
||||||
int(committed_at), recipient, channel, text,
|
int(committed_at), recipient, channel, text,
|
||||||
audit.get("table"), audit.get("pk"),
|
audit.get("table"), audit.get("pk"),
|
||||||
bytes_sent, 0,
|
bytes_sent, 0,
|
||||||
|
transport, 1 if success else 0,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -672,6 +709,11 @@ class Dispatcher:
|
||||||
audit.get("table"), audit.get("pk"),
|
audit.get("table"), audit.get("pk"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if not success:
|
||||||
|
# A failed/skipped send is audited above but must NOT arm the
|
||||||
|
# handler's last_broadcast_* bookkeeping.
|
||||||
|
return
|
||||||
|
|
||||||
cb = data.get("_on_broadcast_committed")
|
cb = data.get("_on_broadcast_committed")
|
||||||
if callable(cb):
|
if callable(cb):
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -735,45 +735,6 @@ class NotificationRouter:
|
||||||
|
|
||||||
return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
|
return {"matches": False, "conditions": [], "preview": "Unknown rule type"}
|
||||||
|
|
||||||
def add_mesh_subscription(self, node_id: str, categories: list[str], rule_name: Optional[str] = None) -> str:
|
|
||||||
"""Add a mesh DM subscription for a node."""
|
|
||||||
if not rule_name:
|
|
||||||
rule_name = "sub_%s" % node_id
|
|
||||||
|
|
||||||
for rule in self._rules:
|
|
||||||
if rule.get("name") == rule_name:
|
|
||||||
rule["categories"] = categories if categories else []
|
|
||||||
rule["node_ids"] = [node_id]
|
|
||||||
return rule_name
|
|
||||||
|
|
||||||
self._rules.append({
|
|
||||||
"name": rule_name,
|
|
||||||
"enabled": True,
|
|
||||||
"trigger_type": "condition",
|
|
||||||
"categories": categories if categories else [],
|
|
||||||
"min_severity": "priority",
|
|
||||||
"delivery_type": "mesh_dm",
|
|
||||||
"node_ids": [node_id],
|
|
||||||
"cooldown_minutes": 10,
|
|
||||||
})
|
|
||||||
|
|
||||||
return rule_name
|
|
||||||
|
|
||||||
def remove_mesh_subscription(self, node_id: str) -> bool:
|
|
||||||
"""Remove a mesh subscription for a node."""
|
|
||||||
rule_name = "sub_%s" % node_id
|
|
||||||
self._rules = [r for r in self._rules if r.get("name") != rule_name]
|
|
||||||
return True
|
|
||||||
|
|
||||||
def get_node_subscriptions(self, node_id: str) -> list[str]:
|
|
||||||
"""Get categories a node is subscribed to."""
|
|
||||||
rule_name = "sub_%s" % node_id
|
|
||||||
for rule in self._rules:
|
|
||||||
if rule.get("name") == rule_name:
|
|
||||||
categories = rule.get("categories", [])
|
|
||||||
return categories if categories else ["all"]
|
|
||||||
return []
|
|
||||||
|
|
||||||
async def generate_report(self, report_type: str, env_store, health_engine) -> str:
|
async def generate_report(self, report_type: str, env_store, health_engine) -> str:
|
||||||
"""Generate an LLM-summarized report from current data."""
|
"""Generate an LLM-summarized report from current data."""
|
||||||
context_parts = []
|
context_parts = []
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,7 @@ logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DEFAULT_DB_PATH = "/data/meshai.sqlite"
|
DEFAULT_DB_PATH = "/data/meshai.sqlite"
|
||||||
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
|
MESHAI_DB_PATH_ENV = "MESHAI_DB_PATH"
|
||||||
SCHEMA_VERSION = 19
|
SCHEMA_VERSION = 20
|
||||||
SCHEMA_META_TABLE = "schema_meta"
|
SCHEMA_META_TABLE = "schema_meta"
|
||||||
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
MIGRATIONS_DIR = Path(__file__).parent / "migrations"
|
||||||
|
|
||||||
|
|
|
||||||
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
|
from .base import MeshTransport
|
||||||
|
|
||||||
|
|
||||||
def build_transport(config) -> MeshTransport:
|
def build_transport(config, meshcore_context=None) -> MeshTransport:
|
||||||
"""Instantiate and return the active MeshTransport derived from config.
|
"""Instantiate and return the active MeshTransport derived from config.
|
||||||
|
|
||||||
The active transports are derived from the connection config, not a
|
The active transports are derived from the connection config, not a
|
||||||
|
|
@ -16,6 +16,8 @@ def build_transport(config) -> MeshTransport:
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
config: A ConnectionConfig (or duck-compatible object).
|
config: A ConnectionConfig (or duck-compatible object).
|
||||||
|
meshcore_context: Optional MeshCoreContextConfig for the MeshCore
|
||||||
|
passive-context / bot-behavior filter (None = pass-through).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A concrete MeshTransport instance ready to be connected.
|
A concrete MeshTransport instance ready to be connected.
|
||||||
|
|
@ -27,6 +29,9 @@ def build_transport(config) -> MeshTransport:
|
||||||
if meshcore_host.strip():
|
if meshcore_host.strip():
|
||||||
from meshai.transport.meshcore_transport import MeshCoreTransport
|
from meshai.transport.meshcore_transport import MeshCoreTransport
|
||||||
from meshai.transport.composite_transport import CompositeTransport
|
from meshai.transport.composite_transport import CompositeTransport
|
||||||
return CompositeTransport([meshtastic, MeshCoreTransport(config)], config=config)
|
return CompositeTransport(
|
||||||
|
[meshtastic, MeshCoreTransport(config, meshcore_context=meshcore_context)],
|
||||||
|
config=config,
|
||||||
|
)
|
||||||
|
|
||||||
return meshtastic
|
return meshtastic
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,30 @@ logger = logging.getLogger(__name__)
|
||||||
_COMMAND_TIMEOUT = 10.0
|
_COMMAND_TIMEOUT = 10.0
|
||||||
|
|
||||||
|
|
||||||
|
def mc_context_allows(cfg, msg, idx_to_name):
|
||||||
|
"""Return True if a MeshCore inbound MeshMessage should be forwarded.
|
||||||
|
|
||||||
|
cfg: MeshCoreContextConfig or None. idx_to_name: dict[int,str] channel-idx->name.
|
||||||
|
"""
|
||||||
|
if cfg is None:
|
||||||
|
return True
|
||||||
|
if msg.is_dm:
|
||||||
|
if not cfg.respond_to_dms:
|
||||||
|
return False
|
||||||
|
# ignore_contacts matches the pubkey prefix (sender_id) OR the contact name (sender_name)
|
||||||
|
if msg.sender_id in cfg.ignore_contacts or msg.sender_name in cfg.ignore_contacts:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
# channel (non-DM) message -> only relevant for passive context
|
||||||
|
if not cfg.enable_passive_context:
|
||||||
|
return False
|
||||||
|
if cfg.observe_channels:
|
||||||
|
name = idx_to_name.get(msg.channel)
|
||||||
|
if name is None or name not in cfg.observe_channels:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class MeshCoreTransport(MeshTransport):
|
class MeshCoreTransport(MeshTransport):
|
||||||
"""MeshTransport implementation over a pyMC companion TCP frame server.
|
"""MeshTransport implementation over a pyMC companion TCP frame server.
|
||||||
|
|
||||||
|
|
@ -41,8 +65,12 @@ class MeshCoreTransport(MeshTransport):
|
||||||
# Name tag used by CompositeTransport for routing hints.
|
# Name tag used by CompositeTransport for routing hints.
|
||||||
transport_name: str = "meshcore"
|
transport_name: str = "meshcore"
|
||||||
|
|
||||||
def __init__(self, config) -> None:
|
def __init__(self, config, meshcore_context=None) -> None:
|
||||||
self.config = config
|
self.config = config
|
||||||
|
# MeshCore passive-context / bot-behavior filter (MeshCoreContextConfig
|
||||||
|
# or None). None = pass-through (no filtering). Injected at construction
|
||||||
|
# time by the factory; can also be (re)set via set_context_config().
|
||||||
|
self._mc_context = meshcore_context
|
||||||
self._mc = None # meshcore.MeshCore instance
|
self._mc = None # meshcore.MeshCore instance
|
||||||
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
self._loop: Optional[asyncio.AbstractEventLoop] = None
|
||||||
self._loop_thread: Optional[threading.Thread] = None
|
self._loop_thread: Optional[threading.Thread] = None
|
||||||
|
|
@ -142,6 +170,13 @@ class MeshCoreTransport(MeshTransport):
|
||||||
"""Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect)."""
|
"""Enumerated MeshCore channel names (from _chan_name_to_idx, populated at connect)."""
|
||||||
return list(self._chan_name_to_idx.keys())
|
return list(self._chan_name_to_idx.keys())
|
||||||
|
|
||||||
|
def set_context_config(self, cfg) -> None:
|
||||||
|
"""Set (or clear) the MeshCore passive-context filter config.
|
||||||
|
|
||||||
|
cfg: MeshCoreContextConfig or None (None = pass-through).
|
||||||
|
"""
|
||||||
|
self._mc_context = cfg
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Internal coroutines (run on the dedicated loop)
|
# Internal coroutines (run on the dedicated loop)
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
@ -416,13 +451,21 @@ class MeshCoreTransport(MeshTransport):
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _on_dm_event(self, event) -> None:
|
def _on_dm_event(self, event) -> None:
|
||||||
"""Handle CONTACT_MSG_RECV: normalize and dispatch to meshai."""
|
"""Handle CONTACT_MSG_RECV: normalize, filter, and dispatch to meshai."""
|
||||||
msg = self._normalize_dm_event(event)
|
msg = self._normalize_dm_event(event)
|
||||||
|
if msg is None or not mc_context_allows(
|
||||||
|
self._mc_context, msg, {v: k for k, v in self._chan_name_to_idx.items()}
|
||||||
|
):
|
||||||
|
return
|
||||||
self._dispatch_message(msg)
|
self._dispatch_message(msg)
|
||||||
|
|
||||||
def _on_channel_event(self, event) -> None:
|
def _on_channel_event(self, event) -> None:
|
||||||
"""Handle CHANNEL_MSG_RECV: normalize and dispatch to meshai."""
|
"""Handle CHANNEL_MSG_RECV: normalize, filter, and dispatch to meshai."""
|
||||||
msg = self._normalize_channel_event(event)
|
msg = self._normalize_channel_event(event)
|
||||||
|
if msg is None or not mc_context_allows(
|
||||||
|
self._mc_context, msg, {v: k for k, v in self._chan_name_to_idx.items()}
|
||||||
|
):
|
||||||
|
return
|
||||||
self._dispatch_message(msg)
|
self._dispatch_message(msg)
|
||||||
|
|
||||||
def _dispatch_message(self, msg: Optional[MeshMessage]) -> None:
|
def _dispatch_message(self, msg: Optional[MeshMessage]) -> None:
|
||||||
|
|
|
||||||
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