Update notification service

This commit is contained in:
MacRimi
2026-02-24 18:20:43 +01:00
parent 05cd21d44e
commit f43feb825f
3 changed files with 145 additions and 15 deletions

View File

@@ -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.

View File

@@ -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,
}

View File

@@ -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 = []