From 58df4f14810e54b3217fc3b901daa5e7e54af370 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Tue, 3 Mar 2026 16:42:12 +0100 Subject: [PATCH] update notification service --- AppImage/components/notification-settings.tsx | 419 +++++++----------- AppImage/scripts/notification_manager.py | 98 ++-- 2 files changed, 176 insertions(+), 341 deletions(-) diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index 99e6f4d2..d8b1bc57 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -223,6 +223,148 @@ export function NotificationSettings() { })) } + /** Reusable 10+1 category block rendered inside each channel tab. */ + const renderChannelCategories = (chName: string, accentColor: string) => { + const overrides = config.channel_overrides?.[chName] || { categories: {}, events: {} } + const evtByGroup = config.event_types_by_group || {} + + return ( +
+
+ + +
+
+ {EVENT_CATEGORIES.map(cat => { + const isEnabled = overrides.categories[cat.key] ?? true + const isExpanded = expandedCategories.has(`${chName}.${cat.key}`) + const eventsForGroup = evtByGroup[cat.key] || [] + const enabledCount = eventsForGroup.filter( + e => (overrides.events?.[e.type] ?? e.default_enabled) + ).length + + return ( +
+
+ + +
+ {cat.label} + {cat.desc} +
+ + {isEnabled && eventsForGroup.length > 0 && ( + + {enabledCount}/{eventsForGroup.length} + + )} + + +
+ + {isEnabled && isExpanded && eventsForGroup.length > 0 && ( +
+ {eventsForGroup.map(evt => { + const evtEnabled = overrides.events?.[evt.type] ?? evt.default_enabled + return ( +
+ + {evt.title} + + +
+ ) + })} +
+ )} +
+ ) + })} +
+
+ ) + } + /** Flatten the nested NotificationConfig into the flat key-value map the backend expects. */ const flattenConfig = (cfg: NotificationConfig): Record => { const flat: Record = { @@ -244,28 +386,8 @@ export function NotificationSettings() { flat[`${chName}.${field}`] = String(value ?? "") } } - // Flatten global event_categories: { vm_ct: true, backup: false } -> events.vm_ct, events.backup - for (const [cat, enabled] of Object.entries(cfg.event_categories)) { - flat[`events.${cat}`] = String(enabled) - } - // Flatten global event_toggles: { vm_start: true } -> event.vm_start - if (cfg.event_toggles) { - for (const [evt, enabled] of Object.entries(cfg.event_toggles)) { - flat[`event.${evt}`] = String(enabled) - } - } - // Write defaults for events NOT in toggles - if (cfg.event_types_by_group) { - for (const events of Object.values(cfg.event_types_by_group)) { - for (const evt of (events as Array<{type: string, default_enabled: boolean}>)) { - const key = `event.${evt.type}` - if (!(key in flat)) { - flat[key] = String(evt.default_enabled) - } - } - } - } - // Flatten per-channel overrides: telegram.events.backup, telegram.event.vm_start, etc. + // Per-channel category & event toggles: telegram.events.vm_ct, telegram.event.vm_start, etc. + // Each channel independently owns its notification preferences. if (cfg.channel_overrides) { for (const [chName, overrides] of Object.entries(cfg.channel_overrides)) { if (overrides.categories) { @@ -754,6 +876,7 @@ matcher: proxmenux-pbs onChange={e => updateChannel("telegram", "chat_id", e.target.value)} /> + {renderChannelCategories("telegram", "blue")} {/* Per-channel action bar */}
+ {renderChannelCategories("gotify", "green")} {/* Per-channel action bar */}
+ {renderChannelCategories("discord", "indigo")} {/* Per-channel action bar */}
+ {renderChannelCategories("email", "amber")} {/* Per-channel action bar */}
- {/* ── Filters ── */} -
-
- - Filters & Events -
-
- {/* Event Categories (global defaults -- per-channel overrides in Channel Filters below) */} -
- -
- {EVENT_CATEGORIES.map(cat => { - const isEnabled = config.event_categories[cat.key] ?? true - const isExpanded = expandedCategories.has(cat.key) - const eventsForGroup = config.event_types_by_group?.[cat.key] || [] - const enabledCount = eventsForGroup.filter(e => config.event_toggles?.[e.type] ?? e.default_enabled).length - - return ( -
- {/* Category header row */} -
- {/* Expand/collapse button */} - - - {/* Label + description */} -
- - {cat.label} - - {cat.desc} -
- - {/* Count badge */} - {isEnabled && eventsForGroup.length > 0 && ( - - {enabledCount}/{eventsForGroup.length} - - )} - - {/* Category toggle */} - -
- - {/* Per-event toggles (expanded) */} - {isEnabled && isExpanded && eventsForGroup.length > 0 && ( -
- {eventsForGroup.map(evt => { - const evtEnabled = config.event_toggles?.[evt.type] ?? evt.default_enabled - return ( -
- - {evt.title} - - -
- ) - })} -
- )} -
- ) - })} -
-
- - {/* Per-channel overrides */} -
- -

- By default every channel inherits the global settings above. Override specific categories per channel to customize what each destination receives. -

-
- {CHANNEL_TYPES.map(chName => { - const chEnabled = config.channels[chName]?.enabled - if (!chEnabled) return null - const overrides = config.channel_overrides?.[chName] || { categories: {}, events: {} } - const hasOverrides = Object.keys(overrides.categories).length > 0 - const chLabel = chName === "email" ? "Email" : chName.charAt(0).toUpperCase() + chName.slice(1) - const chColor = chName === "telegram" ? "blue" : chName === "gotify" ? "green" : chName === "discord" ? "indigo" : "amber" - - return ( -
- -
- - {chLabel} - {hasOverrides && ( - - customized - - )} -
- {!hasOverrides && ( - inherits global - )} -
-
- {EVENT_CATEGORIES.map(cat => { - const globalEnabled = config.event_categories[cat.key] ?? true - const override = overrides.categories[cat.key] - const isCustomized = override !== undefined - const effectiveEnabled = isCustomized ? override : globalEnabled - - return ( -
-
- - {cat.label} - - {!isCustomized && ( - global - )} -
-
- {isCustomized && ( - - )} - -
-
- ) - })} -
-
- ) - })} - {CHANNEL_TYPES.every(ch => !config.channels[ch]?.enabled) && ( -

- Enable at least one channel above to configure per-channel filters. -

- )} -
-
- -
{/* close bordered filters container */} -
- {/* ── Proxmox Webhook ── */}
diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index ee9ec3b2..d9a91760 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -560,28 +560,14 @@ class NotificationManager: print(f"[NotificationManager] Aggregation flush error: {e}") def _process_event(self, event: NotificationEvent): - """Process a single event: filter -> aggregate -> cooldown -> rate limit -> dispatch. + """Process a single event: aggregate -> cooldown -> rate limit -> dispatch. - NOTE: Group and per-event filters are checked globally here. - Per-channel overrides are applied later in _dispatch_to_channels(). + Per-channel category/event filters are applied in _dispatch_to_channels(). + No global category/event filter exists -- each channel decides independently. """ if not self._enabled: return - # Check if this event's GROUP is enabled globally. - template = TEMPLATES.get(event.event_type, {}) - event_group = template.get('group', 'other') - group_setting = f'events.{event_group}' - if self._config.get(group_setting, 'true') == 'false': - return - - # Check if this SPECIFIC event type is enabled globally. - # Default comes from the template's default_enabled field. - default_enabled = 'true' if template.get('default_enabled', True) else 'false' - event_specific = f'event.{event.event_type}' - if self._config.get(event_specific, default_enabled) == 'false': - return - # Try aggregation (may buffer the event) result = self._aggregator.ingest(event) if result is None: @@ -592,23 +578,10 @@ class NotificationManager: self._dispatch_event(event) def _process_event_direct(self, event: NotificationEvent): - """Process a burst summary event. Bypasses aggregator but applies global filters.""" + """Process a burst summary event. Bypasses aggregator but applies cooldown + rate limit.""" if not self._enabled: return - # Check group filter - template = TEMPLATES.get(event.event_type, {}) - event_group = template.get('group', 'other') - group_setting = f'events.{event_group}' - if self._config.get(group_setting, 'true') == 'false': - return - - # Check per-event filter - default_enabled = 'true' if template.get('default_enabled', True) else 'false' - event_specific = f'event.{event.event_type}' - if self._config.get(event_specific, default_enabled) == 'false': - return - self._dispatch_event(event) def _dispatch_event(self, event: NotificationEvent): @@ -666,32 +639,32 @@ class NotificationManager: def _dispatch_to_channels(self, title: str, body: str, severity: str, event_type: str, data: Dict, source: str): - """Send notification through configured channels, respecting per-channel overrides. + """Send notification through configured channels, respecting per-channel filters. - Each channel can override global category/event settings: - - {channel}.events.{group} = "true"/"false" (category override) - - {channel}.event.{type} = "true"/"false" (per-event override) - If no override exists, the channel inherits the global setting (already checked). + Each channel owns its own category/event preferences: + - {channel}.events.{group} = "true"/"false" (category toggle, default "true") + - {channel}.event.{type} = "true"/"false" (per-event toggle, default from template) + No global fallback -- each channel decides independently what it receives. """ with self._lock: channels = dict(self._channels) template = TEMPLATES.get(event_type, {}) event_group = template.get('group', 'other') + default_event_enabled = 'true' if template.get('default_enabled', True) else 'false' for ch_name, channel in channels.items(): - # ── Per-channel override check ── - # If the channel has an explicit override for this group or event, respect it. - # If no override, the global filter already passed (checked in _process_event). + # ── Per-channel category check ── + # Default: category enabled (true) unless explicitly disabled. ch_group_key = f'{ch_name}.events.{event_group}' - ch_group_override = self._config.get(ch_group_key) - if ch_group_override == 'false': - continue # Channel explicitly disabled this category + if self._config.get(ch_group_key, 'true') == 'false': + continue # Channel has this category disabled + # ── Per-channel event check ── + # Default: from template default_enabled, unless explicitly set. ch_event_key = f'{ch_name}.event.{event_type}' - ch_event_override = self._config.get(ch_event_key) - if ch_event_override == 'false': - continue # Channel explicitly disabled this event + if self._config.get(ch_event_key, default_event_enabled) == 'false': + continue # Channel has this specific event disabled try: result = channel.send(title, body, severity, data) @@ -1178,45 +1151,30 @@ class NotificationManager: ch_cfg[config_key] = self._config.get(f'{ch_type}.{config_key}', '') channels[ch_type] = ch_cfg - # Build event_categories dict (group-level toggle) - # EVENT_GROUPS is a dict: { 'vm_ct': {...}, 'services': {...}, 'health': {...}, ... } - event_categories = {} - for group_key in EVENT_GROUPS: - event_categories[group_key] = self._config.get(f'events.{group_key}', 'true') == 'true' - - # Build per-event toggles: { 'vm_start': true, 'vm_stop': false, ... } - event_toggles = {} - for event_type, tmpl in TEMPLATES.items(): - default = tmpl.get('default_enabled', True) - saved = self._config.get(f'event.{event_type}', None) - if saved is not None: - event_toggles[event_type] = saved == 'true' - else: - event_toggles[event_type] = default - # Build event_types_by_group for UI rendering event_types_by_group = get_event_types_by_group() # Build per-channel overrides + # Each channel independently owns its category and event toggles. # Keys: {channel}.events.{group} and {channel}.event.{event_type} + # Defaults: categories default to true, events default to template default_enabled. channel_overrides = {} for ch_type in CHANNEL_TYPES: ch_overrides = {'categories': {}, 'events': {}} for group_key in EVENT_GROUPS: - val = self._config.get(f'{ch_type}.events.{group_key}') - if val is not None: - ch_overrides['categories'][group_key] = val == 'true' - for event_type_key in TEMPLATES: - val = self._config.get(f'{ch_type}.event.{event_type_key}') - if val is not None: - ch_overrides['events'][event_type_key] = val == 'true' + saved = self._config.get(f'{ch_type}.events.{group_key}') + ch_overrides['categories'][group_key] = (saved or 'true') == 'true' + for event_type_key, tmpl in TEMPLATES.items(): + default = 'true' if tmpl.get('default_enabled', True) else 'false' + saved = self._config.get(f'{ch_type}.event.{event_type_key}') + ch_overrides['events'][event_type_key] = (saved or default) == 'true' channel_overrides[ch_type] = ch_overrides config = { 'enabled': self._enabled, 'channels': channels, - 'event_categories': event_categories, - 'event_toggles': event_toggles, + 'event_categories': {}, + 'event_toggles': {}, 'event_types_by_group': event_types_by_group, 'channel_overrides': channel_overrides, 'ai_enabled': self._config.get('ai_enabled', 'false') == 'true',