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 {
|
||||
Cloud, Flame, Radio, Car, Mountain, Satellite, Activity, Server,
|
||||
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info,
|
||||
Save, RotateCcw, RefreshCw, AlertCircle, AlertTriangle, Info, Bell,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
Toggle, TextInput, NumberInput, SelectInput, ListInput, NumberListInput,
|
||||
|
|
@ -11,6 +11,7 @@ import {
|
|||
fetchEnvStatus, fetchEnvActive,
|
||||
type EnvStatus, type EnvEvent,
|
||||
} from '@/lib/api'
|
||||
import { TOGGLE_FAMILY_META, type NotificationToggle, type NotificationsConfig } from './Notifications'
|
||||
|
||||
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: 'tracking', label: 'Tracking', icon: Satellite, adapters: ['satpass'] },
|
||||
{ key: 'mesh', label: 'Mesh Health', icon: Activity, adapters: [] },
|
||||
{ key: 'family_settings', label: 'Family Settings', icon: Bell, adapters: [] },
|
||||
]
|
||||
|
||||
// ---------------------------------------------------------------- main page
|
||||
|
|
@ -322,6 +324,13 @@ export default function Environment() {
|
|||
})
|
||||
const [satpassOriginal, setSatpassOriginal] = useState<string>("")
|
||||
|
||||
// ── Notification family gating state ──────────────────────────────────────
|
||||
const [notifConfig, setNotifConfig] = useState<NotificationsConfig | null>(null)
|
||||
const [notifOriginal, setNotifOriginal] = useState<string>('')
|
||||
const [notifSaving, setNotifSaving] = useState(false)
|
||||
const [notifError, setNotifError] = useState<string | null>(null)
|
||||
const [notifSuccess, setNotifSuccess] = useState<string | null>(null)
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
document.title = 'Environment — MeshAI'
|
||||
|
|
@ -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(() => {
|
||||
const load = async () => {
|
||||
try {
|
||||
|
|
@ -702,6 +725,69 @@ const save = async () => {
|
|||
|
||||
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 (!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>
|
||||
)}
|
||||
|
||||
{/* ── Family Settings — notification gating per family ──────────────── */}
|
||||
{family === 'family_settings' && (
|
||||
<div className="space-y-4">
|
||||
<p className="text-xs text-[#777]">
|
||||
Per-family gating: enable/disable each notification family, set its minimum severity threshold,
|
||||
freshness window, and cooldown. Delivery routing (which mesh channels, email, webhook) is
|
||||
configured on the <a href="/notifications" className="text-accent hover:underline">Meshtastic Routing</a> and{' '}
|
||||
<a href="/meshcore/routing" className="text-accent hover:underline">MeshCore Routing</a> pages.
|
||||
Regions filter is dormant and intentionally hidden.
|
||||
</p>
|
||||
{notifError && <div className="text-sm text-red-400 bg-red-500/10 p-3">{notifError}</div>}
|
||||
{notifSuccess && <div className="text-sm text-green-400 bg-green-500/10 p-3">{notifSuccess}</div>}
|
||||
{notifConfig === null ? (
|
||||
<div className="text-xs text-[#666] italic">Loading family settings…</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
|
||||
const t: NotificationToggle = notifToggles[key] || ({} as NotificationToggle)
|
||||
return (
|
||||
<div key={key} className="border border-border p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2 text-sm text-[#e0e0e0]">
|
||||
<Icon size={15} /> {label}
|
||||
</div>
|
||||
<Toggle label="" checked={!!t.enabled} onChange={(v) => updNotif(key, { enabled: v })} />
|
||||
</div>
|
||||
<div className={t.enabled ? 'space-y-3' : 'space-y-3 opacity-40 pointer-events-none select-none'}>
|
||||
<SelectInput
|
||||
label="Min Severity"
|
||||
value={t.min_severity || 'priority'}
|
||||
onChange={(v) => updNotif(key, { min_severity: v })}
|
||||
options={[
|
||||
{ value: 'routine', label: 'Routine — informational' },
|
||||
{ value: 'priority', label: 'Priority — needs attention' },
|
||||
{ value: 'immediate', label: 'Immediate — act now' },
|
||||
]}
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<NumberInput
|
||||
label="Freshness (sec)"
|
||||
value={t.freshness_seconds ?? 600}
|
||||
onChange={(v) => updNotif(key, { freshness_seconds: v })}
|
||||
min={0}
|
||||
helper="Drop events older than this"
|
||||
/>
|
||||
<NumberInput
|
||||
label="Cooldown (sec)"
|
||||
value={t.cooldown_seconds ?? 0}
|
||||
onChange={(v) => updNotif(key, { cooldown_seconds: v })}
|
||||
min={0}
|
||||
helper="0 = no throttle"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{notifHasChanges && (
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
onClick={discardNotif}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm text-[#777] hover:text-white border border-border"
|
||||
>
|
||||
<RotateCcw size={14} /> Discard
|
||||
</button>
|
||||
<button
|
||||
onClick={saveNotif}
|
||||
disabled={notifSaving}
|
||||
className="flex items-center gap-1 px-3 py-1.5 text-sm bg-accent text-white disabled:opacity-50"
|
||||
>
|
||||
<Save size={14} /> {notifSaving ? 'Saving…' : 'Save'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Adapter sub-tabs + panel */}
|
||||
{fam.adapters.length > 0 && activeAdapter && (
|
||||
<>
|
||||
|
|
|
|||
|
|
@ -197,16 +197,19 @@ export default function MeshCoreRouting() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cross-link note: shared family settings live on the Meshtastic Routing page */}
|
||||
{/* Cross-link note: gating on Data Feeds, MT+Other delivery on Meshtastic Routing */}
|
||||
<div className="flex items-start gap-2 p-3 bg-[#0a0e17] border border-[#1e2a3a] rounded text-sm text-slate-400">
|
||||
<ExternalLink size={16} className="text-accent mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
Shared per-family settings (enable, severity threshold, regions, freshness/cooldown, and
|
||||
Meshtastic / email / webhook delivery) live on the{' '}
|
||||
Family gating (enable, severity threshold, freshness/cooldown) is on{' '}
|
||||
<Link to="/environment" className="text-accent hover:underline">
|
||||
Data Feeds
|
||||
</Link>
|
||||
. Meshtastic and email/webhook/digest delivery is on{' '}
|
||||
<Link to="/notifications" className="text-accent hover:underline">
|
||||
Meshtastic Routing
|
||||
</Link>{' '}
|
||||
page. This page edits only the MeshCore delivery for each family.
|
||||
</Link>
|
||||
. This page edits only the MeshCore delivery for each family.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -225,7 +228,7 @@ export default function MeshCoreRouting() {
|
|||
<div className="bg-bg-card border border-border p-6 space-y-4">
|
||||
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||
MeshCore Delivery
|
||||
<InfoButton info="For each notification family, choose which MeshCore channels fire at each severity, the MeshCore channel name to broadcast on, and the DM contacts to unicast to. Enabling a family and its severity threshold are set on the Meshtastic Routing page." />
|
||||
<InfoButton info="For each notification family, choose which MeshCore channels fire at each severity, the MeshCore channel name to broadcast on, and the DM contacts to unicast to. Enabling a family and its severity threshold are configured on the Data Feeds page." />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
|
||||
|
|
|
|||
|
|
@ -1608,76 +1608,75 @@ export function SeverityChannelMatrix({
|
|||
)
|
||||
}
|
||||
|
||||
function MasterToggles({ toggles, onChange }: {
|
||||
// Merge only the MT+Other-channel-owned delivery fields of `mine` into `fresh`,
|
||||
// preserving MeshCore entries (meshcore_*) and all gating fields
|
||||
// (enabled/min_severity/freshness_seconds/cooldown_seconds/regions) that now live
|
||||
// on the Data Feeds page.
|
||||
function mergeMeshtasticAndOtherFields(
|
||||
fresh: NotificationToggle | undefined,
|
||||
mine: NotificationToggle,
|
||||
key: string,
|
||||
): NotificationToggle {
|
||||
const base: NotificationToggle = fresh ? { ...fresh } : { ...mine, name: key }
|
||||
base.name = base.name || key
|
||||
|
||||
// Merge severity_channels: keep meshcore_* from fresh, overlay non-meshcore from mine.
|
||||
const freshSC = fresh?.severity_channels || {}
|
||||
const mineSC = mine.severity_channels || {}
|
||||
const severities = new Set([...Object.keys(freshSC), ...Object.keys(mineSC)])
|
||||
const mergedSC: Record<string, string[]> = {}
|
||||
severities.forEach((sev) => {
|
||||
const meshcoreOnly = (freshSC[sev] || []).filter((c) => c.startsWith('meshcore_'))
|
||||
const nonMeshcore = (mineSC[sev] || []).filter((c) => !c.startsWith('meshcore_'))
|
||||
mergedSC[sev] = [...meshcoreOnly, ...nonMeshcore]
|
||||
})
|
||||
base.severity_channels = mergedSC
|
||||
|
||||
// Overlay MT delivery fields
|
||||
base.broadcast_channel = mine.broadcast_channel
|
||||
base.node_ids = mine.node_ids
|
||||
// Overlay Other channels delivery fields
|
||||
base.smtp_host = mine.smtp_host
|
||||
base.smtp_port = mine.smtp_port
|
||||
base.smtp_user = mine.smtp_user
|
||||
base.smtp_password = mine.smtp_password
|
||||
base.smtp_tls = mine.smtp_tls
|
||||
base.from_address = mine.from_address
|
||||
base.recipients = mine.recipients
|
||||
base.webhook_url = mine.webhook_url
|
||||
base.webhook_headers = mine.webhook_headers
|
||||
|
||||
// NOTE: do NOT overlay gating fields (enabled, min_severity, freshness_seconds,
|
||||
// cooldown_seconds, regions) — those are managed by Data Feeds > Family Settings.
|
||||
return base
|
||||
}
|
||||
|
||||
// Always-visible per-family Meshtastic delivery grid.
|
||||
// Structure mirrors MeshCoreRouting.tsx: pure delivery, no enable toggle, no expander.
|
||||
function MeshtasticDeliveryGrid({
|
||||
toggles,
|
||||
onChange,
|
||||
}: {
|
||||
toggles: Record<string, NotificationToggle>
|
||||
onChange: (t: Record<string, NotificationToggle>) => void
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState<string | null>(null)
|
||||
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
|
||||
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
|
||||
|
||||
return (
|
||||
<div className="space-y-3 mb-8">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||
Master Toggles
|
||||
<InfoButton info="Per-family notification policy: enable a family, set its severity threshold, choose which channels fire at each severity, and scope to regions (PagerDuty/Grafana-style)." />
|
||||
Meshtastic Delivery
|
||||
<InfoButton info="Per-family Meshtastic delivery matrix. Choose which channels fire at each severity, the broadcast channel index, and DM node IDs. Family on/off and severity threshold are configured on the Data Feeds page." />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
|
||||
const t = (toggles[key] || ({} as NotificationToggle))
|
||||
const isOpen = expanded === key
|
||||
const chanCount = Object.values(t.severity_channels || {}).reduce((n, arr) => n + ((arr as string[])?.length || 0), 0)
|
||||
const regionCount = (t.regions || []).length
|
||||
const t = toggles[key] || ({} as NotificationToggle)
|
||||
return (
|
||||
<div key={key} className="border border-[#1e2a3a] p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<button type="button" onClick={() => setExpanded(isOpen ? null : key)}
|
||||
className="flex items-center gap-2 text-sm text-slate-200">
|
||||
<div key={key} className="border border-[#1e2a3a] p-3 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-200">
|
||||
<Icon size={15} /> {label}
|
||||
{isOpen ? <ChevronDown size={14} /> : <ChevronRight size={14} />}
|
||||
</button>
|
||||
<Toggle label="" checked={!!t.enabled} onChange={(v) => upd(key, { enabled: v })} />
|
||||
</div>
|
||||
{!isOpen && (
|
||||
<div className="text-xs text-slate-600 mt-1">
|
||||
{t.enabled
|
||||
? `${regionCount || 'all'} region${regionCount === 1 ? '' : 's'}, ${chanCount} channel${chanCount === 1 ? '' : 's'} at ${t.min_severity || 'priority'}+`
|
||||
: 'OFF'}
|
||||
</div>
|
||||
)}
|
||||
{isOpen && (
|
||||
<div className={`mt-3 space-y-4 ${t.enabled ? '' : 'opacity-40 pointer-events-none select-none'}`}>
|
||||
|
||||
{/* ── General ──────────────────────────────────────────────── */}
|
||||
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
|
||||
<div className="text-xs text-slate-500 uppercase tracking-wide">General</div>
|
||||
<SeveritySelector value={t.min_severity || 'priority'} onChange={(v) => upd(key, { min_severity: v })} />
|
||||
<ListInput
|
||||
label="Regions (empty = all)"
|
||||
value={t.regions || []}
|
||||
onChange={(v) => upd(key, { regions: v })}
|
||||
placeholder="Add region..."
|
||||
/>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<NumberInput
|
||||
label="Freshness (sec)"
|
||||
value={t.freshness_seconds ?? 600}
|
||||
onChange={(v) => upd(key, { freshness_seconds: v })}
|
||||
min={0}
|
||||
helper="Drop events older than this"
|
||||
info="Events older than this window (seconds) are discarded at dispatcher entrance. 600 = 10 min."
|
||||
/>
|
||||
<NumberInput
|
||||
label="Cooldown (sec)"
|
||||
value={t.cooldown_seconds ?? 0}
|
||||
onChange={(v) => upd(key, { cooldown_seconds: v })}
|
||||
min={0}
|
||||
helper="0 = no throttle"
|
||||
info="Per (family, category, region) throttle window. Prevents repeat sends within this window."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Meshtastic ───────────────────────────────────────────── */}
|
||||
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
|
||||
<Radio size={13} />
|
||||
|
|
@ -1705,12 +1704,40 @@ function MasterToggles({ toggles, onChange }: {
|
|||
info="Hex node IDs for mesh_dm delivery (e.g. !a1b2c3d4). Used when mesh_dm is enabled for a severity."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
{/* MeshCore delivery controls moved to the dedicated
|
||||
MeshCore -> Routing page (/meshcore/routing). Shared
|
||||
family settings (enable/severity/regions) stay here. */}
|
||||
// Per-family Other Channels (email / webhook / digest) delivery grid.
|
||||
// Kept on the Meshtastic Routing page so all non-MeshCore delivery lives in one place.
|
||||
function OtherChannelsGrid({
|
||||
toggles,
|
||||
onChange,
|
||||
}: {
|
||||
toggles: Record<string, NotificationToggle>
|
||||
onChange: (t: Record<string, NotificationToggle>) => void
|
||||
}) {
|
||||
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
|
||||
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), name: fam, ...patch } as NotificationToggle })
|
||||
|
||||
{/* ── Other channels ───────────────────────────────────────── */}
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center text-xs text-slate-500 uppercase tracking-wide">
|
||||
Other Channels (Email / Webhook / Digest)
|
||||
<InfoButton info="Per-family delivery via email, webhook, and digest. The severity matrix selects which of these channels fire at each level. Email SMTP settings are behind the collapsible below each family." />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||
{TOGGLE_FAMILY_META.map(({ key, label, Icon }) => {
|
||||
const t = toggles[key] || ({} as NotificationToggle)
|
||||
return (
|
||||
<div key={key} className="border border-[#1e2a3a] p-3 space-y-3">
|
||||
<div className="flex items-center gap-2 text-sm text-slate-200">
|
||||
<Icon size={15} /> {label}
|
||||
</div>
|
||||
<div className="space-y-3 p-3 bg-[#0a0e17] border border-[#1e2a3a]">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-slate-300">
|
||||
<Mail size={13} />
|
||||
|
|
@ -1749,9 +1776,6 @@ function MasterToggles({ toggles, onChange }: {
|
|||
helper="POST alert as JSON"
|
||||
/>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
|
@ -1825,10 +1849,34 @@ export default function Notifications() {
|
|||
setSuccess(null)
|
||||
|
||||
try {
|
||||
// Re-fetch the live config and merge ONLY MT+Other delivery fields so we never
|
||||
// clobber gating fields (enabled/min_severity/freshness/cooldown) managed by
|
||||
// Data Feeds, nor MeshCore fields managed by the MeshCore Routing page.
|
||||
const freshRes = await fetch('/api/config/notifications')
|
||||
if (!freshRes.ok) throw new Error('Failed to re-fetch notifications config')
|
||||
const fresh: NotificationsConfig = await freshRes.json()
|
||||
|
||||
const merged: NotificationsConfig = {
|
||||
...fresh,
|
||||
enabled: config.enabled, // global master switch lives on this page
|
||||
rules: config.rules, // rules are only edited on this page
|
||||
toggles: { ...(fresh.toggles || {}) },
|
||||
}
|
||||
const myToggles = config.toggles || {}
|
||||
for (const { key } of TOGGLE_FAMILY_META) {
|
||||
const mine = myToggles[key]
|
||||
if (!mine) continue
|
||||
merged.toggles![key] = mergeMeshtasticAndOtherFields(
|
||||
(fresh.toggles || {})[key],
|
||||
mine,
|
||||
key,
|
||||
)
|
||||
}
|
||||
|
||||
const res = await fetch('/api/config/notifications', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(config),
|
||||
body: JSON.stringify(merged),
|
||||
})
|
||||
|
||||
const result = await res.json()
|
||||
|
|
@ -1837,10 +1885,11 @@ export default function Notifications() {
|
|||
throw new Error(result.detail || 'Save failed')
|
||||
}
|
||||
|
||||
setSuccess('Notifications config saved successfully')
|
||||
setOriginalConfig(JSON.parse(JSON.stringify(config)))
|
||||
setConfig(merged)
|
||||
setOriginalConfig(JSON.parse(JSON.stringify(merged)))
|
||||
setHasChanges(false)
|
||||
setDirty(false)
|
||||
setSuccess('Meshtastic routing saved successfully')
|
||||
setTimeout(() => setSuccess(null), 3000)
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Save failed')
|
||||
|
|
@ -2144,7 +2193,7 @@ export default function Notifications() {
|
|||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-slate-500">
|
||||
Alert delivery and scheduled reports. Rules define what triggers a notification and where it gets sent.
|
||||
Per-family Meshtastic delivery and other-channel delivery. Family gating (enable, severity threshold, freshness/cooldown) is on <a href="/environment" className="text-accent hover:underline">Data Feeds</a>. MeshCore delivery is on the <a href="/meshcore/routing" className="text-accent hover:underline">MeshCore Routing</a> page.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -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."
|
||||
/>
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* Master Toggles */}
|
||||
{/* Meshtastic delivery grids — always visible regardless of master switch */}
|
||||
{config.toggles && (
|
||||
<MasterToggles
|
||||
<>
|
||||
<MeshtasticDeliveryGrid
|
||||
toggles={config.toggles}
|
||||
onChange={(t) => setConfig({ ...config, toggles: t })}
|
||||
/>
|
||||
<OtherChannelsGrid
|
||||
toggles={config.toggles}
|
||||
onChange={(t) => setConfig({ ...config, toggles: t })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{config.enabled && (
|
||||
<>
|
||||
{/* Rules Section */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue