diff --git a/work/dashboard-frontend/src/pages/Notifications.tsx b/work/dashboard-frontend/src/pages/Notifications.tsx index 7bd0fa0..11e26ee 100644 --- a/work/dashboard-frontend/src/pages/Notifications.tsx +++ b/work/dashboard-frontend/src/pages/Notifications.tsx @@ -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 | 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(_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() - 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 (
- {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)}
) @@ -1669,6 +1733,7 @@ function MeshtasticDeliveryGrid({ toggles: Record onChange: (t: Record) => void }) { + const families = useFamilies() const upd = (fam: string, patch: Partial) => onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), ...patch } as NotificationToggle }) @@ -1679,7 +1744,7 @@ function MeshtasticDeliveryGrid({
- {TOGGLE_FAMILY_META.map(({ key, label, Icon }) => { + {families.map(({ key, label, Icon }) => { const t = toggles[key] || ({} as NotificationToggle) return (
@@ -1730,6 +1795,7 @@ function OtherChannelsGrid({ toggles: Record onChange: (t: Record) => void }) { + const families = useFamilies() const upd = (fam: string, patch: Partial) => onChange({ ...toggles, [fam]: { ...(toggles[fam] || {}), ...patch } as NotificationToggle }) @@ -1740,7 +1806,7 @@ function OtherChannelsGrid({
- {TOGGLE_FAMILY_META.map(({ key, label, Icon }) => { + {families.map(({ key, label, Icon }) => { const t = toggles[key] || ({} as NotificationToggle) return (
@@ -1819,6 +1885,7 @@ function OtherChannelsGrid({ export default function Notifications() { const { setDirty } = useDirty() + const families = useFamilies() const [config, setConfig] = useState(null) const [originalConfig, setOriginalConfig] = useState(null) const [categories, setCategories] = useState([]) @@ -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( diff --git a/work/meshai/dashboard/api/notification_routes.py b/work/meshai/dashboard/api/notification_routes.py index 4dbbf47..98e424e 100644 --- a/work/meshai/dashboard/api/notification_routes.py +++ b/work/meshai/dashboard/api/notification_routes.py @@ -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."""