-
{html_mod.escape(subject)}
- {body_html}
+
+
+
+
{html_mod.escape(display_title)}
-
-
Sent by ProxMenux Notification Service
+
+
+
+
+
+
+ |
+ Host: {html_mod.escape(data.get('hostname', ''))}
+ |
+
+ {html_mod.escape(ts)}
+ |
+
+
+
+
+
+
+ {reason_html}
+
+
+
+
+
+ | ProxMenux Notification Service |
+ proxmenux.com |
+
+
+
+
-'''
+
+'''
+
+ @staticmethod
+ def _build_detail_rows(data: Dict, event_type: str, group: str,
+ html_mod) -> list:
+ """Build structured (label, value) rows from event data.
+
+ Returns list of (label_html, value_html) tuples.
+ An empty label means a full-width descriptive row.
+ """
+ esc = html_mod.escape
+ rows = []
+
+ def _add(label: str, value, fmt: str = ''):
+ """Add a row if value is truthy."""
+ v = str(value).strip() if value else ''
+ if not v or v == '0' and label not in ('Failures',):
+ return
+ if fmt == 'severity':
+ sev_colors = {
+ 'CRITICAL': '#dc2626', 'WARNING': '#d97706',
+ 'INFO': '#2563eb', 'OK': '#16a34a',
+ }
+ c = sev_colors.get(v, '#6b7280')
+ rows.append((esc(label), f'
{esc(v)}'))
+ elif fmt == 'code':
+ rows.append((esc(label), f'
{esc(v)}'))
+ elif fmt == 'bold':
+ rows.append((esc(label), f'
{esc(v)}'))
+ else:
+ rows.append((esc(label), esc(v)))
+
+ # ── Common fields present in most events ──
+
+ # ── VM / CT events ──
+ if group == 'vm_ct':
+ _add('VM/CT ID', data.get('vmid'), 'code')
+ _add('Name', data.get('vmname'), 'bold')
+ _add('Action', event_type.replace('_', ' ').replace('vm ', 'VM ').replace('ct ', 'CT ').title())
+ _add('Target Node', data.get('target_node'))
+ _add('Reason', data.get('reason'))
+
+ # ── Backup events ──
+ elif group == 'backup':
+ _add('VM/CT ID', data.get('vmid'), 'code')
+ _add('Name', data.get('vmname'), 'bold')
+ _add('Status', 'Failed' if 'fail' in event_type else 'Completed' if 'complete' in event_type else 'Started',
+ 'severity' if 'fail' in event_type else '')
+ _add('Size', data.get('size'))
+ _add('Duration', data.get('duration'))
+ _add('Snapshot', data.get('snapshot_name'), 'code')
+ # For backup_complete/fail with parsed body, add short reason only
+ reason = data.get('reason', '')
+ if reason and len(reason) <= 80:
+ _add('Details', reason)
+
+ # ── Resources ──
+ elif group == 'resources':
+ _add('Metric', event_type.replace('_', ' ').title())
+ _add('Current Value', data.get('value'), 'bold')
+ _add('Threshold', data.get('threshold'))
+ _add('CPU Cores', data.get('cores'))
+ _add('Memory', f"{data.get('used', '')} / {data.get('total', '')}" if data.get('used') else '')
+ _add('Temperature', f"{data.get('value')}C" if 'temp' in event_type else '')
+
+ # ── Storage ──
+ elif group == 'storage':
+ if 'disk_space' in event_type:
+ _add('Mount Point', data.get('mount'), 'code')
+ _add('Usage', f"{data.get('used')}%", 'bold')
+ _add('Available', data.get('available'))
+ elif 'io_error' in event_type:
+ _add('Device', data.get('device'), 'code')
+ _add('Severity', data.get('severity', ''), 'severity')
+ elif 'unavailable' in event_type:
+ _add('Storage Name', data.get('storage_name'), 'bold')
+ _add('Type', data.get('storage_type'), 'code')
+ reason = data.get('reason', '')
+ if reason and len(reason) <= 80:
+ _add('Details', reason)
+
+ # ── Network ──
+ elif group == 'network':
+ _add('Interface', data.get('interface'), 'code')
+ _add('Latency', f"{data.get('value')}ms" if data.get('value') else '')
+ _add('Threshold', f"{data.get('threshold')}ms" if data.get('threshold') else '')
+ reason = data.get('reason', '')
+ if reason and len(reason) <= 80:
+ _add('Details', reason)
+
+ # ── Security ──
+ elif group == 'security':
+ _add('Event', event_type.replace('_', ' ').title())
+ _add('Source IP', data.get('source_ip'), 'code')
+ _add('Username', data.get('username'), 'code')
+ _add('Service', data.get('service'))
+ _add('Jail', data.get('jail'), 'code')
+ _add('Failures', data.get('failures'))
+ _add('Change', data.get('change_details'))
+
+ # ── Cluster ──
+ elif group == 'cluster':
+ _add('Event', event_type.replace('_', ' ').title())
+ _add('Node', data.get('node_name'), 'bold')
+ _add('Quorum', data.get('quorum'))
+ _add('Nodes Affected', data.get('entity_list'))
+
+ # ── Services ──
+ elif group == 'services':
+ _add('Service', data.get('service_name'), 'code')
+ _add('Process', data.get('process'), 'code')
+ _add('Event', event_type.replace('_', ' ').title())
+ reason = data.get('reason', '')
+ if reason and len(reason) <= 80:
+ _add('Details', reason)
+
+ # ── Health monitor ──
+ elif group == 'health':
+ _add('Category', data.get('category'), 'bold')
+ _add('Severity', data.get('severity', ''), 'severity')
+ if data.get('original_severity'):
+ _add('Previous Severity', data.get('original_severity'), 'severity')
+ _add('Duration', data.get('duration'))
+ _add('Active Issues', data.get('count'))
+ reason = data.get('reason', '')
+ if reason and len(reason) <= 80:
+ _add('Details', reason)
+
+ # ── Updates ──
+ elif group == 'updates':
+ _add('Total Updates', data.get('total_count'), 'bold')
+ _add('Security Updates', data.get('security_count'))
+ _add('Proxmox Updates', data.get('pve_count'))
+ _add('Kernel Updates', data.get('kernel_count'))
+ imp = data.get('important_list', '')
+ if imp:
+ _add('Important Packages', imp, 'code')
+ _add('Current Version', data.get('current_version'), 'code')
+ _add('New Version', data.get('new_version'), 'code')
+
+ # ── Other / unknown ──
+ else:
+ reason = data.get('reason', '')
+ if reason and len(reason) <= 80:
+ _add('Details', reason)
+
+ return rows
def test(self) -> Tuple[bool, str]:
+ import socket as _socket
+ hostname = _socket.gethostname().split('.')[0]
result = self.send(
'ProxMenux Test Notification',
'This is a test notification from ProxMenux Monitor.\n'
'If you received this, your email channel is working correctly.',
- 'INFO'
+ 'INFO',
+ data={
+ 'hostname': hostname,
+ '_event_type': 'webhook_test',
+ '_group': 'other',
+ 'reason': 'Email notification channel connectivity verified successfully. '
+ 'You will receive alerts from ProxMenux Monitor at this address.',
+ }
)
return result.get('success', False), result.get('error', '')
diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py
index d9de1a4b..6b8445d3 100644
--- a/AppImage/scripts/notification_manager.py
+++ b/AppImage/scripts/notification_manager.py
@@ -630,6 +630,8 @@ class NotificationManager:
enriched_data = dict(event.data)
enriched_data['_rendered_fields'] = rendered.get('fields', [])
enriched_data['_body_html'] = rendered.get('body_html', '')
+ enriched_data['_event_type'] = event.event_type
+ enriched_data['_group'] = TEMPLATES.get(event.event_type, {}).get('group', 'other')
# Send through all active channels
self._dispatch_to_channels(