mirror of
https://github.com/zvx-echo6/meshai.git
synced 2026-08-26 17:31:34 +00:00
feat(gui): Routing renders families from the registry (custom families assignable)
Add GET /api/notifications/families and make the Routing UI merge the static built-in families with registered dynamic families, so a generic source's family appears as a toggle the operator can enable and assign a delivery destination (mesh/meshcore/email/webhook). Closes "there's nowhere to broadcast it" — a custom source is now routable from the GUI. Data Feeds fold + orphan-page retirement follow in B2. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
91e00d28e0
commit
661d5913ef
2 changed files with 96 additions and 7 deletions
|
|
@ -1427,7 +1427,13 @@ function NotificationRuleCard({
|
|||
}
|
||||
|
||||
// Main Notifications Page Component
|
||||
export const TOGGLE_FAMILY_META: { key: string; label: string; Icon: typeof Activity }[] = [
|
||||
export type FamilyMeta = { key: string; label: string; Icon: typeof Activity }
|
||||
|
||||
// Static base list of built-in families with their curated icons. This stays the
|
||||
// canonical export (Environment.tsx still imports it directly). Dynamic/generic
|
||||
// families registered at runtime are layered on top via useFamilies() below —
|
||||
// this list is NEVER mutated.
|
||||
export const TOGGLE_FAMILY_META: FamilyMeta[] = [
|
||||
{ key: 'mesh_health', label: 'Mesh Health', Icon: Activity },
|
||||
{ key: 'weather', label: 'Weather', Icon: Cloud },
|
||||
{ key: 'fire', label: 'Fire', Icon: Flame },
|
||||
|
|
@ -1439,6 +1445,63 @@ export const TOGGLE_FAMILY_META: { key: string; label: string; Icon: typeof Acti
|
|||
{ key: 'tracking', label: 'Tracking', Icon: MapPin },
|
||||
]
|
||||
|
||||
// Shape returned by GET /api/notifications/families.
|
||||
type ApiFamily = { key: string; label: string; dynamic: boolean }
|
||||
|
||||
// Merge the static built-ins with API-reported families. Static entries win on a
|
||||
// key collision (keep their curated icon + label); any family NOT already in the
|
||||
// static set is appended with a generic Layers icon and its API label. Dedups by key.
|
||||
function mergeFamilies(apiFamilies: ApiFamily[]): FamilyMeta[] {
|
||||
const seen = new Set(TOGGLE_FAMILY_META.map((f) => f.key))
|
||||
const extra: FamilyMeta[] = []
|
||||
for (const f of apiFamilies) {
|
||||
if (!f || !f.key || seen.has(f.key)) continue // static wins; skip dupes
|
||||
seen.add(f.key)
|
||||
extra.push({ key: f.key, label: f.label || f.key, Icon: Layers })
|
||||
}
|
||||
return [...TOGGLE_FAMILY_META, ...extra]
|
||||
}
|
||||
|
||||
// Module-level cache so the several consumers of useFamilies() (the two delivery
|
||||
// grids, the category picker, the save loop) share a SINGLE fetch rather than
|
||||
// each issuing their own request on mount.
|
||||
let _familiesCache: FamilyMeta[] | null = null
|
||||
let _familiesPromise: Promise<FamilyMeta[]> | null = null
|
||||
|
||||
// Returns the MERGED family list (static built-ins + registered dynamic families).
|
||||
// While the fetch is in-flight — or if it fails — it returns just the static
|
||||
// TOGGLE_FAMILY_META, so the UI always renders the built-ins and degrades
|
||||
// gracefully. Once families are known they render as ordinary assignable rows.
|
||||
export function useFamilies(): FamilyMeta[] {
|
||||
const [families, setFamilies] = useState<FamilyMeta[]>(_familiesCache ?? TOGGLE_FAMILY_META)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
if (_familiesCache) {
|
||||
setFamilies(_familiesCache)
|
||||
return
|
||||
}
|
||||
if (!_familiesPromise) {
|
||||
_familiesPromise = fetch('/api/notifications/families')
|
||||
.then((r) => (r.ok ? r.json() : []))
|
||||
.then((data: unknown) => {
|
||||
const merged = mergeFamilies(Array.isArray(data) ? (data as ApiFamily[]) : [])
|
||||
_familiesCache = merged
|
||||
return merged
|
||||
})
|
||||
.catch(() => TOGGLE_FAMILY_META) // fall back to static on error
|
||||
}
|
||||
_familiesPromise.then((merged) => {
|
||||
if (!cancelled) setFamilies(merged)
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
return families
|
||||
}
|
||||
|
||||
// Grouped category picker — shows categories family-by-family using TOGGLE_FAMILY_META
|
||||
// for icons/labels/order. Each family has a Select-all / Clear control and a count.
|
||||
// Categories whose `toggle` field doesn't match a known family fall into "Other".
|
||||
|
|
@ -1453,10 +1516,11 @@ function GroupedCategoryPicker({
|
|||
onToggle: (catId: string) => void
|
||||
onSelectMany: (catIds: string[], action: 'add' | 'remove') => void
|
||||
}) {
|
||||
const FAMILY_KEYS = new Set(TOGGLE_FAMILY_META.map(f => f.key))
|
||||
const families = useFamilies()
|
||||
const FAMILY_KEYS = new Set(families.map(f => f.key))
|
||||
// Group by toggle, preserving family order; collect unknowns into "other".
|
||||
const byFamily = new Map<string, AlertCategory[]>()
|
||||
TOGGLE_FAMILY_META.forEach(f => byFamily.set(f.key, []))
|
||||
families.forEach(f => byFamily.set(f.key, []))
|
||||
const other: AlertCategory[] = []
|
||||
for (const cat of categories) {
|
||||
const fam = cat.toggle
|
||||
|
|
@ -1546,7 +1610,7 @@ function GroupedCategoryPicker({
|
|||
|
||||
return (
|
||||
<div className="max-h-96 overflow-y-auto border border-[#1e2a3a] p-2 space-y-2">
|
||||
{TOGGLE_FAMILY_META.map(f => renderGroup(f.key, f.label, f.Icon, byFamily.get(f.key) || []))}
|
||||
{families.map(f => renderGroup(f.key, f.label, f.Icon, byFamily.get(f.key) || []))}
|
||||
{renderGroup('other', 'Other', null, other)}
|
||||
</div>
|
||||
)
|
||||
|
|
@ -1669,6 +1733,7 @@ function MeshtasticDeliveryGrid({
|
|||
toggles: Record<string, NotificationToggle>
|
||||
onChange: (t: Record<string, NotificationToggle>) => void
|
||||
}) {
|
||||
const families = useFamilies()
|
||||
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
|
||||
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), ...patch } as NotificationToggle })
|
||||
|
||||
|
|
@ -1679,7 +1744,7 @@ function MeshtasticDeliveryGrid({
|
|||
<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 }) => {
|
||||
{families.map(({ key, label, Icon }) => {
|
||||
const t = toggles[key] || ({} as NotificationToggle)
|
||||
return (
|
||||
<div key={key} className="border border-[#1e2a3a] p-3 space-y-3">
|
||||
|
|
@ -1730,6 +1795,7 @@ function OtherChannelsGrid({
|
|||
toggles: Record<string, NotificationToggle>
|
||||
onChange: (t: Record<string, NotificationToggle>) => void
|
||||
}) {
|
||||
const families = useFamilies()
|
||||
const upd = (fam: string, patch: Partial<NotificationToggle>) =>
|
||||
onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), ...patch } as NotificationToggle })
|
||||
|
||||
|
|
@ -1740,7 +1806,7 @@ function OtherChannelsGrid({
|
|||
<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 }) => {
|
||||
{families.map(({ key, label, Icon }) => {
|
||||
const t = toggles[key] || ({} as NotificationToggle)
|
||||
return (
|
||||
<div key={key} className="border border-[#1e2a3a] p-3 space-y-3">
|
||||
|
|
@ -1819,6 +1885,7 @@ function OtherChannelsGrid({
|
|||
|
||||
export default function Notifications() {
|
||||
const { setDirty } = useDirty()
|
||||
const families = useFamilies()
|
||||
const [config, setConfig] = useState<NotificationsConfig | null>(null)
|
||||
const [originalConfig, setOriginalConfig] = useState<NotificationsConfig | null>(null)
|
||||
const [categories, setCategories] = useState<AlertCategory[]>([])
|
||||
|
|
@ -1897,7 +1964,7 @@ export default function Notifications() {
|
|||
toggles: { ...(fresh.toggles || {}) },
|
||||
}
|
||||
const myToggles = config.toggles || {}
|
||||
for (const { key } of TOGGLE_FAMILY_META) {
|
||||
for (const { key } of families) {
|
||||
const mine = myToggles[key]
|
||||
if (!mine) continue
|
||||
merged.toggles![key] = mergeMeshtasticAndOtherFields(
|
||||
|
|
|
|||
|
|
@ -52,6 +52,28 @@ async def get_categories():
|
|||
return []
|
||||
|
||||
|
||||
@router.get("/families")
|
||||
async def get_families():
|
||||
"""List all routable families (toggles) for the Routing UI.
|
||||
|
||||
Returns the built-in (static VALID_TOGGLES) families PLUS any families
|
||||
registered at runtime by generic/config-driven sources (Integration
|
||||
Phase A). Each entry is `{key, label, dynamic}` where `dynamic` is True
|
||||
for families that are NOT part of the static VALID_TOGGLES set — i.e.
|
||||
generic sources whose category was registered as a first-class family.
|
||||
The frontend merges these with its icon table so custom families render
|
||||
as assignable toggle rows. Read-only.
|
||||
"""
|
||||
try:
|
||||
from ...notifications.categories import registered_families, VALID_TOGGLES
|
||||
except ImportError:
|
||||
return []
|
||||
return [
|
||||
{"key": key, "label": label, "dynamic": key not in VALID_TOGGLES}
|
||||
for key, label in registered_families().items()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/rules")
|
||||
async def get_rules(request: Request):
|
||||
"""Get configured notification rules with stats."""
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue