mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
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>
This commit is contained in:
parent
236a9cada5
commit
361e6d1bc4
3 changed files with 372 additions and 147 deletions
|
|
@ -1,7 +1,7 @@
|
||||||
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,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import {
|
import {
|
||||||
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
|
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
|
||||||
|
|
@ -11,6 +11,7 @@ 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'
|
||||||
|
|
||||||
type FeedSource = 'native' | 'central'
|
type FeedSource = 'native' | 'central'
|
||||||
|
|
||||||
|
|
@ -248,6 +249,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
|
||||||
|
|
@ -322,6 +324,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'
|
||||||
|
|
@ -479,6 +488,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 {
|
||||||
|
|
@ -702,6 +725,69 @@ const save = async () => {
|
||||||
|
|
||||||
const up = (patch: Partial<EnvConfig>) => env && setEnv({ ...env, ...patch })
|
const up = (patch: Partial<EnvConfig>) => env && setEnv({ ...env, ...patch })
|
||||||
|
|
||||||
|
// ── 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>
|
||||||
|
|
||||||
|
|
@ -1248,6 +1334,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 && (
|
||||||
<>
|
<>
|
||||||
|
|
|
||||||
|
|
@ -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 }) => {
|
||||||
|
|
|
||||||
|
|
@ -1608,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>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|
@ -1825,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()
|
||||||
|
|
@ -1837,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')
|
||||||
|
|
@ -2144,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">
|
||||||
|
|
@ -2197,16 +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 && (
|
||||||
<>
|
<>
|
||||||
{/* 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">
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue