2026-02-27 23:49:26 +01:00
"""
ProxMenux Notification Templates
Message templates for all event types with per - channel formatting .
Templates use Python str . format ( ) variables :
{ hostname } , { severity } , { category } , { reason } , { summary } ,
{ previous } , { current } , { vmid } , { vmname } , { timestamp } , etc .
Optional AI enhancement enriches messages with context / suggestions .
Author : MacRimi
"""
import json
import re
import socket
import time
import urllib . request
import urllib . error
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.
Supports two formats :
1. Local storage : table with columns VMID Name Status Time Size Filename
2. PBS storage : log - style output with ' Finished Backup of VM NNN (HH:MM:SS) '
and sizes in lines like ' root.pxar: had to backup X of Y ' or ' transferred X '
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 ' )
# ── Strategy 1: classic table (local/NFS/CIFS storage) ──
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 :
# Use column positions from the header to slice each row.
# Header: "VMID Name Status Time Size Filename"
header = lines [ header_idx ]
col_starts = [ ]
for col_name in [ ' VMID ' , ' Name ' , ' Status ' , ' Time ' , ' Size ' , ' Filename ' ] :
idx = header . find ( col_name )
if idx > = 0 :
col_starts . append ( idx )
if len ( col_starts ) == 6 :
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
# Pad line to avoid index errors
padded = line . ljust ( col_starts [ - 1 ] + 50 )
vmid = padded [ col_starts [ 0 ] : col_starts [ 1 ] ] . strip ( )
name = padded [ col_starts [ 1 ] : col_starts [ 2 ] ] . strip ( )
status = padded [ col_starts [ 2 ] : col_starts [ 3 ] ] . strip ( )
time_val = padded [ col_starts [ 3 ] : col_starts [ 4 ] ] . strip ( )
size = padded [ col_starts [ 4 ] : col_starts [ 5 ] ] . strip ( )
filename = padded [ col_starts [ 5 ] : ] . strip ( )
if vmid and vmid . isdigit ( ) :
2026-03-02 23:21:40 +01:00
# Infer type from filename (vzdump-lxc-NNN or vzdump-qemu-NNN)
vm_type = ' '
if ' lxc ' in filename :
vm_type = ' lxc '
elif ' qemu ' in filename :
vm_type = ' qemu '
2026-02-27 23:49:26 +01:00
vms . append ( {
' vmid ' : vmid ,
' name ' : name ,
' status ' : status ,
' time ' : time_val ,
' size ' : size ,
' filename ' : filename ,
2026-03-02 23:21:40 +01:00
' type ' : vm_type ,
2026-02-27 23:49:26 +01:00
} )
# ── Strategy 2: log-style (PBS / Proxmox Backup Server) ──
# Parse from the full vzdump log lines.
# Look for patterns:
# "Starting Backup of VM NNN (lxc/qemu)" -> detect guest
# "CT Name: xxx" or "VM Name: xxx" -> guest name
# "Finished Backup of VM NNN (HH:MM:SS)" -> duration + status=ok
# "root.pxar: had to backup X of Y" -> size (CT)
# "transferred X in N seconds" -> size (QEMU)
# "creating ... archive 'ct/100/2026-..'" -> archive name for PBS
# "TASK ERROR:" or "ERROR:" -> status=error
if not vms :
current_vm : Optional [ Dict [ str , str ] ] = None
for line in lines :
# Remove "INFO: " prefix that PVE adds
clean = re . sub ( r ' ^(?:INFO|WARNING|ERROR): \ s* ' , ' ' , line . strip ( ) )
# Start of a new VM backup
m_start = re . match (
r ' Starting Backup of VM ( \ d+) \ s+ \ ((lxc|qemu) \ ) ' , clean )
if m_start :
if current_vm :
vms . append ( current_vm )
current_vm = {
' vmid ' : m_start . group ( 1 ) ,
' name ' : ' ' ,
' status ' : ' ok ' ,
' time ' : ' ' ,
' size ' : ' ' ,
' filename ' : ' ' ,
' type ' : m_start . group ( 2 ) ,
}
continue
if current_vm :
# Guest name
m_name = re . match ( r ' (?:CT|VM) Name: \ s*(.+) ' , clean )
if m_name :
current_vm [ ' name ' ] = m_name . group ( 1 ) . strip ( )
continue
# PBS archive path -> extract as filename
m_archive = re . search (
r " creating .+ archive ' ([^ ' ]+) ' " , clean )
if m_archive :
current_vm [ ' filename ' ] = m_archive . group ( 1 )
continue
# Size for containers (pxar)
m_pxar = re . search (
r ' root \ .pxar:.*?of \ s+([ \ d.]+ \ s+ \ S+) ' , clean )
if m_pxar :
current_vm [ ' size ' ] = m_pxar . group ( 1 )
continue
# Size for QEMU (transferred)
m_transfer = re . search (
r ' transferred \ s+([ \ d.]+ \ s+ \ S+) ' , clean )
if m_transfer :
current_vm [ ' size ' ] = m_transfer . group ( 1 )
continue
# Finished -> duration
m_finish = re . match (
r ' Finished Backup of VM ( \ d+) \ s+ \ (([^)]+) \ ) ' , clean )
if m_finish :
current_vm [ ' time ' ] = m_finish . group ( 2 )
current_vm [ ' status ' ] = ' ok '
vms . append ( current_vm )
current_vm = None
continue
# Error
if clean . startswith ( ' ERROR: ' ) or clean . startswith ( ' TASK ERROR ' ) :
if current_vm :
current_vm [ ' status ' ] = ' error '
# Don't forget the last VM if it wasn't finished
if current_vm :
vms . append ( current_vm )
# ── 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 ( )
# For PBS: calculate total size if not explicitly stated
if not total_size and vms :
# Sum individual sizes if they share units
sizes_gib = 0.0
for vm in vms :
s = vm . get ( ' size ' , ' ' )
m = re . match ( r ' ([ \ d.]+) \ s+(.*) ' , s )
if m :
val = float ( m . group ( 1 ) )
unit = m . group ( 2 ) . strip ( ) . upper ( )
if ' GIB ' in unit or ' GB ' in unit :
sizes_gib + = val
elif ' MIB ' in unit or ' MB ' in unit :
sizes_gib + = val / 1024
elif ' TIB ' in unit or ' TB ' in unit :
sizes_gib + = val * 1024
if sizes_gib > 0 :
if sizes_gib > = 1024 :
total_size = f " { sizes_gib / 1024 : .3f } TiB "
elif sizes_gib > = 1 :
total_size = f " { sizes_gib : .3f } GiB "
else :
total_size = f " { sizes_gib * 1024 : .3f } MiB "
# For PBS: calculate total time if not stated
if not total_time and vms :
total_secs = 0
for vm in vms :
t = vm . get ( ' time ' , ' ' )
# Parse HH:MM:SS format
m = re . match ( r ' ( \ d+):( \ d+):( \ d+) ' , t )
if m :
total_secs + = int ( m . group ( 1 ) ) * 3600 + int ( m . group ( 2 ) ) * 60 + int ( m . group ( 3 ) )
if total_secs > 0 :
hours = total_secs / / 3600
mins = ( total_secs % 3600 ) / / 60
secs = total_secs % 60
if hours :
total_time = f " { hours } h { mins } m { secs } s "
elif mins :
total_time = f " { mins } m { secs } s "
else :
total_time = f " { secs } s "
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 ( )
icon = ' \u2705 ' if status == ' ok ' else ' \u274C '
2026-03-02 23:21:40 +01:00
# Determine VM/CT type prefix
vm_type = vm . get ( ' type ' , ' ' )
if vm_type == ' lxc ' :
prefix = ' CT '
elif vm_type == ' qemu ' :
prefix = ' VM '
else :
# Try to infer from filename (vzdump-lxc-NNN or vzdump-qemu-NNN)
fname = vm . get ( ' filename ' , ' ' )
if ' lxc ' in fname or fname . startswith ( ' ct/ ' ) :
prefix = ' CT '
elif ' qemu ' in fname or fname . startswith ( ' vm/ ' ) :
prefix = ' VM '
else :
prefix = ' '
# Format: "VM Name (ID)" or "CT Name (ID)" -- name first
name = vm . get ( ' name ' , ' ' )
vmid = vm . get ( ' vmid ' , ' ' )
if prefix and name :
parts . append ( f " { icon } { prefix } { name } ( { vmid } ) " )
elif name :
parts . append ( f " { icon } { name } ( { vmid } ) " )
else :
parts . append ( f " { icon } ID { vmid } " )
2026-02-27 23:49:26 +01:00
2026-03-06 12:06:53 +01:00
# Size and Duration on same line with icons
2026-03-02 23:21:40 +01:00
detail_line = [ ]
2026-02-27 23:49:26 +01:00
if vm . get ( ' size ' ) :
2026-03-06 12:06:53 +01:00
detail_line . append ( f " \U0001F4CF Size: { vm [ ' size ' ] } " )
2026-02-27 23:49:26 +01:00
if vm . get ( ' time ' ) :
2026-03-06 12:06:53 +01:00
detail_line . append ( f " \u23F1 \uFE0F Duration: { vm [ ' time ' ] } " )
2026-03-02 23:21:40 +01:00
if detail_line :
parts . append ( ' | ' . join ( detail_line ) )
2026-03-06 12:06:53 +01:00
# PBS/File on separate line with icon
2026-02-27 23:49:26 +01:00
if vm . get ( ' filename ' ) :
fname = vm [ ' filename ' ]
if re . match ( r ' ^(?:ct|vm)/ \ d+/ ' , fname ) :
2026-03-06 12:06:53 +01:00
parts . append ( f " \U0001F5C4 \uFE0F PBS: { fname } " )
2026-02-27 23:49:26 +01:00
else :
2026-03-06 12:06:53 +01:00
parts . append ( f " \U0001F4C1 File: { fname } " )
# Error reason if failed
if status != ' ok ' and vm . get ( ' error ' ) :
parts . append ( f " \u26A0 \uFE0F { vm [ ' error ' ] } " )
2026-03-02 23:21:40 +01:00
2026-02-27 23:49:26 +01:00
parts . append ( ' ' ) # blank line between VMs
2026-03-06 12:06:53 +01:00
# Summary line with icons
2026-02-27 23:49:26 +01:00
vm_count = parsed . get ( ' vm_count ' , 0 )
if vm_count > 0 or parsed . get ( ' total_size ' ) :
ok_count = sum ( 1 for v in parsed . get ( ' vms ' , [ ] )
if v . get ( ' status ' , ' ' ) . lower ( ) == ' ok ' )
fail_count = vm_count - ok_count
summary_parts = [ ]
if vm_count :
2026-03-06 12:06:53 +01:00
summary_parts . append ( f " \U0001F4CA { vm_count } backups " )
2026-02-27 23:49:26 +01:00
if fail_count :
2026-03-06 12:06:53 +01:00
summary_parts . append ( f " \u274C { fail_count } failed " )
2026-02-27 23:49:26 +01:00
if parsed . get ( ' total_size ' ) :
2026-03-06 12:06:53 +01:00
summary_parts . append ( f " \U0001F4E6 Total: { parsed [ ' total_size ' ] } " )
2026-02-27 23:49:26 +01:00
if parsed . get ( ' total_time ' ) :
2026-03-06 12:06:53 +01:00
summary_parts . append ( f " \u23F1 \uFE0F Time: { parsed [ ' total_time ' ] } " )
2026-02-27 23:49:26 +01:00
if summary_parts :
2026-03-06 12:06:53 +01:00
parts . append ( ' | ' . join ( summary_parts ) )
2026-02-27 23:49:26 +01:00
return ' \n ' . join ( parts )
# ─── Severity Icons ──────────────────────────────────────────────
SEVERITY_ICONS = {
' CRITICAL ' : ' \U0001F534 ' ,
' WARNING ' : ' \U0001F7E1 ' ,
' INFO ' : ' \U0001F535 ' ,
' OK ' : ' \U0001F7E2 ' ,
' UNKNOWN ' : ' \u26AA ' ,
}
SEVERITY_ICONS_DISCORD = {
' CRITICAL ' : ' :red_circle: ' ,
' WARNING ' : ' :yellow_circle: ' ,
' INFO ' : ' :blue_circle: ' ,
' OK ' : ' :green_circle: ' ,
' UNKNOWN ' : ' :white_circle: ' ,
}
# ─── Event Templates ─────────────────────────────────────────────
# Each template has a 'title' and 'body' with {variable} placeholders.
# 'group' is used for UI event filter grouping.
# 'default_enabled' controls initial state in settings.
TEMPLATES = {
# ── Health Monitor state changes ──
# NOTE: state_change is disabled by default -- it fires on every
# status oscillation (OK->WARNING->OK) which creates noise.
# The health_persistent and new_error templates cover this better.
' state_change ' : {
' title ' : ' {hostname} : {category} changed to {current} ' ,
' body ' : ' {category} status changed from {previous} to {current} . \n {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Health state changed ' ,
' group ' : ' health ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : False ,
} ,
' new_error ' : {
' title ' : ' {hostname} : New {severity} - {category} ' ,
' body ' : ' {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' New health issue ' ,
' group ' : ' health ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
} ,
' error_resolved ' : {
' title ' : ' {hostname} : Resolved - {category} ' ,
2026-03-06 12:06:53 +01:00
' body ' : ' The {category} issue has been resolved. \n {reason} \n \U0001F6A6 Previous severity: {original_severity} \n \u23F1 \uFE0F Duration: {duration} ' ,
2026-03-03 18:48:54 +01:00
' label ' : ' Recovery notification ' ,
2026-03-03 13:40:46 +01:00
' group ' : ' health ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
} ,
' error_escalated ' : {
' title ' : ' {hostname} : Escalated to {severity} - {category} ' ,
' body ' : ' {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Health issue escalated ' ,
' group ' : ' health ' ,
' default_enabled ' : True ,
} ,
' health_degraded ' : {
' title ' : ' {hostname} : Health check degraded ' ,
' body ' : ' {reason} ' ,
' label ' : ' Health check degraded ' ,
' group ' : ' health ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
} ,
# ── VM / CT events ──
' vm_start ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : VM {vmname} ( {vmid} ) started ' ,
' body ' : ' Virtual machine {vmname} (ID: {vmid} ) is now running. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' VM started ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : True ,
} ,
' vm_stop ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : VM {vmname} ( {vmid} ) stopped ' ,
' body ' : ' Virtual machine {vmname} (ID: {vmid} ) has been stopped. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' VM stopped ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : False ,
} ,
' vm_shutdown ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : VM {vmname} ( {vmid} ) shut down ' ,
' body ' : ' Virtual machine {vmname} (ID: {vmid} ) has been cleanly shut down. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' VM shutdown ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : False ,
} ,
' vm_fail ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : VM {vmname} ( {vmid} ) FAILED ' ,
' body ' : ' Virtual machine {vmname} (ID: {vmid} ) has crashed or failed to start. \n Reason: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' VM FAILED ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : True ,
} ,
' vm_restart ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : VM {vmname} ( {vmid} ) restarted ' ,
' body ' : ' Virtual machine {vmname} (ID: {vmid} ) has been restarted. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' VM restarted ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : False ,
} ,
' ct_start ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : CT {vmname} ( {vmid} ) started ' ,
' body ' : ' Container {vmname} (ID: {vmid} ) is now running. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' CT started ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : True ,
} ,
' ct_stop ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : CT {vmname} ( {vmid} ) stopped ' ,
' body ' : ' Container {vmname} (ID: {vmid} ) has been stopped. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' CT stopped ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : False ,
} ,
' ct_shutdown ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : CT {vmname} ( {vmid} ) shut down ' ,
' body ' : ' Container {vmname} (ID: {vmid} ) has been cleanly shut down. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' CT shutdown ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : False ,
} ,
' ct_restart ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : CT {vmname} ( {vmid} ) restarted ' ,
' body ' : ' Container {vmname} (ID: {vmid} ) has been restarted. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' CT restarted ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : False ,
} ,
' ct_fail ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : CT {vmname} ( {vmid} ) FAILED ' ,
' body ' : ' Container {vmname} (ID: {vmid} ) has crashed or failed to start. \n Reason: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' CT FAILED ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : True ,
} ,
' migration_start ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Migration started — {vmname} ( {vmid} ) ' ,
' body ' : ' Live migration of {vmname} (ID: {vmid} ) to node {target_node} has started. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Migration started ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : True ,
} ,
' migration_complete ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Migration complete — {vmname} ( {vmid} ) ' ,
' body ' : ' {vmname} (ID: {vmid} ) successfully migrated to node {target_node} . ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Migration complete ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : True ,
} ,
' migration_fail ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Migration FAILED — {vmname} ( {vmid} ) ' ,
' body ' : ' Migration of {vmname} (ID: {vmid} ) to node {target_node} failed. \n Reason: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Migration FAILED ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : True ,
} ,
' replication_fail ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Replication FAILED — {vmname} ( {vmid} ) ' ,
' body ' : ' Replication of {vmname} (ID: {vmid} ) failed. \n Reason: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Replication FAILED ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : True ,
} ,
' replication_complete ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Replication complete — {vmname} ( {vmid} ) ' ,
' body ' : ' Replication of {vmname} (ID: {vmid} ) completed successfully. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Replication complete ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' vm_ct ' ,
' default_enabled ' : False ,
} ,
# ── Backup / Snapshot events ──
' backup_start ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Backup started on {storage} ' ,
' body ' : ' Backup job started on storage {storage} . \n {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Backup started ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' backup ' ,
' default_enabled ' : False ,
} ,
' backup_complete ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Backup complete — {vmname} ( {vmid} ) ' ,
' body ' : ' Backup of {vmname} (ID: {vmid} ) completed successfully. \n Size: {size} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Backup complete ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' backup ' ,
' default_enabled ' : True ,
} ,
' backup_fail ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Backup FAILED — {vmname} ( {vmid} ) ' ,
' body ' : ' Backup of {vmname} (ID: {vmid} ) failed. \n Reason: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Backup FAILED ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' backup ' ,
' default_enabled ' : True ,
} ,
' snapshot_complete ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Snapshot created — {vmname} ( {vmid} ) ' ,
' body ' : ' Snapshot " {snapshot_name} " created for {vmname} (ID: {vmid} ). ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Snapshot created ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' backup ' ,
' default_enabled ' : False ,
} ,
' snapshot_fail ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Snapshot FAILED — {vmname} ( {vmid} ) ' ,
' body ' : ' Snapshot creation for {vmname} (ID: {vmid} ) failed. \n Reason: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Snapshot FAILED ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' backup ' ,
' default_enabled ' : True ,
} ,
# ── Resource events (from Health Monitor) ──
' cpu_high ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : High CPU usage — {value} % ' ,
' body ' : ' CPU usage has reached {value} % o n {cores} cores. \n {details} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' High CPU usage ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' resources ' ,
' default_enabled ' : True ,
} ,
' ram_high ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : High memory usage — {value} % ' ,
2026-02-27 23:49:26 +01:00
' body ' : ' Memory usage: {used} / {total} ( {value} % ). \n {details} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' High memory usage ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' resources ' ,
' default_enabled ' : True ,
} ,
' temp_high ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : High CPU temperature — {value} °C ' ,
' body ' : ' CPU temperature has reached {value} °C (threshold: {threshold} °C). \n {details} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' High temperature ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' resources ' ,
' default_enabled ' : True ,
} ,
' disk_space_low ' : {
' title ' : ' {hostname} : Low disk space on {mount} ' ,
2026-03-17 19:43:26 +01:00
' body ' : ' Filesystem {mount} : {used} % u sed ( {available} available). \n Free up disk space to avoid service disruption. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Low disk space ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' storage ' ,
' default_enabled ' : True ,
} ,
' disk_io_error ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Disk failure detected — {device} ' ,
' body ' : ' I/O error or disk failure detected on device {device} . \n {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Disk failure / I/O error ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' storage ' ,
' default_enabled ' : True ,
} ,
' storage_unavailable ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Storage unavailable — {storage_name} ' ,
' body ' : ' PVE storage " {storage_name} " (type: {storage_type} ) is not accessible. \n Reason: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Storage unavailable ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' storage ' ,
' default_enabled ' : True ,
} ,
' load_high ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : High system load — {value} ' ,
' body ' : ' System load average is {value} on {cores} cores. \n {details} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' High system load ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' resources ' ,
' default_enabled ' : True ,
} ,
# ── Network events ──
' network_down ' : {
' title ' : ' {hostname} : Network connectivity lost ' ,
2026-03-17 19:43:26 +01:00
' body ' : ' The node has lost network connectivity. \n Reason: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Network connectivity lost ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' network ' ,
' default_enabled ' : True ,
} ,
' network_latency ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : High network latency — {value} ms ' ,
' body ' : ' Latency to gateway: {value} ms (threshold: {threshold} ms). \n This may indicate network congestion or hardware issues. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' High network latency ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' network ' ,
' default_enabled ' : False ,
} ,
# ── Security events ──
' auth_fail ' : {
' title ' : ' {hostname} : Authentication failure ' ,
2026-03-17 19:43:26 +01:00
' body ' : ' Failed login attempt detected. \n Source IP: {source_ip} \n User: {username} \n Service: {service} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Authentication failure ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' security ' ,
' default_enabled ' : True ,
} ,
' ip_block ' : {
' title ' : ' {hostname} : IP blocked by Fail2Ban ' ,
2026-03-17 19:43:26 +01:00
' body ' : ' IP address {source_ip} has been banned. \n Jail: {jail} \n Failed attempts: {failures} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' IP blocked by Fail2Ban ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' security ' ,
' default_enabled ' : True ,
} ,
' firewall_issue ' : {
' title ' : ' {hostname} : Firewall issue detected ' ,
2026-03-17 19:43:26 +01:00
' body ' : ' A firewall configuration issue has been detected. \n Reason: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Firewall issue detected ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' security ' ,
' default_enabled ' : True ,
} ,
' user_permission_change ' : {
' title ' : ' {hostname} : User permission changed ' ,
' body ' : ' User: {username} \n Change: {change_details} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' User permission changed ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' security ' ,
' default_enabled ' : True ,
} ,
# ── Cluster events ──
' split_brain ' : {
' title ' : ' {hostname} : SPLIT-BRAIN detected ' ,
2026-03-17 19:43:26 +01:00
' body ' : ' A cluster split-brain condition has been detected. Quorum may be lost. \n Quorum status: {quorum} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' SPLIT-BRAIN detected ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' cluster ' ,
' default_enabled ' : True ,
} ,
' node_disconnect ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Node {node_name} disconnected ' ,
2026-02-27 23:49:26 +01:00
' body ' : ' Node {node_name} has disconnected from the cluster. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Node disconnected ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' cluster ' ,
' default_enabled ' : True ,
} ,
' node_reconnect ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Node {node_name} reconnected ' ,
' body ' : ' Node {node_name} has rejoined the cluster successfully. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Node reconnected ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' cluster ' ,
' default_enabled ' : True ,
} ,
2026-03-03 13:40:46 +01:00
# ── Services events ──
2026-02-27 23:49:26 +01:00
' system_shutdown ' : {
' title ' : ' {hostname} : System shutting down ' ,
2026-03-17 19:43:26 +01:00
' body ' : ' The node is shutting down. \n {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' System shutting down ' ,
' group ' : ' services ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
} ,
' system_reboot ' : {
' title ' : ' {hostname} : System rebooting ' ,
2026-03-17 19:43:26 +01:00
' body ' : ' The node is rebooting. \n {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' System rebooting ' ,
' group ' : ' services ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
} ,
' system_problem ' : {
' title ' : ' {hostname} : System problem detected ' ,
2026-03-17 19:43:26 +01:00
' body ' : ' A system-level problem has been detected. \n Reason: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' System problem detected ' ,
' group ' : ' services ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
} ,
' service_fail ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : Service failed — {service_name} ' ,
' body ' : ' System service " {service_name} " has failed. \n Reason: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Service failed ' ,
' group ' : ' services ' ,
' default_enabled ' : True ,
} ,
' oom_kill ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : OOM Kill — {process} ' ,
' body ' : ' Process " {process} " was killed by the Out-of-Memory manager. \n {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Out of memory kill ' ,
' group ' : ' services ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
} ,
2026-03-03 13:40:46 +01:00
# ── Hidden internal templates (not shown in UI) ──
2026-03-02 23:21:40 +01:00
' service_fail_batch ' : {
' title ' : ' {hostname} : {service_count} services failed ' ,
' body ' : ' {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Service fail batch ' ,
' group ' : ' services ' ,
2026-03-02 23:21:40 +01:00
' default_enabled ' : True ,
2026-03-03 13:40:46 +01:00
' hidden ' : True ,
2026-03-02 23:21:40 +01:00
} ,
2026-03-02 17:16:22 +01:00
' system_mail ' : {
' title ' : ' {hostname} : {pve_title} ' ,
' body ' : ' {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' PVE system mail ' ,
' group ' : ' other ' ,
2026-03-02 17:16:22 +01:00
' default_enabled ' : True ,
2026-03-03 13:40:46 +01:00
' hidden ' : True ,
} ,
' webhook_test ' : {
' title ' : ' {hostname} : Webhook test received ' ,
' body ' : ' PVE webhook connectivity test successful. \n {reason} ' ,
' label ' : ' Webhook test ' ,
' group ' : ' other ' ,
' default_enabled ' : True ,
' hidden ' : True ,
2026-03-02 17:16:22 +01:00
} ,
2026-02-27 23:49:26 +01:00
' update_available ' : {
2026-03-01 18:44:11 +01:00
' title ' : ' {hostname} : Updates available ' ,
2026-03-03 19:45:54 +01:00
' body ' : ' Total updates: {total_count} \n Security: {security_count} \n Proxmox: {pve_count} \n Kernel: {kernel_count} \n Important packages: \n {important_list} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Updates available (legacy) ' ,
' group ' : ' updates ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : False ,
2026-03-03 13:40:46 +01:00
' hidden ' : True ,
2026-02-27 23:49:26 +01:00
} ,
' unknown_persistent ' : {
' title ' : ' {hostname} : Check unavailable - {category} ' ,
' body ' : ' Health check for {category} has been unavailable for 3+ cycles. \n {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Check unavailable ' ,
' group ' : ' health ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : False ,
2026-03-03 13:40:46 +01:00
' hidden ' : True ,
2026-02-27 23:49:26 +01:00
} ,
2026-03-03 13:40:46 +01:00
# ── Health Monitor events ──
2026-02-27 23:49:26 +01:00
' health_persistent ' : {
' title ' : ' {hostname} : {count} active health issue(s) ' ,
2026-03-17 19:43:26 +01:00
' body ' : ' The following health issues remain unresolved: \n {issue_list} \n \n This digest is sent once every 24 hours while issues persist. ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Active health issues (daily) ' ,
' group ' : ' health ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
} ,
' health_issue_new ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : New health issue — {category} ' ,
' body ' : ' New {severity} issue detected in: {category} \n Details: {reason} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' New health issue ' ,
' group ' : ' health ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
} ,
' health_issue_resolved ' : {
' title ' : ' {hostname} : Resolved - {category} ' ,
' body ' : ' {category} issue has been resolved. \n {reason} \n Duration: {duration} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Health issue resolved ' ,
' group ' : ' health ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
2026-03-03 18:48:54 +01:00
' hidden ' : True , # Use error_resolved instead (avoids duplicate in UI)
2026-02-27 23:49:26 +01:00
} ,
2026-03-03 13:40:46 +01:00
# ── Update notifications ──
2026-02-27 23:49:26 +01:00
' update_summary ' : {
2026-03-01 18:44:11 +01:00
' title ' : ' {hostname} : Updates available ' ,
' body ' : (
' Total updates: {total_count} \n '
' Security updates: {security_count} \n '
' Proxmox-related updates: {pve_count} \n '
' Kernel updates: {kernel_count} \n '
2026-03-03 19:45:54 +01:00
' Important packages: \n {important_list} '
2026-03-01 18:44:11 +01:00
) ,
2026-03-03 13:40:46 +01:00
' label ' : ' Updates available ' ,
' group ' : ' updates ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
} ,
' pve_update ' : {
2026-03-01 18:44:11 +01:00
' title ' : ' {hostname} : Proxmox VE {new_version} available ' ,
2026-03-18 19:32:38 +01:00
' body ' : ' A new Proxmox VE release is available. \n Current: {current_version} \n New: {new_version} \n {details} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Proxmox VE update available ' ,
' group ' : ' updates ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
} ,
2026-03-03 13:40:46 +01:00
' update_complete ' : {
2026-03-17 19:43:26 +01:00
' title ' : ' {hostname} : System update completed ' ,
' body ' : ' System packages have been successfully updated. \n {details} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Update completed ' ,
' group ' : ' updates ' ,
' default_enabled ' : False ,
2026-02-27 23:49:26 +01:00
} ,
2026-03-03 13:40:46 +01:00
# ── Burst aggregation summaries (hidden -- auto-generated by BurstAggregator) ──
# These inherit enabled state from their parent event type at dispatch time.
2026-02-27 23:49:26 +01:00
' burst_auth_fail ' : {
' title ' : ' {hostname} : {count} auth failures in {window} ' ,
' body ' : ' {count} authentication failures detected in {window} . \n Sources: {entity_list} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Auth failures burst ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' security ' ,
' default_enabled ' : True ,
2026-03-03 13:40:46 +01:00
' hidden ' : True ,
2026-02-27 23:49:26 +01:00
} ,
' burst_ip_block ' : {
' title ' : ' {hostname} : Fail2Ban banned {count} IPs in {window} ' ,
' body ' : ' {count} IPs banned by Fail2Ban in {window} . \n IPs: {entity_list} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' IP block burst ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' security ' ,
' default_enabled ' : True ,
2026-03-03 13:40:46 +01:00
' hidden ' : True ,
2026-02-27 23:49:26 +01:00
} ,
' burst_disk_io ' : {
' title ' : ' {hostname} : {count} disk I/O errors on {entity_list} ' ,
' body ' : ' {count} I/O errors detected in {window} . \n Devices: {entity_list} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Disk I/O burst ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' storage ' ,
' default_enabled ' : True ,
2026-03-03 13:40:46 +01:00
' hidden ' : True ,
2026-02-27 23:49:26 +01:00
} ,
' burst_cluster ' : {
' title ' : ' {hostname} : Cluster flapping detected ( {count} changes) ' ,
' body ' : ' Cluster state changed {count} times in {window} . \n Nodes: {entity_list} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Cluster flapping burst ' ,
2026-02-27 23:49:26 +01:00
' group ' : ' cluster ' ,
' default_enabled ' : True ,
2026-03-03 13:40:46 +01:00
' hidden ' : True ,
2026-02-27 23:49:26 +01:00
} ,
2026-03-02 23:21:40 +01:00
' burst_service_fail ' : {
' title ' : ' {hostname} : {count} services failed in {window} ' ,
' body ' : ' {count} service failures detected in {window} . \n This typically indicates a node reboot or PVE service restart. \n \n Additional failures: \n {details} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Service fail burst ' ,
' group ' : ' services ' ,
2026-03-02 23:21:40 +01:00
' default_enabled ' : True ,
2026-03-03 13:40:46 +01:00
' hidden ' : True ,
2026-03-02 23:21:40 +01:00
} ,
' burst_system ' : {
' title ' : ' {hostname} : {count} system problems in {window} ' ,
' body ' : ' {count} system problems detected in {window} . \n \n Additional issues: \n {details} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' System problems burst ' ,
' group ' : ' services ' ,
2026-03-02 23:21:40 +01:00
' default_enabled ' : True ,
2026-03-03 13:40:46 +01:00
' hidden ' : True ,
2026-03-02 23:21:40 +01:00
} ,
2026-02-27 23:49:26 +01:00
' burst_generic ' : {
' title ' : ' {hostname} : {count} {event_type} events in {window} ' ,
2026-03-02 23:21:40 +01:00
' body ' : ' {count} events of type {event_type} in {window} . \n \n Additional events: \n {details} ' ,
2026-03-03 13:40:46 +01:00
' label ' : ' Generic burst ' ,
' group ' : ' other ' ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : True ,
2026-03-03 13:40:46 +01:00
' hidden ' : True ,
2026-02-27 23:49:26 +01:00
} ,
}
# ─── Event Groups (for UI filtering) ─────────────────────────────
EVENT_GROUPS = {
2026-03-03 13:40:46 +01:00
' vm_ct ' : { ' label ' : ' VM / CT ' , ' description ' : ' Start, stop, crash, migration ' } ,
' backup ' : { ' label ' : ' Backups ' , ' description ' : ' Backup start, complete, fail ' } ,
' resources ' : { ' label ' : ' Resources ' , ' description ' : ' CPU, memory, temperature ' } ,
' storage ' : { ' label ' : ' Storage ' , ' description ' : ' Disk space, I/O, SMART ' } ,
' network ' : { ' label ' : ' Network ' , ' description ' : ' Connectivity, bond, latency ' } ,
' security ' : { ' label ' : ' Security ' , ' description ' : ' Auth failures, Fail2Ban, firewall ' } ,
' cluster ' : { ' label ' : ' Cluster ' , ' description ' : ' Quorum, split-brain, HA fencing ' } ,
' services ' : { ' label ' : ' Services ' , ' description ' : ' System services, shutdown, reboot ' } ,
' health ' : { ' label ' : ' Health Monitor ' , ' description ' : ' Health checks, degradation, recovery ' } ,
' updates ' : { ' label ' : ' Updates ' , ' description ' : ' System and PVE updates ' } ,
' other ' : { ' label ' : ' Other ' , ' description ' : ' Uncategorized notifications ' } ,
2026-02-27 23:49:26 +01:00
}
# ─── Template Renderer ───────────────────────────────────────────
def _get_hostname ( ) - > str :
""" Get short hostname for message titles. """
try :
return socket . gethostname ( ) . split ( ' . ' ) [ 0 ]
except Exception :
return ' proxmox '
def render_template ( event_type : str , data : Dict [ str , Any ] ) - > Dict [ str , Any ] :
""" Render a template into a structured notification object.
Returns structured output usable by all channels :
title , body ( text ) , body_text , body_html ( escaped ) , fields , tags , severity , group
"""
import html as html_mod
template = TEMPLATES . get ( event_type )
if not template :
2026-03-03 13:40:46 +01:00
# Catch-all: unknown event types always get delivered (group 'other')
# so no Proxmox notification is ever silently dropped.
2026-02-27 23:49:26 +01:00
fallback_body = data . get ( ' message ' , data . get ( ' reason ' , str ( data ) ) )
severity = data . get ( ' severity ' , ' INFO ' )
return {
' title ' : f " { _get_hostname ( ) } : { event_type } " ,
' body ' : fallback_body , ' body_text ' : fallback_body ,
' body_html ' : f ' <p> { html_mod . escape ( str ( fallback_body ) ) } </p> ' ,
2026-03-03 13:40:46 +01:00
' fields ' : [ ] , ' tags ' : [ severity , ' other ' , event_type ] ,
' severity ' : severity , ' group ' : ' other ' ,
2026-02-27 23:49:26 +01:00
}
# Ensure hostname is always available
variables = {
' hostname ' : _get_hostname ( ) ,
' timestamp ' : time . strftime ( ' % Y- % m- %d % H: % M: % S ' ) ,
' severity ' : data . get ( ' severity ' , ' INFO ' ) ,
# Burst event variables
' window ' : ' ' , ' entity_list ' : ' ' ,
# Common defaults
' vmid ' : ' ' , ' vmname ' : ' ' , ' reason ' : ' ' , ' summary ' : ' ' ,
' details ' : ' ' , ' category ' : ' ' , ' previous ' : ' ' , ' current ' : ' ' ,
' duration ' : ' ' , ' value ' : ' ' , ' threshold ' : ' ' ,
' source_ip ' : ' ' , ' username ' : ' ' , ' service ' : ' ' , ' service_name ' : ' ' ,
' node_name ' : ' ' , ' target_node ' : ' ' , ' mount ' : ' ' , ' device ' : ' ' ,
' used ' : ' ' , ' total ' : ' ' , ' available ' : ' ' , ' cores ' : ' ' ,
' count ' : ' ' , ' size ' : ' ' , ' snapshot_name ' : ' ' , ' jail ' : ' ' ,
' failures ' : ' ' , ' quorum ' : ' ' , ' change_details ' : ' ' , ' message ' : ' ' ,
' security_count ' : ' 0 ' , ' total_count ' : ' 0 ' , ' package_list ' : ' ' ,
' packages ' : ' ' , ' pve_packages ' : ' ' , ' version ' : ' ' ,
' issue_list ' : ' ' , ' error_key ' : ' ' ,
' storage_name ' : ' ' , ' storage_type ' : ' ' ,
2026-03-17 20:34:55 +01:00
' important_list ' : ' none ' ,
2026-02-27 23:49:26 +01:00
}
variables . update ( data )
2026-03-17 20:34:55 +01:00
# Ensure important_list is never blank (fallback to 'none')
if not variables . get ( ' important_list ' , ' ' ) . strip ( ) :
variables [ ' important_list ' ] = ' none '
2026-02-27 23:49:26 +01:00
try :
title = template [ ' title ' ] . format ( * * variables )
except ( KeyError , ValueError ) :
title = template [ ' title ' ]
# ── 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: collapse runs of 3+ blank lines into 1, remove trailing whitespace
import re as _re
body_text = _re . sub ( r ' \ n { 3,} ' , ' \n \n ' , body_text . strip ( ) )
severity = variables . get ( ' severity ' , ' INFO ' )
group = template . get ( ' group ' , ' system ' )
# Build structured fields for Discord embeds / rich notifications
fields = [ ]
field_map = [
( ' vmid ' , ' VM/CT ' ) , ( ' vmname ' , ' Name ' ) , ( ' device ' , ' Device ' ) ,
( ' source_ip ' , ' Source IP ' ) , ( ' node_name ' , ' Node ' ) , ( ' category ' , ' Category ' ) ,
( ' service_name ' , ' Service ' ) , ( ' jail ' , ' Jail ' ) , ( ' username ' , ' User ' ) ,
( ' count ' , ' Count ' ) , ( ' window ' , ' Window ' ) , ( ' entity_list ' , ' Affected ' ) ,
]
for key , label in field_map :
val = variables . get ( key , ' ' )
if val :
fields . append ( ( label , str ( val ) ) )
# Build HTML body with escaped content
body_html_parts = [ ]
for line in body_text . split ( ' \n ' ) :
if line . strip ( ) :
body_html_parts . append ( f ' <p> { html_mod . escape ( line ) } </p> ' )
body_html = ' \n ' . join ( body_html_parts ) if body_html_parts else f ' <p> { html_mod . escape ( body_text ) } </p> '
return {
' title ' : title ,
' body ' : body_text , # backward compat
' body_text ' : body_text ,
' body_html ' : body_html ,
' fields ' : fields ,
' tags ' : [ severity , group , event_type ] ,
' severity ' : severity ,
' group ' : group ,
}
def get_event_types_by_group ( ) - > Dict [ str , list ] :
""" Get all event types organized by group, for UI rendering.
2026-03-03 13:40:46 +01:00
Hidden templates ( burst aggregations , internal types ) are excluded
from the UI . They still work in the backend and inherit enabled
state from their parent event type .
2026-02-27 23:49:26 +01:00
Returns :
2026-03-03 13:40:46 +01:00
{ group_key : [ { ' type ' : event_type , ' title ' : label ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : bool } , . . . ] }
"""
result = { }
for event_type , template in TEMPLATES . items ( ) :
2026-03-03 13:40:46 +01:00
# Skip hidden templates (bursts, internal, deprecated)
if template . get ( ' hidden ' , False ) :
continue
group = template . get ( ' group ' , ' other ' )
2026-02-27 23:49:26 +01:00
if group not in result :
result [ group ] = [ ]
2026-03-03 13:40:46 +01:00
# Use explicit label if available, otherwise derive from title
label = template . get ( ' label ' , ' ' )
if not label :
import re
label = template [ ' title ' ] . replace ( ' {hostname} ' , ' ' ) . strip ( ' : ' )
label = re . sub ( r ' \ s* \ { [^}]+ \ } ' , ' ' , label ) . strip ( ' -: ' )
if not label :
label = event_type . replace ( ' _ ' , ' ' ) . title ( )
2026-02-27 23:49:26 +01:00
result [ group ] . append ( {
' type ' : event_type ,
2026-03-03 13:40:46 +01:00
' title ' : label ,
2026-02-27 23:49:26 +01:00
' default_enabled ' : template . get ( ' default_enabled ' , True ) ,
} )
return result
def get_default_enabled_events ( ) - > Dict [ str , bool ] :
""" Get the default enabled state for all event types. """
return {
event_type : template . get ( ' default_enabled ' , True )
for event_type , template in TEMPLATES . items ( )
}
2026-03-03 19:19:56 +01:00
# ─── Emoji Enrichment (per-channel opt-in) ──────────────────────
# Category-level header icons
CATEGORY_EMOJI = {
' vm_ct ' : ' \U0001F5A5 \uFE0F ' , # desktop computer
' backup ' : ' \U0001F4BE ' , # floppy disk (backup)
' resources ' : ' \U0001F4CA ' , # bar chart
' storage ' : ' \U0001F4BD ' , # minidisc / hard disk
' network ' : ' \U0001F310 ' , # globe with meridians
' security ' : ' \U0001F6E1 \uFE0F ' , # shield
' cluster ' : ' \U0001F517 ' , # chain link
' services ' : ' \u2699 \uFE0F ' , # gear
' health ' : ' \U0001FA7A ' , # stethoscope
' updates ' : ' \U0001F504 ' , # counterclockwise arrows (update)
' other ' : ' \U0001F4E8 ' , # incoming envelope
}
# Event-specific title icons (override category default when present)
EVENT_EMOJI = {
# VM / CT
' vm_start ' : ' \u25B6 \uFE0F ' , # play button
' vm_stop ' : ' \u23F9 \uFE0F ' , # stop button
' vm_shutdown ' : ' \u23CF \uFE0F ' , # eject
' vm_fail ' : ' \U0001F4A5 ' , # collision (crash)
' vm_restart ' : ' \U0001F504 ' , # cycle
' ct_start ' : ' \u25B6 \uFE0F ' ,
' ct_stop ' : ' \u23F9 \uFE0F ' ,
' ct_shutdown ' : ' \u23CF \uFE0F ' ,
' ct_restart ' : ' \U0001F504 ' ,
' ct_fail ' : ' \U0001F4A5 ' ,
' migration_start ' : ' \U0001F69A ' , # moving truck
' migration_complete ' : ' \u2705 ' , # check mark
' migration_fail ' : ' \u274C ' , # cross mark
' replication_fail ' : ' \u274C ' ,
' replication_complete ' : ' \u2705 ' ,
# Backups
2026-03-18 09:36:05 +01:00
' backup_start ' : ' \U0001F4BE \U0001F680 ' , # 💾🚀 floppy + rocket
' backup_complete ' : ' \U0001F4BE \u2705 ' , # 💾✅ floppy + check
' backup_fail ' : ' \U0001F4BE \u274C ' , # 💾❌ floppy + cross
2026-03-03 19:19:56 +01:00
' snapshot_complete ' : ' \U0001F4F8 ' , # camera with flash
' snapshot_fail ' : ' \u274C ' ,
# Resources
' cpu_high ' : ' \U0001F525 ' , # fire
' ram_high ' : ' \U0001F4A7 ' , # droplet
' temp_high ' : ' \U0001F321 \uFE0F ' , # thermometer
' load_high ' : ' \u26A0 \uFE0F ' , # warning
# Storage
' disk_space_low ' : ' \U0001F4C9 ' , # chart decreasing
' disk_io_error ' : ' \U0001F4A5 ' ,
' storage_unavailable ' : ' \U0001F6AB ' , # prohibited
# Network
' network_down ' : ' \U0001F50C ' , # electric plug
' network_latency ' : ' \U0001F422 ' , # turtle (slow)
# Security
' auth_fail ' : ' \U0001F6A8 ' , # police light
' ip_block ' : ' \U0001F6B7 ' , # no pedestrians (banned)
' firewall_issue ' : ' \U0001F525 ' ,
' user_permission_change ' : ' \U0001F511 ' , # key
# Cluster
' split_brain ' : ' \U0001F4A2 ' , # anger symbol
' node_disconnect ' : ' \U0001F50C ' ,
' node_reconnect ' : ' \u2705 ' ,
# Services
' system_shutdown ' : ' \u23FB \uFE0F ' , # power symbol (Unicode)
' system_reboot ' : ' \U0001F504 ' ,
' system_problem ' : ' \u26A0 \uFE0F ' ,
' service_fail ' : ' \u274C ' ,
' oom_kill ' : ' \U0001F4A3 ' , # bomb
# Health
' new_error ' : ' \U0001F198 ' , # SOS
' error_resolved ' : ' \u2705 ' ,
' error_escalated ' : ' \U0001F53A ' , # red triangle up
' health_degraded ' : ' \u26A0 \uFE0F ' ,
' health_persistent ' : ' \U0001F4CB ' , # clipboard
# Updates
' update_summary ' : ' \U0001F4E6 ' ,
' pve_update ' : ' \U0001F195 ' , # NEW
' update_complete ' : ' \u2705 ' ,
}
# Decorative field-level icons for body text enrichment
FIELD_EMOJI = {
' hostname ' : ' \U0001F4BB ' , # laptop
' vmid ' : ' \U0001F194 ' , # ID button
' vmname ' : ' \U0001F3F7 \uFE0F ' , # label
' device ' : ' \U0001F4BD ' , # disk
' mount ' : ' \U0001F4C2 ' , # open folder
' source_ip ' : ' \U0001F310 ' , # globe
' username ' : ' \U0001F464 ' , # bust in silhouette
' service_name ' : ' \u2699 \uFE0F ' , # gear
' node_name ' : ' \U0001F5A5 \uFE0F ' , # computer
' target_node ' : ' \U0001F3AF ' , # direct hit (target)
' category ' : ' \U0001F4CC ' , # pushpin
' severity ' : ' \U0001F6A6 ' , # traffic light
' duration ' : ' \u23F1 \uFE0F ' , # stopwatch
' timestamp ' : ' \U0001F552 ' , # clock three
' size ' : ' \U0001F4CF ' , # ruler
' reason ' : ' \U0001F4DD ' , # memo
' value ' : ' \U0001F4CA ' , # chart
' threshold ' : ' \U0001F6A7 ' , # construction
' jail ' : ' \U0001F512 ' , # lock
' failures ' : ' \U0001F522 ' , # input numbers
' quorum ' : ' \U0001F465 ' , # busts in silhouette
' total_count ' : ' \U0001F4E6 ' , # package
' security_count ' : ' \U0001F6E1 \uFE0F ' , # shield
' pve_count ' : ' \U0001F4E6 ' ,
' kernel_count ' : ' \u2699 \uFE0F ' ,
' important_list ' : ' \U0001F4CB ' , # clipboard
}
def enrich_with_emojis ( event_type : str , title : str , body : str ,
data : Dict [ str , Any ] ) - > tuple :
""" Replace the plain title/body with emoji-enriched versions.
Returns ( enriched_title , enriched_body ) .
The function is idempotent : if the title already starts with an emoji ,
it is returned unchanged .
"""
# Pick the best title icon: event-specific > category > severity circle
template = TEMPLATES . get ( event_type , { } )
group = template . get ( ' group ' , ' other ' )
severity = data . get ( ' severity ' , ' INFO ' )
icon = EVENT_EMOJI . get ( event_type ) or CATEGORY_EMOJI . get ( group ) or SEVERITY_ICONS . get ( severity , ' ' )
# Build enriched title: replace severity circle with event-specific icon
# Current format: "hostname: Something" -> "ICON hostname: Something"
# If title already starts with an emoji (from a previous pass), skip.
enriched_title = title
if icon and not any ( title . startswith ( e ) for e in SEVERITY_ICONS . values ( ) ) :
enriched_title = f ' { icon } { title } '
elif icon :
# Replace existing severity circle with richer icon
for sev_icon in SEVERITY_ICONS . values ( ) :
if title . startswith ( sev_icon ) :
enriched_title = title . replace ( sev_icon , icon , 1 )
break
# Build enriched body: prepend field emojis to recognizable lines
lines = body . split ( ' \n ' )
enriched_lines = [ ]
for line in lines :
stripped = line . strip ( )
if not stripped :
enriched_lines . append ( line )
continue
# Try to match "FieldName: value" patterns
enriched = False
for field_key , field_icon in FIELD_EMOJI . items ( ) :
# Match common label patterns: "Device:", "Duration:", "Size:", etc.
label_variants = [
field_key . replace ( ' _ ' , ' ' ) . title ( ) , # "Source Ip" -> not great
field_key . replace ( ' _ ' , ' ' ) , # "source ip"
]
# Also add specific known labels
_LABEL_MAP = {
' vmid ' : ' VM/CT ' , ' vmname ' : ' Name ' , ' source_ip ' : ' Source IP ' ,
' service_name ' : ' Service ' , ' node_name ' : ' Node ' ,
' target_node ' : ' Target ' , ' total_count ' : ' Total updates ' ,
' security_count ' : ' Security updates ' , ' pve_count ' : ' Proxmox-related updates ' ,
' kernel_count ' : ' Kernel updates ' , ' important_list ' : ' Important packages ' ,
' duration ' : ' Duration ' , ' severity ' : ' Previous severity ' ,
' original_severity ' : ' Previous severity ' ,
}
if field_key in _LABEL_MAP :
label_variants . append ( _LABEL_MAP [ field_key ] )
for label in label_variants :
if stripped . lower ( ) . startswith ( label . lower ( ) + ' : ' ) :
enriched_lines . append ( f ' { field_icon } { stripped } ' )
enriched = True
break
elif stripped . lower ( ) . startswith ( label . lower ( ) + ' ' ) :
enriched_lines . append ( f ' { field_icon } { stripped } ' )
enriched = True
break
if enriched :
break
if not enriched :
enriched_lines . append ( line )
enriched_body = ' \n ' . join ( enriched_lines )
return enriched_title , enriched_body
2026-02-27 23:49:26 +01:00
# ─── AI Enhancement (Optional) ───────────────────────────────────
2026-03-17 14:07:47 +01:00
# Supported languages for AI translation
AI_LANGUAGES = {
' en ' : ' English ' ,
' es ' : ' Spanish ' ,
' fr ' : ' French ' ,
' de ' : ' German ' ,
' pt ' : ' Portuguese ' ,
' it ' : ' Italian ' ,
' ru ' : ' Russian ' ,
' sv ' : ' Swedish ' ,
' no ' : ' Norwegian ' ,
' ja ' : ' Japanese ' ,
' zh ' : ' Chinese ' ,
' nl ' : ' Dutch ' ,
}
# Token limits for different detail levels
2026-03-18 20:10:48 +01:00
# max_tokens is a LIMIT, not fixed consumption - you only pay for tokens actually generated
2026-03-17 14:07:47 +01:00
AI_DETAIL_TOKENS = {
2026-03-18 20:10:48 +01:00
' brief ' : 300 , # Short messages, 2-3 lines
' standard ' : 1000 , # Standard messages, sufficient for 15-20 VMs
' detailed ' : 2000 , # Complete technical reports with all details
2026-03-17 14:07:47 +01:00
}
2026-03-20 17:46:02 +01:00
# System prompt template - informative, no recommendations
AI_SYSTEM_PROMPT = """ You are a system notification formatter for ProxMenux Monitor, a Proxmox VE monitoring tool.
Your task is to translate and reformat incoming server alert messages into { language } .
═ ═ ═ ABSOLUTE RULES ═ ═ ═
1. Translate BOTH title and body to { language } . Every word , label , and unit must be in { language } .
2. NO markdown : no * * bold * * , no * italic * , no ` code ` , no headers ( #), no bullet lists (- or *)
3. Plain text only — the output is sent to chat apps and email which handle their own formatting
4. Tone : factual , concise , technical . No greetings , no closings , no apologies
5. DO NOT add recommendations , action items , or suggestions ( " you should… " , " consider… " )
6. Present ONLY the facts already in the input — do not invent or assume information
2026-03-20 18:37:31 +01:00
7. OUTPUT ONLY THE FINAL RESULT — never include both original and processed versions .
Do NOT append " Original message: " , " Original: " , " Source: " , or any before / after comparison .
Return ONLY the single , final formatted message in { language } .
2026-03-20 17:46:02 +01:00
8. PLAIN NARRATIVE LINES — if a line in the input is a complete sentence ( not a " Label: value "
pair ) , translate it as - is . Never prepend " Message: " , " Note: " , or any other label to a sentence .
9. Detail level to apply : { detail_level }
- brief → 2 - 3 lines , essential data only ( status + key metric )
- standard → short paragraph covering who / what / where and the key value
- detailed → full technical breakdown of all available fields
10. Keep the " hostname: " prefix in the title . Translate only the descriptive part .
Example : " pve01: Updates available " → " pve01: Actualizaciones disponibles "
2026-03-20 18:37:31 +01:00
11. EMPTY LIST VALUES — if a list field is empty , " none " , or " 0 " :
Always write the translated word for " none " on the line after the label , never leave it blank .
Example : 🗂 ️ Important packages : \\n • none
2026-03-20 17:46:02 +01:00
12. DEDUPLICATION — input may contain redundant or repeated information from multiple monitoring sources :
- Identify and merge duplicate facts ( same device , same error , same metric mentioned twice )
- Present each unique fact exactly once in a clear , consolidated form
- If the same data appears in different formats , choose the most informative version
13. PROXMOX CONTEXT — silently translate Proxmox technical references into plain language .
Never explain what the term means — just use the human - readable equivalent directly .
Service / process name mapping ( replace the raw name with the friendly form ) :
- " pve-container@XXXX.service " → " Container CT XXXX "
- " qemu-server@XXXX.service " → " Virtual Machine VM XXXX "
- " pvesr-XXXX " → " storage replication job for XXXX "
- " vzdump " → " backup process "
- " pveproxy " → " Proxmox web proxy "
- " pvedaemon " → " Proxmox daemon "
- " pvestatd " → " Proxmox statistics service "
- " pvescheduler " → " Proxmox task scheduler "
- " pve-cluster " → " Proxmox cluster service "
- " corosync " → " cluster communication service "
- " ceph-osd@N " → " Ceph storage disk N "
- " ceph-mon " → " Ceph monitor service "
systemd message patterns ( rewrite the whole phrase , not just the service name ) :
- " systemd[1]: pve-container@9000.service: Failed "
→ " Container CT 9000 service failed "
- " systemd[1]: qemu-server@100.service: Failed with result ' exit-code ' "
→ " Virtual Machine VM 100 failed to start "
- " systemd[1]: Started pve-container@9000.service "
→ " Container CT 9000 started "
ATA / SMART / kernel error patterns ( replace raw kernel log with plain description ) :
- " ata8.00: exception Emask 0x1 SAct 0x4ce0 SErr 0x40000 action 0x0 "
→ " ATA controller error on port 8 "
- " blk_update_request: I/O error, dev sdX, sector NNNN "
→ " I/O error on disk /dev/sdX at sector NNNN "
- " SCSI error: return code = 0x08000002 "
→ " SCSI communication error "
Apply these mappings everywhere : in the body narrative , in field values , and when
the raw technical string appears inside a longer sentence .
{ emoji_instructions }
2026-03-20 18:37:31 +01:00
═ ═ ═ MESSAGE TYPES — FORMAT RULES ═ ═ ═
2026-03-20 17:46:02 +01:00
BACKUP ( backup_complete / backup_fail / backup_start ) :
Input contains : VM / CT names , IDs , size , duration , storage location , status per VM
Output body : first line is plain text ( no emoji ) describing the event briefly .
Then list each VM / CT with its fields . End with a summary line .
PARTIAL FAILURE RULE : if some VMs succeeded and at least one failed , use a combined title
like " Backup partially failed " / " Copia de seguridad parcialmente fallida " — never say
" backup failed " when there are also successful VMs in the same job .
NEVER omit the storage / archive line or the summary line — always include them even for long jobs .
2026-03-20 18:07:54 +01:00
2026-03-20 18:37:31 +01:00
UPDATES ( update_summary ) :
- Each count on its own line with its label .
- Package list uses " • " ( bullet + space ) per package , NOT the 🗂 ️ emoji on each line .
- The 🗂 ️ emoji goes only on the " Important packages: " header line .
- NEVER add a redundant summary line repeating the total count .
PVE UPDATE ( pve_update ) :
- First line : plain sentence announcing the new version ( no emoji on this line ) .
- Blank line after intro .
- Current version : 🔹 prefix | New version : 🟢 prefix
- Blank line before packages block .
- Packages header : 🗂 ️ | Package lines : 📌 prefix with version arrow v { { old } } ➜ v { { new } }
2026-03-20 18:07:54 +01:00
2026-03-20 17:46:02 +01:00
DISK / SMART ERRORS ( disk_io_error / storage_unavailable ) :
Input contains : device name , error type , SMART values or I / O error codes
Output body : device , then the specific error or failing attribute
DEDUPLICATION : Input may contain repeated or similar information from multiple sources .
If you see the same device , error count , or technical details mentioned multiple times ,
consolidate them into a single , clear statement . Never repeat the same information twice .
2026-03-20 18:07:54 +01:00
2026-03-20 17:46:02 +01:00
RESOURCES ( cpu_high / ram_high / temp_high / load_high ) :
Input contains : current value , threshold , core count
Output : current value vs threshold , context if available
2026-03-20 18:07:54 +01:00
2026-03-20 17:46:02 +01:00
SECURITY ( auth_fail / ip_block ) :
Input contains : source IP , user , service , jail , failure count
Output : list each field on its own line
2026-03-20 18:07:54 +01:00
2026-03-20 18:37:31 +01:00
VM / CT LIFECYCLE ( vm_start , vm_stop , vm_shutdown , vm_fail , vm_restart ,
ct_start , ct_stop , ct_shutdown , ct_fail , ct_restart ,
migration_start , migration_complete , migration_fail ,
replication_complete , replication_fail ) :
- Line 1 : 🏷 ️ [ Type ] [ name ] ( ID : [ id ] )
where Type is " Virtual machine " for VMs or " Container " for CTs
- Line 2 : [ status emoji ] [ action sentence — no subject , no ID repeated ]
✔ ️ for success states ( started , stopped , shut down , restarted , migrated )
❌ for failure states
- Line 3 ( only on failure ) : blank line + 📝 Reason : [ reason ]
- Line 4 ( only on migration ) : 🎯 Target : [ target_node ]
2026-03-20 18:07:54 +01:00
2026-03-20 17:46:02 +01:00
CLUSTER ( split_brain / node_disconnect / node_reconnect ) :
Input : node name , quorum status
Output : state change + quorum value
HEALTH ( new_error / error_resolved / health_persistent / health_degraded ) :
Input : category , severity , duration , reason
Output : what changed , in which category , for how long ( if resolved )
2026-03-20 18:07:54 +01:00
═ ═ ═ OUTPUT FORMAT ( follow exactly — parsers rely on these markers ) ═ ═ ═
2026-03-17 19:22:12 +01:00
[ TITLE ]
2026-03-20 17:46:02 +01:00
translated title here
2026-03-17 19:22:12 +01:00
[ BODY ]
2026-03-20 17:46:02 +01:00
translated body here
2026-03-20 18:37:31 +01:00
CRITICAL :
- [ TITLE ] on its own line , title text on the very next line — no blank line between them
- [ BODY ] on its own line , body text starting on the very next line — no blank line between them
- Do NOT write " Title: " , " Body: " , or any label substituting the markers
- Do NOT include the literal words TITLE or BODY anywhere in the translated content """
2026-03-20 14:13:22 +01:00
2026-03-20 18:07:54 +01:00
# Emoji instructions injected into AI_SYSTEM_PROMPT for rich channels (Telegram, Discord, Pushover)
AI_EMOJI_INSTRUCTIONS = """
2026-03-20 18:37:31 +01:00
═ ═ ═ EMOJI RULES ═ ═ ═
Place ONE emoji at the START of every non - empty line ( title and each body line ) .
Never skip a line . Never put the emoji at the end .
A blank line must be completely empty — no emoji , no spaces .
TITLE emoji — one per event type :
✅ success / resolved / complete / reconnected
❌ failed / FAILED / error
💥 crash / I / O error / hardware fault
🆘 new critical health issue
📦 backup started / updates available ( update_summary )
🆕 new PVE version available ( pve_update )
🔺 escalated / severity increased
📋 health digest / persistent issues
🚚 migration started
🔌 network down / node disconnected
🚨 auth failure / security alert
🚷 IP banned / blocked
🔑 permission change
💢 split - brain
💣 OOM kill
🚀 VM or CT started
⏹ ️ VM or CT stopped
🔽 VM or CT shutdown
🔄 restarted / reboot / proxmox updates
🔥 high CPU / firewall issue
💧 high memory
🌡 ️ high temperature
⚠ ️ warning / degraded / high load / system problem
📉 low disk space
🚫 storage unavailable
🐢 high latency
📸 snapshot created
⏻ system shutdown
BODY LINE emoji — one per line based on content :
🏷 ️ VM name / CT name / ID line ( first line of VM / CT lifecycle events )
✔ ️ status ok / success / action confirmed
❌ status error / failed
💽 size ( individual VM / CT backup )
💾 total backup size ( summary line only )
⏱ ️ duration
🗄 ️ storage location / PBS path
📦 total updates count
🔒 security updates / jail
🔄 proxmox updates
⚙ ️ kernel updates / service name
🗂 ️ important packages header
🌐 source IP
👤 user
📝 reason / details
🌡 ️ temperature
🔥 CPU usage
💧 memory usage
📊 summary line / statistics
👥 quorum / cluster nodes
💿 disk device
📂 filesystem / mount point
📌 category / package item ( pve_update )
🚦 severity
🖥 ️ node name
🎯 target node
🔹 current version ( pve_update )
🟢 new version ( pve_update )
2026-03-20 14:13:22 +01:00
BLANK LINES FOR READABILITY — insert ONE blank line between logical sections within the body .
Blank lines go BETWEEN groups , not before the first line or after the last line .
A blank line must be completely empty — no emoji , no spaces .
When to add a blank line :
- Updates : after the last count line , before the packages block
- Backup multi - VM : one blank line between each VM entry ; one blank line before the summary line
- Disk / SMART errors : after the device line , before the error description lines
- VM events with a reason : after the main status line , before Reason / Node / Target lines
- Health events : after the category / status line , before duration or detail lines
2026-03-20 19:03:04 +01:00
EXAMPLE — CT shutdown :
[ TITLE ]
🔽 amd : CT alpine ( 101 ) shut down
[ BODY ]
🏷 ️ Container alpine ( ID : 101 )
✔ ️ Cleanly shut down
EXAMPLE — VM started :
[ TITLE ]
🚀 pve01 : VM arch - linux ( 100 ) started
[ BODY ]
🏷 ️ Virtual machine arch - linux ( ID : 100 )
✔ ️ Now running
2026-03-20 14:13:22 +01:00
EXAMPLE — updates message ( no important packages ) :
[ TITLE ]
📦 amd : Updates available
[ BODY ]
📦 Total updates : 55
🔒 Security updates : 0
🔄 Proxmox updates : 0
⚙ ️ Kernel updates : 0
2026-03-20 18:45:35 +01:00
🗂 ️ Important packages : 0
2026-03-20 14:13:22 +01:00
EXAMPLE — updates message ( with important packages ) :
[ TITLE ]
📦 amd : Updates available
[ BODY ]
📦 Total updates : 90
🔒 Security updates : 6
🔄 Proxmox updates : 14
⚙ ️ Kernel updates : 1
🗂 ️ Important packages :
• pve - manager ( 9.1 .4 - > 9.1 .6 )
• qemu - server ( 9.1 .3 - > 9.1 .4 )
• pve - container ( 6.0 .18 - > 6.1 .2 )
EXAMPLE — pve_update ( new Proxmox VE version ) :
[ TITLE ]
🆕 pve01 : Proxmox VE 9.1 .6 available
[ BODY ]
🚀 A new Proxmox VE release is available .
🔹 Current : 9.1 .4
🟢 New : 9.1 .6
🗂 ️ Important packages :
📌 pve - manager ( v9 .1 .4 ➜ v9 .1 .6 )
EXAMPLE — backup complete with multiple VMs :
[ TITLE ]
💾 ✅ pve01 : Backup complete
[ BODY ]
Backup job finished on storage local - bak .
🏷 ️ VM web01 ( ID : 100 )
✔ ️ Status : ok
💽 Size : 12.3 GiB
⏱ ️ Duration : 00 : 04 : 21
🗄 ️ Storage : vm / 100 / 2026 - 03 - 17 T22 : 00 : 08 Z
🏷 ️ CT db ( ID : 101 )
✔ ️ Status : ok
💽 Size : 4.1 GiB
⏱ ️ Duration : 00 : 01 : 10
🗄 ️ Storage : ct / 101 / 2026 - 03 - 17 T22 : 04 : 29 Z
2026-03-20 16:26:30 +01:00
📊 Total : 2 backups | 💾 16.4 GiB | ⏱ ️ 00 : 05 : 31
2026-03-20 14:13:22 +01:00
EXAMPLE — backup partially failed ( some ok , some failed ) :
[ TITLE ]
💾 ❌ pve01 : Backup partially failed
[ BODY ]
Backup job finished with errors on storage PBS2 .
🏷 ️ VM web01 ( ID : 100 )
✔ ️ Status : ok
💽 Size : 12.3 GiB
⏱ ️ Duration : 00 : 04 : 21
🗄 ️ Storage : vm / 100 / 2026 - 03 - 17 T22 : 00 : 08 Z
🏷 ️ VM broken ( ID : 102 )
❌ Status : error
💽 Size : 0 B
⏱ ️ Duration : 00 : 00 : 37
2026-03-20 16:26:30 +01:00
📊 Total : 2 backups | ❌ 1 failed | 💾 12.3 GiB | ⏱ ️ 00 : 04 : 58
2026-03-20 14:13:22 +01:00
EXAMPLE — disk I / O health warning :
[ TITLE ]
💥 amd : Health warning — Disk I / O errors
[ BODY ]
💿 Device : / dev / sda
⚠ ️ 1 sector currently unreadable ( pending )
2026-03-20 19:03:04 +01:00
📝 Disk reports sectors in pending reallocation state """
2026-03-17 14:07:47 +01:00
2026-03-20 17:09:32 +01:00
2026-03-17 19:43:26 +01:00
# No emoji instructions for email/plain text channels
2026-03-17 14:07:47 +01:00
AI_NO_EMOJI_INSTRUCTIONS = """
2026-03-18 19:32:38 +01:00
DO NOT use any emojis or special Unicode symbols . Plain ASCII text only for email compatibility . """
2026-03-17 14:07:47 +01:00
2026-02-27 23:49:26 +01:00
class AIEnhancer :
2026-03-17 14:07:47 +01:00
""" AI message enhancement using pluggable providers.
2026-02-27 23:49:26 +01:00
2026-03-17 14:07:47 +01:00
Supports 6 providers : Groq , OpenAI , Anthropic , Gemini , Ollama , OpenRouter .
Translates and formats notifications based on configured language and detail level .
2026-02-27 23:49:26 +01:00
"""
2026-03-17 14:07:47 +01:00
def __init__ ( self , config : Dict [ str , Any ] ) :
""" Initialize AIEnhancer with configuration.
Args :
config : Dictionary containing :
- ai_provider : Provider name ( groq , openai , anthropic , gemini , ollama , openrouter )
- ai_api_key : API key ( not required for ollama )
- ai_model : Optional model override
- ai_language : Target language code ( en , es , fr , etc . )
- ai_ollama_url : URL for Ollama server ( optional )
"""
self . config = config
self . _provider = None
self . _init_provider ( )
2026-02-27 23:49:26 +01:00
2026-03-17 14:07:47 +01:00
def _init_provider ( self ) :
""" Initialize the AI provider based on configuration. """
try :
# Import here to avoid circular imports
import sys
import os
# Add script directory to path for ai_providers import
script_dir = os . path . dirname ( os . path . abspath ( __file__ ) )
if script_dir not in sys . path :
sys . path . insert ( 0 , script_dir )
from ai_providers import get_provider
provider_name = self . config . get ( ' ai_provider ' , ' groq ' )
2026-03-18 09:36:05 +01:00
# Determine base_url based on provider
if provider_name == ' ollama ' :
base_url = self . config . get ( ' ai_ollama_url ' , ' ' )
elif provider_name == ' openai ' :
base_url = self . config . get ( ' ai_openai_base_url ' , ' ' )
else :
base_url = ' '
2026-03-17 14:07:47 +01:00
self . _provider = get_provider (
provider_name ,
api_key = self . config . get ( ' ai_api_key ' , ' ' ) ,
model = self . config . get ( ' ai_model ' , ' ' ) ,
2026-03-18 09:36:05 +01:00
base_url = base_url ,
2026-03-17 14:07:47 +01:00
)
except Exception as e :
print ( f " [AIEnhancer] Failed to initialize provider: { e } " )
self . _provider = None
2026-02-27 23:49:26 +01:00
@property
def enabled ( self ) - > bool :
2026-03-17 14:07:47 +01:00
""" Check if AI enhancement is available. """
return self . _provider is not None
2026-02-27 23:49:26 +01:00
2026-03-17 14:07:47 +01:00
def enhance ( self , title : str , body : str , severity : str ,
detail_level : str = ' standard ' ,
journal_context : str = ' ' ,
2026-03-17 19:22:12 +01:00
use_emojis : bool = False ) - > Optional [ Dict [ str , str ] ] :
2026-03-17 14:07:47 +01:00
""" Enhance/translate notification with AI.
2026-02-27 23:49:26 +01:00
2026-03-17 14:07:47 +01:00
Args :
title : Notification title
body : Notification body text
severity : Severity level ( info , warning , critical )
detail_level : Level of detail ( brief , standard , detailed )
journal_context : Optional journal log lines for context
use_emojis : Whether to include emojis in the response ( for push channels )
Returns :
2026-03-17 19:22:12 +01:00
Dict with ' title ' and ' body ' keys , or None if failed
2026-02-27 23:49:26 +01:00
"""
2026-03-17 14:07:47 +01:00
if not self . _provider :
2026-02-27 23:49:26 +01:00
return None
2026-03-17 14:07:47 +01:00
# Get language settings
language_code = self . config . get ( ' ai_language ' , ' en ' )
language_name = AI_LANGUAGES . get ( language_code , ' English ' )
2026-02-27 23:49:26 +01:00
2026-03-17 14:07:47 +01:00
# Get token limit for detail level
max_tokens = AI_DETAIL_TOKENS . get ( detail_level , 200 )
2026-02-27 23:49:26 +01:00
2026-03-17 14:07:47 +01:00
# Select emoji instructions based on channel type
emoji_instructions = AI_EMOJI_INSTRUCTIONS if use_emojis else AI_NO_EMOJI_INSTRUCTIONS
2026-02-27 23:49:26 +01:00
2026-03-17 14:07:47 +01:00
# Build system prompt with emoji instructions
system_prompt = AI_SYSTEM_PROMPT . format (
language = language_name ,
detail_level = detail_level ,
emoji_instructions = emoji_instructions
)
2026-02-27 23:49:26 +01:00
2026-03-17 14:07:47 +01:00
# Build user message
user_msg = f " Severity: { severity } \n Title: { title } \n Message: \n { body } "
if journal_context :
user_msg + = f " \n \n Journal log context: \n { journal_context } "
try :
result = self . _provider . generate ( system_prompt , user_msg , max_tokens )
2026-03-20 11:26:26 +01:00
if result is None :
print ( f " [AIEnhancer] Provider returned None - possible timeout or connection issue " )
return None
2026-03-17 19:22:12 +01:00
return self . _parse_ai_response ( result , title , body )
2026-03-17 14:07:47 +01:00
except Exception as e :
print ( f " [AIEnhancer] Enhancement failed: { e } " )
return None
2026-03-17 19:22:12 +01:00
def _parse_ai_response ( self , response : str , original_title : str , original_body : str ) - > Dict [ str , str ] :
""" Parse AI response to extract title and body.
Args :
response : Raw AI response text
original_title : Original title as fallback
original_body : Original body as fallback
Returns :
Dict with ' title ' and ' body ' keys
"""
if not response :
return { ' title ' : original_title , ' body ' : original_body }
2026-03-20 13:55:47 +01:00
import re
2026-03-17 19:22:12 +01:00
2026-03-20 13:55:47 +01:00
# Try to parse [TITLE] and [BODY] markers (case-insensitive, multiline)
title_match = re . search ( r ' \ [TITLE \ ] \ s*(.*?) \ s* \ [BODY \ ] ' , response , re . DOTALL | re . IGNORECASE )
body_match = re . search ( r ' \ [BODY \ ] \ s*(.*) ' , response , re . DOTALL | re . IGNORECASE )
2026-03-17 19:22:12 +01:00
2026-03-20 13:55:47 +01:00
if title_match and body_match :
title_content = title_match . group ( 1 ) . strip ( )
body_content = body_match . group ( 1 ) . strip ( )
# Remove any "Original message/text" sections the AI might have added
# This cleanup is important because some models (especially Ollama) tend to
# include the original text alongside the translation
original_patterns = [
r ' \ n*- { 3,} \ n*Original message:.* ' ,
r ' \ n*- { 3,} \ n*Original:.* ' ,
r ' \ n*- { 3,} \ n*Source:.* ' ,
r ' \ n*- { 3,} \ n*Mensaje original:.* ' ,
r ' \ n*Original message:.* ' ,
r ' \ n*Original text:.* ' ,
r ' \ n*Mensaje original:.* ' ,
r ' \ n*Texto original:.* ' ,
]
for pattern in original_patterns :
body_content = re . sub ( pattern , ' ' , body_content , flags = re . DOTALL | re . IGNORECASE ) . strip ( )
2026-03-17 19:22:12 +01:00
return {
' title ' : title_content if title_content else original_title ,
' body ' : body_content if body_content else original_body
}
# Fallback: if markers not found, use whole response as body
return {
' title ' : original_title ,
' body ' : response . strip ( )
}
2026-03-17 14:07:47 +01:00
def test_connection ( self ) - > Dict [ str , Any ] :
""" Test the AI provider connection.
2026-02-27 23:49:26 +01:00
2026-03-17 14:07:47 +01:00
Returns :
Dict with success , message , and model info
"""
if not self . _provider :
return {
' success ' : False ,
' message ' : ' Provider not initialized ' ,
' model ' : ' '
}
return self . _provider . test_connection ( )
2026-02-27 23:49:26 +01:00
def format_with_ai ( title : str , body : str , severity : str ,
2026-03-17 14:07:47 +01:00
ai_config : Dict [ str , Any ] ,
detail_level : str = ' standard ' ,
journal_context : str = ' ' ,
use_emojis : bool = False ) - > str :
""" Format a message with AI enhancement/translation.
2026-02-27 23:49:26 +01:00
2026-03-17 14:07:47 +01:00
Replaces the message body with AI - processed version if successful .
Falls back to original body if AI is unavailable or fails .
2026-02-27 23:49:26 +01:00
Args :
title : Notification title
body : Notification body
severity : Severity level
2026-03-17 14:07:47 +01:00
ai_config : Configuration dictionary with AI settings
detail_level : Level of detail ( brief , standard , detailed )
journal_context : Optional journal log context
use_emojis : Whether to include emojis ( for push channels like Telegram / Discord )
2026-02-27 23:49:26 +01:00
Returns :
2026-03-17 14:07:47 +01:00
Enhanced body string or original if AI fails
2026-02-27 23:49:26 +01:00
"""
2026-03-17 19:22:12 +01:00
result = format_with_ai_full ( title , body , severity , ai_config , detail_level , journal_context , use_emojis )
return result . get ( ' body ' , body )
def format_with_ai_full ( title : str , body : str , severity : str ,
ai_config : Dict [ str , Any ] ,
detail_level : str = ' standard ' ,
journal_context : str = ' ' ,
use_emojis : bool = False ) - > Dict [ str , str ] :
""" Format a message with AI enhancement/translation, returning both title and body.
Args :
title : Notification title
body : Notification body
severity : Severity level
ai_config : Configuration dictionary with AI settings
detail_level : Level of detail ( brief , standard , detailed )
journal_context : Optional journal log context
use_emojis : Whether to include emojis ( for push channels like Telegram / Discord )
Returns :
Dict with ' title ' and ' body ' keys ( translated / enhanced )
"""
default_result = { ' title ' : title , ' body ' : body }
2026-03-17 14:07:47 +01:00
# Check if AI is enabled
ai_enabled = ai_config . get ( ' ai_enabled ' )
if isinstance ( ai_enabled , str ) :
ai_enabled = ai_enabled . lower ( ) == ' true '
if not ai_enabled :
2026-03-17 19:22:12 +01:00
return default_result
2026-03-17 14:07:47 +01:00
# Check for API key (not required for Ollama)
provider = ai_config . get ( ' ai_provider ' , ' groq ' )
if provider != ' ollama ' and not ai_config . get ( ' ai_api_key ' ) :
2026-03-17 19:22:12 +01:00
return default_result
2026-03-17 14:07:47 +01:00
# For Ollama, check URL is configured
if provider == ' ollama ' and not ai_config . get ( ' ai_ollama_url ' ) :
2026-03-17 19:22:12 +01:00
return default_result
2026-02-27 23:49:26 +01:00
2026-03-17 14:07:47 +01:00
# Create enhancer and process
enhancer = AIEnhancer ( ai_config )
enhanced = enhancer . enhance (
title , body , severity ,
detail_level = detail_level ,
journal_context = journal_context ,
use_emojis = use_emojis
2026-02-27 23:49:26 +01:00
)
2026-03-17 19:22:12 +01:00
# Return enhanced result if successful, otherwise original
if enhanced and isinstance ( enhanced , dict ) :
result_title = enhanced . get ( ' title ' , title )
result_body = enhanced . get ( ' body ' , body )
2026-03-20 11:26:26 +01:00
# For email channel with detailed level, append original message for reference
2026-03-17 14:07:47 +01:00
# This ensures full technical data is available even after AI processing
2026-03-20 11:26:26 +01:00
# Only for email - other channels (Telegram, Discord, Gotify) should not get duplicates
channel_type = ai_config . get ( ' channel_type ' , ' ' ) . lower ( )
is_email = channel_type == ' email '
if is_email and detail_level == ' detailed ' and body and len ( body ) > 50 :
2026-03-17 14:07:47 +01:00
# Only append if original has substantial content
2026-03-17 19:22:12 +01:00
result_body + = " \n \n " + " - " * 40 + " \n "
result_body + = " Original message: \n "
result_body + = body
return { ' title ' : result_title , ' body ' : result_body }
2026-02-27 23:49:26 +01:00
2026-03-17 19:22:12 +01:00
return default_result