diff --git a/AppImage/scripts/flask_notification_routes.py b/AppImage/scripts/flask_notification_routes.py index 64ab97cb..c0a79c63 100644 --- a/AppImage/scripts/flask_notification_routes.py +++ b/AppImage/scripts/flask_notification_routes.py @@ -686,16 +686,6 @@ def proxmox_webhook(): else: return _reject(400, 'empty_payload', 400) - # DEBUG: Log full webhook payload to file for analysis - import json as _json - try: - with open('/tmp/proxmenux_webhook_payload.log', 'a') as _f: - _f.write(f"\n{'='*60}\n{time.strftime('%Y-%m-%d %H:%M:%S')}\n") - _f.write(_json.dumps(payload, indent=2, default=str, ensure_ascii=False)) - _f.write('\n') - except Exception: - pass - result = notification_manager.process_webhook(payload) # Always return 200 to PVE -- a non-200 makes PVE report the webhook as broken. # The 'accepted' field in the JSON body indicates actual processing status. diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index 04aa22e7..497d25f7 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -996,6 +996,7 @@ class ProxmoxHookWatcher: 'hostname': pve_hostname, 'pve_type': pve_type, 'pve_message': message, + 'pve_title': title, 'title': title, 'job_id': pve_job_id, } diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index 6b1c238c..5a5438ff 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -12,11 +12,129 @@ Author: MacRimi """ import json +import re import socket import time import urllib.request import urllib.error -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, List + + +# ─── vzdump message parser ─────────────────────────────────────── + +def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]: + """Parse a PVE vzdump notification message into structured data. + + PVE vzdump messages contain: + - A table: VMID Name Status Time Size Filename + - Totals: Total running time: Xs / Total size: X GiB + - Full logs per VM + + Returns dict with 'vms' list, 'total_time', 'total_size', or None. + """ + if not message: + return None + + vms: List[Dict[str, str]] = [] + total_time = '' + total_size = '' + + lines = message.split('\n') + + # Find the table header line + header_idx = -1 + for i, line in enumerate(lines): + if re.match(r'\s*VMID\s+Name\s+Status', line, re.IGNORECASE): + header_idx = i + break + + if header_idx >= 0: + # Parse column positions from header + header = lines[header_idx] + # Parse table rows after header + for line in lines[header_idx + 1:]: + stripped = line.strip() + if not stripped or stripped.startswith('Total') or stripped.startswith('Logs') or stripped.startswith('='): + break + # Table row: VMID Name Status Time Size Filename + # Use regex to parse flexible whitespace columns + m = re.match( + r'\s*(\d+)\s+' # VMID + r'(\S+)\s+' # Name + r'(\S+)\s+' # Status (ok/error) + r'(\S+)\s+' # Time + r'([\d.]+\s+\S+)\s+' # Size (e.g. "1.423 GiB") + r'(\S+)', # Filename + line + ) + if m: + vms.append({ + 'vmid': m.group(1), + 'name': m.group(2), + 'status': m.group(3), + 'time': m.group(4), + 'size': m.group(5), + 'filename': m.group(6).split('/')[-1], # just filename + }) + + # Extract totals + for line in lines: + m_time = re.search(r'Total running time:\s*(.+)', line) + if m_time: + total_time = m_time.group(1).strip() + m_size = re.search(r'Total size:\s*(.+)', line) + if m_size: + total_size = m_size.group(1).strip() + + if not vms and not total_size: + return None + + return { + 'vms': vms, + 'total_time': total_time, + 'total_size': total_size, + 'vm_count': len(vms), + } + + +def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool) -> str: + """Format parsed vzdump data into a clean Telegram-friendly message.""" + parts = [] + + for vm in parsed.get('vms', []): + status = vm.get('status', '').lower() + if status == 'ok': + icon = '\u2705' # green check + else: + icon = '\u274C' # red X + + vm_line = f"{icon} ID {vm['vmid']} ({vm['name']})" + parts.append(vm_line) + + if vm.get('size'): + parts.append(f" Size: {vm['size']}") + if vm.get('time'): + parts.append(f" Duration: {vm['time']}") + if vm.get('filename'): + parts.append(f" File: {vm['filename']}") + parts.append('') # blank line between VMs + + # Summary + vm_count = parsed.get('vm_count', 0) + if vm_count > 0 or parsed.get('total_size'): + parts.append('Summary:') + if vm_count: + ok_count = sum(1 for v in parsed.get('vms', []) if v.get('status', '').lower() == 'ok') + fail_count = vm_count - ok_count + parts.append(f" Total: {vm_count} backup(s)") + if fail_count: + parts.append(f" Failed: {fail_count}") + if parsed.get('total_size'): + parts.append(f" Total size: {parsed['total_size']}") + if parsed.get('total_time'): + parts.append(f" Total time: {parsed['total_time']}") + + return '\n'.join(parts) # ─── Severity Icons ────────────────────────────────────────────── @@ -475,10 +593,31 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]: except (KeyError, ValueError): title = template['title'] - try: - body_text = template['body'].format(**variables) - except (KeyError, ValueError): - body_text = template['body'] + # ── PVE vzdump special formatting ── + # When the event came from PVE webhook with a full vzdump message, + # parse the table/logs and format a rich body instead of the sparse template. + pve_message = data.get('pve_message', '') + pve_title = data.get('pve_title', '') + + if event_type in ('backup_complete', 'backup_fail') and pve_message: + parsed = _parse_vzdump_message(pve_message) + if parsed: + is_success = (event_type == 'backup_complete') + body_text = _format_vzdump_body(parsed, is_success) + # Use PVE's own title if available (contains hostname and status) + if pve_title: + title = pve_title + else: + # Couldn't parse -- use PVE raw message as body + body_text = pve_message.strip() + elif event_type == 'system_mail' and pve_message: + # System mail -- use PVE message directly (mail bounce, cron, smartd) + body_text = pve_message.strip()[:1000] + else: + try: + body_text = template['body'].format(**variables) + except (KeyError, ValueError): + body_text = template['body'] # Clean up: remove empty lines and consecutive duplicate lines cleaned_lines = []