mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-02-19 17:06:37 +00:00
Update notification service
This commit is contained in:
554
AppImage/scripts/notification_templates.py
Normal file
554
AppImage/scripts/notification_templates.py
Normal file
@@ -0,0 +1,554 @@
|
||||
"""
|
||||
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 socket
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from typing import Dict, Any, Optional
|
||||
|
||||
|
||||
# ─── 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 ──
|
||||
'state_change': {
|
||||
'title': '{hostname}: {category} changed to {current}',
|
||||
'body': '{category} status changed from {previous} to {current}.\n{reason}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'new_error': {
|
||||
'title': '{hostname}: New {severity} - {category}',
|
||||
'body': '{reason}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'error_resolved': {
|
||||
'title': '{hostname}: Resolved - {category}',
|
||||
'body': '{reason}\nDuration: {duration}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'error_escalated': {
|
||||
'title': '{hostname}: Escalated to {severity} - {category}',
|
||||
'body': '{reason}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
# ── VM / CT events ──
|
||||
'vm_start': {
|
||||
'title': '{hostname}: VM {vmid} started',
|
||||
'body': '{vmname} ({vmid}) has been started.',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'vm_stop': {
|
||||
'title': '{hostname}: VM {vmid} stopped',
|
||||
'body': '{vmname} ({vmid}) has been stopped.',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': False,
|
||||
},
|
||||
'vm_shutdown': {
|
||||
'title': '{hostname}: VM {vmid} shutdown',
|
||||
'body': '{vmname} ({vmid}) has been shut down.',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': False,
|
||||
},
|
||||
'vm_fail': {
|
||||
'title': '{hostname}: VM {vmid} FAILED',
|
||||
'body': '{vmname} ({vmid}) has failed.\n{reason}',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'vm_restart': {
|
||||
'title': '{hostname}: VM {vmid} restarted',
|
||||
'body': '{vmname} ({vmid}) has been restarted.',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': False,
|
||||
},
|
||||
'ct_start': {
|
||||
'title': '{hostname}: CT {vmid} started',
|
||||
'body': '{vmname} ({vmid}) has been started.',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'ct_stop': {
|
||||
'title': '{hostname}: CT {vmid} stopped',
|
||||
'body': '{vmname} ({vmid}) has been stopped.',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': False,
|
||||
},
|
||||
'ct_fail': {
|
||||
'title': '{hostname}: CT {vmid} FAILED',
|
||||
'body': '{vmname} ({vmid}) has failed.\n{reason}',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'migration_start': {
|
||||
'title': '{hostname}: Migration started - {vmid}',
|
||||
'body': '{vmname} ({vmid}) migration to {target_node} started.',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'migration_complete': {
|
||||
'title': '{hostname}: Migration complete - {vmid}',
|
||||
'body': '{vmname} ({vmid}) migrated successfully to {target_node}.',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'migration_fail': {
|
||||
'title': '{hostname}: Migration FAILED - {vmid}',
|
||||
'body': '{vmname} ({vmid}) migration to {target_node} failed.\n{reason}',
|
||||
'group': 'vm_ct',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
# ── Backup / Snapshot events ──
|
||||
'backup_start': {
|
||||
'title': '{hostname}: Backup started - {vmid}',
|
||||
'body': 'Backup of {vmname} ({vmid}) has started.',
|
||||
'group': 'backup',
|
||||
'default_enabled': False,
|
||||
},
|
||||
'backup_complete': {
|
||||
'title': '{hostname}: Backup complete - {vmid}',
|
||||
'body': 'Backup of {vmname} ({vmid}) completed successfully.\nSize: {size}',
|
||||
'group': 'backup',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'backup_fail': {
|
||||
'title': '{hostname}: Backup FAILED - {vmid}',
|
||||
'body': 'Backup of {vmname} ({vmid}) has failed.\n{reason}',
|
||||
'group': 'backup',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'snapshot_complete': {
|
||||
'title': '{hostname}: Snapshot created - {vmid}',
|
||||
'body': 'Snapshot of {vmname} ({vmid}) created: {snapshot_name}',
|
||||
'group': 'backup',
|
||||
'default_enabled': False,
|
||||
},
|
||||
'snapshot_fail': {
|
||||
'title': '{hostname}: Snapshot FAILED - {vmid}',
|
||||
'body': 'Snapshot of {vmname} ({vmid}) failed.\n{reason}',
|
||||
'group': 'backup',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
# ── Resource events (from Health Monitor) ──
|
||||
'cpu_high': {
|
||||
'title': '{hostname}: High CPU usage ({value}%)',
|
||||
'body': 'CPU usage is at {value}% on {cores} cores.\n{details}',
|
||||
'group': 'resources',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'ram_high': {
|
||||
'title': '{hostname}: High memory usage ({value}%)',
|
||||
'body': 'Memory usage: {used} / {total} ({value}%).\n{details}',
|
||||
'group': 'resources',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'temp_high': {
|
||||
'title': '{hostname}: High temperature ({value}C)',
|
||||
'body': 'CPU temperature: {value}C (threshold: {threshold}C).\n{details}',
|
||||
'group': 'resources',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'disk_space_low': {
|
||||
'title': '{hostname}: Low disk space on {mount}',
|
||||
'body': '{mount}: {used}% used ({available} available).',
|
||||
'group': 'storage',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'disk_io_error': {
|
||||
'title': '{hostname}: Disk I/O error',
|
||||
'body': 'I/O error detected on {device}.\n{reason}',
|
||||
'group': 'storage',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'load_high': {
|
||||
'title': '{hostname}: High system load ({value})',
|
||||
'body': 'System load average: {value} on {cores} cores.\n{details}',
|
||||
'group': 'resources',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
# ── Network events ──
|
||||
'network_down': {
|
||||
'title': '{hostname}: Network connectivity lost',
|
||||
'body': 'Network connectivity check failed.\n{reason}',
|
||||
'group': 'network',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'network_latency': {
|
||||
'title': '{hostname}: High network latency ({value}ms)',
|
||||
'body': 'Latency to gateway: {value}ms (threshold: {threshold}ms).',
|
||||
'group': 'network',
|
||||
'default_enabled': False,
|
||||
},
|
||||
|
||||
# ── Security events ──
|
||||
'auth_fail': {
|
||||
'title': '{hostname}: Authentication failure',
|
||||
'body': 'Failed login attempt from {source_ip}.\nUser: {username}\nService: {service}',
|
||||
'group': 'security',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'ip_block': {
|
||||
'title': '{hostname}: IP blocked by Fail2Ban',
|
||||
'body': 'IP {source_ip} has been banned.\nJail: {jail}\nFailures: {failures}',
|
||||
'group': 'security',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'firewall_issue': {
|
||||
'title': '{hostname}: Firewall issue detected',
|
||||
'body': '{reason}',
|
||||
'group': 'security',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'user_permission_change': {
|
||||
'title': '{hostname}: User permission changed',
|
||||
'body': 'User: {username}\nChange: {change_details}',
|
||||
'group': 'security',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
# ── Cluster events ──
|
||||
'split_brain': {
|
||||
'title': '{hostname}: SPLIT-BRAIN detected',
|
||||
'body': 'Cluster split-brain condition detected.\nQuorum status: {quorum}',
|
||||
'group': 'cluster',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'node_disconnect': {
|
||||
'title': '{hostname}: Node disconnected',
|
||||
'body': 'Node {node_name} has disconnected from the cluster.',
|
||||
'group': 'cluster',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'node_reconnect': {
|
||||
'title': '{hostname}: Node reconnected',
|
||||
'body': 'Node {node_name} has reconnected to the cluster.',
|
||||
'group': 'cluster',
|
||||
'default_enabled': True,
|
||||
},
|
||||
|
||||
# ── System events ──
|
||||
'system_shutdown': {
|
||||
'title': '{hostname}: System shutting down',
|
||||
'body': 'The system is shutting down.\n{reason}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'system_reboot': {
|
||||
'title': '{hostname}: System rebooting',
|
||||
'body': 'The system is rebooting.\n{reason}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'system_problem': {
|
||||
'title': '{hostname}: System problem detected',
|
||||
'body': '{reason}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'service_fail': {
|
||||
'title': '{hostname}: Service failed - {service_name}',
|
||||
'body': 'Service {service_name} has failed.\n{reason}',
|
||||
'group': 'system',
|
||||
'default_enabled': True,
|
||||
},
|
||||
'update_available': {
|
||||
'title': '{hostname}: Updates available ({count})',
|
||||
'body': '{count} package updates are available.\n{details}',
|
||||
'group': 'system',
|
||||
'default_enabled': False,
|
||||
},
|
||||
'update_complete': {
|
||||
'title': '{hostname}: Update completed',
|
||||
'body': '{details}',
|
||||
'group': 'system',
|
||||
'default_enabled': False,
|
||||
},
|
||||
|
||||
# ── Unknown persistent (from health monitor) ──
|
||||
'unknown_persistent': {
|
||||
'title': '{hostname}: Check unavailable - {category}',
|
||||
'body': 'Health check for {category} has been unavailable for 3+ cycles.\n{reason}',
|
||||
'group': 'system',
|
||||
'default_enabled': False,
|
||||
},
|
||||
}
|
||||
|
||||
# ─── Event Groups (for UI filtering) ─────────────────────────────
|
||||
|
||||
EVENT_GROUPS = {
|
||||
'system': {'label': 'System', 'description': 'System health, services, updates'},
|
||||
'vm_ct': {'label': 'VM / CT', 'description': 'Virtual machines and containers'},
|
||||
'backup': {'label': 'Backup', 'description': 'Backups and snapshots'},
|
||||
'resources': {'label': 'Resources', 'description': 'CPU, memory, temperature, load'},
|
||||
'storage': {'label': 'Storage', 'description': 'Disk space and I/O'},
|
||||
'network': {'label': 'Network', 'description': 'Connectivity and latency'},
|
||||
'security': {'label': 'Security', 'description': 'Authentication, firewall, bans'},
|
||||
'cluster': {'label': 'Cluster', 'description': 'Cluster health and quorum'},
|
||||
}
|
||||
|
||||
|
||||
# ─── 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, str]:
|
||||
"""Render a template with the given data.
|
||||
|
||||
Args:
|
||||
event_type: Key from TEMPLATES dict
|
||||
data: Variables to fill into the template
|
||||
|
||||
Returns:
|
||||
{'title': rendered_title, 'body': rendered_body, 'severity': severity}
|
||||
"""
|
||||
template = TEMPLATES.get(event_type)
|
||||
if not template:
|
||||
# Fallback for unknown event types
|
||||
return {
|
||||
'title': f"{_get_hostname()}: {event_type}",
|
||||
'body': data.get('message', data.get('reason', str(data))),
|
||||
'severity': data.get('severity', 'INFO'),
|
||||
}
|
||||
|
||||
# Ensure hostname is always available
|
||||
variables = {
|
||||
'hostname': _get_hostname(),
|
||||
'timestamp': time.strftime('%Y-%m-%d %H:%M:%S'),
|
||||
'severity': data.get('severity', 'INFO'),
|
||||
# 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': '',
|
||||
}
|
||||
variables.update(data)
|
||||
|
||||
try:
|
||||
title = template['title'].format(**variables)
|
||||
except (KeyError, ValueError):
|
||||
title = template['title'] # Use raw template if formatting fails
|
||||
|
||||
try:
|
||||
body = template['body'].format(**variables)
|
||||
except (KeyError, ValueError):
|
||||
body = template['body']
|
||||
|
||||
# Clean up empty lines from missing optional variables
|
||||
body = '\n'.join(line for line in body.split('\n') if line.strip())
|
||||
|
||||
return {
|
||||
'title': title,
|
||||
'body': body,
|
||||
'severity': variables.get('severity', 'INFO'),
|
||||
}
|
||||
|
||||
|
||||
def get_event_types_by_group() -> Dict[str, list]:
|
||||
"""Get all event types organized by group, for UI rendering.
|
||||
|
||||
Returns:
|
||||
{group_key: [{'type': event_type, 'title': template_title,
|
||||
'default_enabled': bool}, ...]}
|
||||
"""
|
||||
result = {}
|
||||
for event_type, template in TEMPLATES.items():
|
||||
group = template.get('group', 'system')
|
||||
if group not in result:
|
||||
result[group] = []
|
||||
result[group].append({
|
||||
'type': event_type,
|
||||
'title': template['title'].replace('{hostname}', '').strip(': '),
|
||||
'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()
|
||||
}
|
||||
|
||||
|
||||
# ─── AI Enhancement (Optional) ───────────────────────────────────
|
||||
|
||||
class AIEnhancer:
|
||||
"""Optional AI message enhancement using external LLM API.
|
||||
|
||||
Enriches template-generated messages with context and suggestions.
|
||||
Falls back to original message if AI is unavailable or fails.
|
||||
"""
|
||||
|
||||
SYSTEM_PROMPT = """You are a Proxmox system administrator assistant.
|
||||
You receive a notification message about a server event and must enhance it with:
|
||||
1. A brief explanation of what this means in practical terms
|
||||
2. A suggested action if applicable (1-2 sentences max)
|
||||
|
||||
Keep the response concise (max 3 sentences total). Do not repeat the original message.
|
||||
Respond in the same language as the input message."""
|
||||
|
||||
def __init__(self, provider: str, api_key: str, model: str = ''):
|
||||
self.provider = provider.lower()
|
||||
self.api_key = api_key
|
||||
self.model = model
|
||||
self._enabled = bool(api_key)
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return self._enabled
|
||||
|
||||
def enhance(self, title: str, body: str, severity: str) -> Optional[str]:
|
||||
"""Enhance a notification message with AI context.
|
||||
|
||||
Returns enhanced body text, or None if enhancement fails/disabled.
|
||||
"""
|
||||
if not self._enabled:
|
||||
return None
|
||||
|
||||
try:
|
||||
if self.provider in ('openai', 'groq'):
|
||||
return self._call_openai_compatible(title, body, severity)
|
||||
except Exception as e:
|
||||
print(f"[AIEnhancer] Enhancement failed: {e}")
|
||||
|
||||
return None
|
||||
|
||||
def _call_openai_compatible(self, title: str, body: str, severity: str) -> Optional[str]:
|
||||
"""Call OpenAI-compatible API (works with OpenAI, Groq, local)."""
|
||||
if self.provider == 'groq':
|
||||
url = 'https://api.groq.com/openai/v1/chat/completions'
|
||||
model = self.model or 'llama-3.3-70b-versatile'
|
||||
else: # openai
|
||||
url = 'https://api.openai.com/v1/chat/completions'
|
||||
model = self.model or 'gpt-4o-mini'
|
||||
|
||||
user_msg = f"Severity: {severity}\nTitle: {title}\nMessage: {body}"
|
||||
|
||||
payload = json.dumps({
|
||||
'model': model,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': self.SYSTEM_PROMPT},
|
||||
{'role': 'user', 'content': user_msg},
|
||||
],
|
||||
'max_tokens': 150,
|
||||
'temperature': 0.3,
|
||||
}).encode('utf-8')
|
||||
|
||||
headers = {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': f'Bearer {self.api_key}',
|
||||
}
|
||||
|
||||
req = urllib.request.Request(url, data=payload, headers=headers)
|
||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
||||
result = json.loads(resp.read().decode('utf-8'))
|
||||
content = result['choices'][0]['message']['content'].strip()
|
||||
return content if content else None
|
||||
|
||||
|
||||
def format_with_ai(title: str, body: str, severity: str,
|
||||
ai_config: Dict[str, str]) -> str:
|
||||
"""Format a message with optional AI enhancement.
|
||||
|
||||
If AI is configured and succeeds, appends AI insight to the body.
|
||||
Otherwise returns the original body unchanged.
|
||||
|
||||
Args:
|
||||
title: Notification title
|
||||
body: Notification body
|
||||
severity: Severity level
|
||||
ai_config: {'enabled': 'true', 'provider': 'groq', 'api_key': '...', 'model': ''}
|
||||
|
||||
Returns:
|
||||
Enhanced body string
|
||||
"""
|
||||
if ai_config.get('enabled') != 'true' or not ai_config.get('api_key'):
|
||||
return body
|
||||
|
||||
enhancer = AIEnhancer(
|
||||
provider=ai_config.get('provider', 'groq'),
|
||||
api_key=ai_config['api_key'],
|
||||
model=ai_config.get('model', ''),
|
||||
)
|
||||
|
||||
insight = enhancer.enhance(title, body, severity)
|
||||
if insight:
|
||||
return f"{body}\n\n---\n{insight}"
|
||||
|
||||
return body
|
||||
Reference in New Issue
Block a user