Details
{html_mod.escape(reason)}
{esc(v)}'))
elif fmt == 'bold':
rows.append((esc(label), f'{esc(v)}'))
else:
rows.append((esc(label), esc(v)))
# ── Common fields present in most events ──
# ── VM / CT events ──
if group == 'vm_ct':
_add('VM/CT ID', data.get('vmid'), 'code')
_add('Name', data.get('vmname'), 'bold')
_add('Action', event_type.replace('_', ' ').replace('vm ', 'VM ').replace('ct ', 'CT ').title())
_add('Target Node', data.get('target_node'))
_add('Reason', data.get('reason'))
# ── Backup events ──
elif group == 'backup':
_add('VM/CT ID', data.get('vmid'), 'code')
_add('Name', data.get('vmname'), 'bold')
_add('Status', 'Failed' if 'fail' in event_type else 'Completed' if 'complete' in event_type else 'Started',
'severity' if 'fail' in event_type else '')
_add('Size', data.get('size'))
_add('Duration', data.get('duration'))
_add('Snapshot', data.get('snapshot_name'), 'code')
# For backup_complete/fail with parsed body, add short reason only
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
# ── Resources ──
elif group == 'resources':
_add('Metric', event_type.replace('_', ' ').title())
_add('Current Value', data.get('value'), 'bold')
_add('Threshold', data.get('threshold'))
_add('CPU Cores', data.get('cores'))
_add('Memory', f"{data.get('used', '')} / {data.get('total', '')}" if data.get('used') else '')
_add('Temperature', f"{data.get('value')}C" if 'temp' in event_type else '')
# ── Storage ──
elif group == 'storage':
if 'disk_space' in event_type:
_add('Mount Point', data.get('mount'), 'code')
_add('Usage', f"{data.get('used')}%", 'bold')
_add('Available', data.get('available'))
elif 'io_error' in event_type:
_add('Device', data.get('device'), 'code')
_add('Severity', data.get('severity', ''), 'severity')
elif 'unavailable' in event_type:
_add('Storage Name', data.get('storage_name'), 'bold')
_add('Type', data.get('storage_type'), 'code')
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
# ── Network ──
elif group == 'network':
_add('Interface', data.get('interface'), 'code')
_add('Latency', f"{data.get('value')}ms" if data.get('value') else '')
_add('Threshold', f"{data.get('threshold')}ms" if data.get('threshold') else '')
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
# ── Security ──
elif group == 'security':
_add('Event', event_type.replace('_', ' ').title())
_add('Source IP', data.get('source_ip'), 'code')
_add('Username', data.get('username'), 'code')
_add('Service', data.get('service'))
_add('Jail', data.get('jail'), 'code')
_add('Failures', data.get('failures'))
_add('Change', data.get('change_details'))
# ── Cluster ──
elif group == 'cluster':
_add('Event', event_type.replace('_', ' ').title())
_add('Node', data.get('node_name'), 'bold')
_add('Quorum', data.get('quorum'))
_add('Nodes Affected', data.get('entity_list'))
# ── Services ──
elif group == 'services':
_add('Service', data.get('service_name'), 'code')
_add('Process', data.get('process'), 'code')
_add('Event', event_type.replace('_', ' ').title())
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
# ── Health monitor ──
elif group == 'health':
_add('Category', data.get('category'), 'bold')
_add('Severity', data.get('severity', ''), 'severity')
if data.get('original_severity'):
_add('Previous Severity', data.get('original_severity'), 'severity')
_add('Duration', data.get('duration'))
_add('Active Issues', data.get('count'))
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
# ── Updates ──
elif group == 'updates':
_add('Total Updates', data.get('total_count'), 'bold')
_add('Security Updates', data.get('security_count'))
_add('Proxmox Updates', data.get('pve_count'))
_add('Kernel Updates', data.get('kernel_count'))
imp = data.get('important_list', '')
if imp and imp != 'none':
# Render each package on its own line inside a single cell
pkg_lines = [l.strip() for l in imp.split('\n') if l.strip()]
if pkg_lines:
pkg_html = '{esc(p)}'
for p in pkg_lines
)
rows.append((esc('Important Packages'), pkg_html))
_add('Current Version', data.get('current_version'), 'code')
_add('New Version', data.get('new_version'), 'code')
# ── Other / unknown ──
else:
reason = data.get('reason', '')
if reason and len(reason) <= 80:
_add('Details', reason)
return rows
def test(self) -> Tuple[bool, str]:
import socket as _socket
hostname = _socket.gethostname().split('.')[0]
result = self.send(
'ProxMenux Test Notification',
'This is a test notification from ProxMenux Monitor.\n'
'If you received this, your email channel is working correctly.',
'INFO',
data={
'hostname': hostname,
'_event_type': 'webhook_test',
'_group': 'other',
'reason': 'Email notification channel connectivity verified successfully. '
'You will receive alerts from ProxMenux Monitor at this address.',
}
)
return result.get('success', False), result.get('error', '')
# ─── Channel Factory ─────────────────────────────────────────────
CHANNEL_TYPES = {
'telegram': {
'name': 'Telegram',
'config_keys': ['bot_token', 'chat_id'],
'class': TelegramChannel,
},
'gotify': {
'name': 'Gotify',
'config_keys': ['url', 'token'],
'class': GotifyChannel,
},
'discord': {
'name': 'Discord',
'config_keys': ['webhook_url'],
'class': DiscordChannel,
},
'email': {
'name': 'Email (SMTP)',
'config_keys': ['host', 'port', 'username', 'password', 'tls_mode',
'from_address', 'to_addresses', 'subject_prefix'],
'class': EmailChannel,
},
}
def create_channel(channel_type: str, config: Dict[str, str]) -> Optional[NotificationChannel]:
"""Create a channel instance from type name and config dict.
Args:
channel_type: 'telegram', 'gotify', or 'discord'
config: Dict with channel-specific keys (see CHANNEL_TYPES)
Returns:
Channel instance or None if creation fails
"""
try:
if channel_type == 'telegram':
return TelegramChannel(
bot_token=config.get('bot_token', ''),
chat_id=config.get('chat_id', '')
)
elif channel_type == 'gotify':
return GotifyChannel(
server_url=config.get('url', ''),
app_token=config.get('token', '')
)
elif channel_type == 'discord':
return DiscordChannel(
webhook_url=config.get('webhook_url', '')
)
elif channel_type == 'email':
return EmailChannel(config)
except Exception as e:
print(f"[NotificationChannels] Failed to create {channel_type}: {e}")
return None