From f134fcb52891f1f6f1486e8b050cc55e4feb6a90 Mon Sep 17 00:00:00 2001 From: MacRimi Date: Fri, 20 Feb 2026 17:55:05 +0100 Subject: [PATCH] Update notification service --- AppImage/components/notification-settings.tsx | 169 +++++++++++++++--- AppImage/scripts/flask_notification_routes.py | 4 +- AppImage/scripts/notification_events.py | 24 +++ AppImage/scripts/notification_manager.py | 57 +++++- AppImage/scripts/notification_templates.py | 8 +- 5 files changed, 222 insertions(+), 40 deletions(-) diff --git a/AppImage/components/notification-settings.tsx b/AppImage/components/notification-settings.tsx index c6ecf490..5691a797 100644 --- a/AppImage/components/notification-settings.tsx +++ b/AppImage/components/notification-settings.tsx @@ -6,13 +6,13 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "./ui/tabs" import { Input } from "./ui/input" import { Label } from "./ui/label" import { Badge } from "./ui/badge" -import { Checkbox } from "./ui/checkbox" + import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "./ui/select" import { fetchApi } from "../lib/api-config" import { Bell, BellOff, Send, CheckCircle2, XCircle, Loader2, AlertTriangle, Info, Settings2, Zap, Eye, EyeOff, - Trash2, ChevronDown, ChevronUp, TestTube2, Mail, Webhook, + Trash2, ChevronDown, ChevronUp, ChevronRight, TestTube2, Mail, Webhook, Copy, Server, Shield } from "lucide-react" @@ -34,11 +34,19 @@ interface ChannelConfig { subject_prefix?: string } +interface EventTypeInfo { + type: string + title: string + default_enabled: boolean +} + interface NotificationConfig { enabled: boolean channels: Record severity_filter: string event_categories: Record + event_toggles: Record + event_types_by_group: Record ai_enabled: boolean ai_provider: string ai_api_key: string @@ -101,11 +109,13 @@ const DEFAULT_CONFIG: NotificationConfig = { discord: { enabled: false }, email: { enabled: false }, }, - severity_filter: "warning", + severity_filter: "all", event_categories: { system: true, vm_ct: true, backup: true, resources: true, storage: true, network: true, security: true, cluster: true, }, + event_toggles: {}, + event_types_by_group: {}, ai_enabled: false, ai_provider: "openai", ai_api_key: "", @@ -132,6 +142,7 @@ export function NotificationSettings() { const [showSecrets, setShowSecrets] = useState>({}) const [editMode, setEditMode] = useState(false) const [hasChanges, setHasChanges] = useState(false) + const [expandedCategories, setExpandedCategories] = useState>(new Set()) const [originalConfig, setOriginalConfig] = useState(DEFAULT_CONFIG) const [webhookSetup, setWebhookSetup] = useState<{ status: "idle" | "running" | "success" | "failed" @@ -228,6 +239,12 @@ export function NotificationSettings() { for (const [cat, enabled] of Object.entries(cfg.event_categories)) { flat[`events.${cat}`] = String(enabled) } + // Flatten event_toggles: { vm_start: true, vm_stop: false } -> event.vm_start, event.vm_stop + if (cfg.event_toggles) { + for (const [evt, enabled] of Object.entries(cfg.event_toggles)) { + flat[`event.${evt}`] = String(enabled) + } + } return flat } @@ -1043,32 +1060,128 @@ matcher: proxmenux-pbs {/* Event Categories */}
-
- {EVENT_CATEGORIES.map(cat => ( -
{/* close bordered filters container */} diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index e0e1b32e..fe30b3ae 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -262,7 +262,7 @@ def setup_proxmox_webhook(): "", f"matcher: {_PVE_MATCHER_ID}", f"\ttarget {_PVE_ENDPOINT_ID}", - "\tmatch-severity warning,error", + "\tmode all", ] try: @@ -315,7 +315,7 @@ def setup_proxmox_webhook(): matcher_block = ( f"matcher: {_PVE_MATCHER_ID}\n" f"\ttarget {_PVE_ENDPOINT_ID}\n" - f"\tmatch-severity warning,error\n" + f"\tmode all\n" ) # ── Step 7: Append our blocks to cleaned main config ── diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index 9cd68595..6b92d2cb 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -835,6 +835,30 @@ class ProxmoxHookWatcher: body_lower = (body or '').lower() component_lower = (component or '').lower() + # VM / CT lifecycle events (if sent via webhook) + vmid = str(payload.get('vmid', '')) + if any(k in component_lower for k in ('qemu', 'lxc', 'vm', 'ct', 'container')): + if any(w in title_lower for w in ('start', 'running')): + etype = 'ct_start' if 'lxc' in component_lower or 'container' in component_lower else 'vm_start' + return etype, 'vm', vmid + if any(w in title_lower for w in ('stop', 'shutdown', 'down')): + etype = 'ct_stop' if 'lxc' in component_lower or 'container' in component_lower else 'vm_stop' + return etype, 'vm', vmid + if 'fail' in title_lower or 'crash' in title_lower or 'error' in body_lower: + return 'vm_fail', 'vm', vmid + if 'migrat' in title_lower: + return 'migration_complete', 'vm', vmid + return 'vm_start', 'vm', vmid + + # Also check title for VM keywords if component is not specific + if vmid or any(k in title_lower for k in ('vm ', 'ct ', 'qemu', 'lxc')): + if 'start' in title_lower: + return 'vm_start', 'vm', vmid + if any(w in title_lower for w in ('stop', 'shutdown')): + return 'vm_stop', 'vm', vmid + if 'fail' in title_lower or 'crash' in title_lower: + return 'vm_fail', 'vm', vmid + # Storage / SMART / ZFS / Ceph if any(k in component_lower for k in ('smart', 'disk', 'zfs', 'ceph')): entity_id = payload.get('device', payload.get('pool', '')) diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index 9920bf7a..b013cbdf 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -460,13 +460,28 @@ class NotificationManager: if not self._enabled: return - # Check if this event type is enabled in settings - event_setting = f'events.{event.event_type}' - if self._config.get(event_setting, 'true') == 'false': + # Check if this event's GROUP is enabled in settings. + # The UI saves categories by group key: events.vm_ct, events.backup, etc. + template = TEMPLATES.get(event.event_type, {}) + event_group = template.get('group', 'system') + group_setting = f'events.{event_group}' + if self._config.get(group_setting, 'true') == 'false': return - # Check severity filter - min_severity = self._config.get('filter.min_severity', 'INFO') + # Check if this SPECIFIC event type is enabled (granular per-event toggle). + # Key format: event.{event_type} = "true"/"false" + # 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 + + # Check severity filter. + # The UI saves severity_filter as: "all", "warning", "critical". + # Map to our internal severity names for comparison. + severity_map = {'all': 'INFO', 'warning': 'WARNING', 'critical': 'CRITICAL'} + raw_filter = self._config.get('severity_filter', 'all') + min_severity = severity_map.get(raw_filter.lower(), 'INFO') if not self._meets_severity(event.severity, min_severity): return @@ -484,8 +499,10 @@ class NotificationManager: if not self._enabled: return - # Check severity filter - min_severity = self._config.get('filter.min_severity', 'INFO') + # Check severity filter (same mapping as _process_event) + severity_map = {'all': 'INFO', 'warning': 'WARNING', 'critical': 'CRITICAL'} + raw_filter = self._config.get('severity_filter', 'all') + min_severity = severity_map.get(raw_filter.lower(), 'INFO') if not self._meets_severity(event.severity, min_severity): return @@ -955,17 +972,32 @@ class NotificationManager: ch_cfg[config_key] = self._config.get(f'{ch_type}.{config_key}', '') channels[ch_type] = ch_cfg - # Build event_categories dict + # Build event_categories dict (group-level toggle) # EVENT_GROUPS is a dict: { 'system': {...}, 'vm_ct': {...}, ... } 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() + config = { 'enabled': self._enabled, 'channels': channels, - 'severity_filter': self._config.get('severity_filter', 'warning'), + 'severity_filter': self._config.get('severity_filter', 'all'), 'event_categories': event_categories, + 'event_toggles': event_toggles, + 'event_types_by_group': event_types_by_group, 'ai_enabled': self._config.get('ai_enabled', 'false') == 'true', 'ai_provider': self._config.get('ai_provider', 'openai'), 'ai_api_key': self._config.get('ai_api_key', ''), @@ -1008,9 +1040,16 @@ class NotificationManager: conn.close() # Rebuild channels with new config + was_enabled = self._enabled self._enabled = self._config.get('enabled', 'false') == 'true' self._rebuild_channels() + # Start/stop service if enabled state changed + if self._enabled and not self._running: + self.start() + elif not self._enabled and self._running: + self.stop() + return {'success': True, 'channels_active': list(self._channels.keys())} except Exception as e: return {'success': False, 'error': str(e)} diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index d76850b7..eafa358f 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -470,9 +470,15 @@ def get_event_types_by_group() -> Dict[str, list]: group = template.get('group', 'system') if group not in result: result[group] = [] + import re + # Clean title: remove {hostname}: prefix and any remaining {placeholders} + title = template['title'].replace('{hostname}', '').strip(': ') + title = re.sub(r'\s*\{[^}]+\}', '', title).strip(' -:') + if not title: + title = event_type.replace('_', ' ').title() result[group].append({ 'type': event_type, - 'title': template['title'].replace('{hostname}', '').strip(': '), + 'title': title, 'default_enabled': template.get('default_enabled', True), }) return result