From 67c61a5829374a42c3cabcc410c702c560efc058 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Sat, 21 Feb 2026 18:47:15 +0100 Subject: [PATCH] Update notification service --- AppImage/components/notification-settings.tsx | 14 +++++++ AppImage/scripts/flask_notification_routes.py | 2 +- AppImage/scripts/notification_events.py | 18 +++++++- AppImage/scripts/notification_manager.py | 42 ++++++++++++++++++- AppImage/scripts/notification_templates.py | 12 +++++- 5 files changed, 82 insertions(+), 6 deletions(-) diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index 5691a797..6d720da3 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -240,11 +240,25 @@ export function NotificationSettings() { flat[`events.${cat}`] = String(enabled) } // Flatten event_toggles: { vm_start: true, vm_stop: false } -> event.vm_start, event.vm_stop + // Always write ALL toggles to DB so the backend has an explicit record. + // This ensures default_enabled changes in templates don't get overridden by stale DB values. if (cfg.event_toggles) { for (const [evt, enabled] of Object.entries(cfg.event_toggles)) { flat[`event.${evt}`] = String(enabled) } } + // Also write any events NOT in event_toggles using their template defaults. + // This covers newly added templates whose default_enabled may be false. + 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) + } + } + } + } return flat } diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index fe30b3ae..8749424c 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -115,7 +115,7 @@ def get_notification_status(): def get_notification_history(): """Get notification history with optional filters.""" try: - limit = request.args.get('limit', 50, type=int) + limit = request.args.get('limit', 100, type=int) offset = request.args.get('offset', 0, type=int) severity = request.args.get('severity', '') channel = request.args.get('channel', '') diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index f256b14d..129127fe 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -962,9 +962,25 @@ class ProxmoxHookWatcher: severity = self._map_severity(severity_raw) + # Extract "reason" as extra detail, NOT the full body. + # Templates already have their own intro text (e.g. "{vmname} has failed."). + # The body from the webhook often starts with the same intro, so using the + # full body as {reason} causes duplication. We strip the first line (which + # is typically the title/summary) and keep only the extra detail lines. + reason = '' + if body: + body_lines = body.strip().split('\n') + # If more than 1 line, skip the first (summary) and use the rest + if len(body_lines) > 1: + reason = '\n'.join(body_lines[1:]).strip()[:500] + else: + # Single-line body: only use it as reason if it differs from title + if body.strip().lower() != (title or '').strip().lower(): + reason = body.strip()[:500] + data = { 'hostname': self._hostname, - 'reason': body[:500] if body else title, + 'reason': reason, 'title': title, 'source_component': source_component, 'notification_type': notification_type, diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index 11669a80..7cb689fe 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -311,6 +311,24 @@ class NotificationManager: except Exception as e: print(f"[NotificationManager] Failed to load config: {e}") + # Reconcile per-event toggles with current template defaults. + # If a template's default_enabled was changed (e.g. state_change False), + # but the DB has a stale 'true' from a previous default, fix it now. + # Only override if the user hasn't explicitly set it (we track this with + # a sentinel: if the value came from auto-save of defaults, it may be stale). + for event_type, tmpl in TEMPLATES.items(): + key = f'event.{event_type}' + if key in self._config: + db_val = self._config[key] == 'true' + tmpl_default = tmpl.get('default_enabled', True) + # If template says disabled but DB says enabled, AND there's no + # explicit user marker, enforce the template default. + if not tmpl_default and db_val: + # Check if user explicitly enabled it (look for a marker) + marker = f'event_explicit.{event_type}' + if marker not in self._config: + self._config[key] = 'false' + self._enabled = self._config.get('enabled', 'false') == 'true' self._rebuild_channels() @@ -533,6 +551,13 @@ class NotificationManager: if not self._group_limiter.allow(group): return + # Use the properly mapped severity from the event, not from template defaults. + # event.severity was set by _map_severity which normalises to CRITICAL/WARNING/INFO. + severity = event.severity + + # Inject the canonical severity into data so templates see it too. + event.data['severity'] = severity + # Render message from template (structured output) rendered = render_template(event.event_type, event.data) @@ -544,7 +569,7 @@ class NotificationManager: 'model': self._config.get('ai_model', ''), } body = format_with_ai( - rendered['title'], rendered['body'], rendered['severity'], ai_config + rendered['title'], rendered['body'], severity, ai_config ) # Enrich data with structured fields for channels that support them @@ -554,7 +579,7 @@ class NotificationManager: # Send through all active channels self._dispatch_to_channels( - rendered['title'], body, rendered['severity'], + rendered['title'], body, severity, event.event_type, enriched_data, event.source ) @@ -1048,6 +1073,19 @@ class NotificationManager: ''', (full_key, str(value), now)) self._config[short_key] = str(value) + + # If user is explicitly enabling an event that defaults to disabled, + # mark it so _load_config reconciliation won't override it later. + if short_key.startswith('event.') and str(value) == 'true': + event_type = short_key[6:] # strip 'event.' + tmpl = TEMPLATES.get(event_type, {}) + if not tmpl.get('default_enabled', True): + marker_key = f'{SETTINGS_PREFIX}event_explicit.{event_type}' + cursor.execute(''' + INSERT OR REPLACE INTO user_settings (setting_key, setting_value, updated_at) + VALUES (?, ?, ?) + ''', (marker_key, 'true', now)) + self._config[f'event_explicit.{event_type}'] = 'true' conn.commit() conn.close() diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index 1bef87ca..7786057d 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -472,8 +472,16 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]: except (KeyError, ValueError): body_text = template['body'] - # Clean up empty lines from missing optional variables - body_text = '\n'.join(line for line in body_text.split('\n') if line.strip()) + # Clean up: remove empty lines and consecutive duplicate lines + cleaned_lines = [] + for line in body_text.split('\n'): + stripped = line.strip() + if not stripped: + continue + if cleaned_lines and stripped == cleaned_lines[-1]: + continue # skip consecutive duplicate + cleaned_lines.append(stripped) + body_text = '\n'.join(cleaned_lines) severity = variables.get('severity', 'INFO') group = template.get('group', 'system')