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]:
# Lazy import to avoid a circular dependency with notification_manager,
# which already imports from this module at load time.
from notification_manager import _resolve_display_hostname
hostname = _resolve_display_hostname()
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', '')
# ─── Apprise ─────────────────────────────────────────────────────
class AppriseChannel(NotificationChannel):
"""Apprise meta-channel — a single URL talks to ~80 services.
Apprise (https://github.com/caronc/apprise) is a Python library that
normalises a wide catalogue of notification destinations behind a
single URL scheme: `tgram://`, `discord://`, `slack://`, `gotify://`,
`ntfy://`, `matrix://`, `mailto://`, `pushover://`, `signal://`, etc.
The operator pastes one URL and ProxMenux delegates the transport.
Requested in issue #207 by @0berkampf. Implemented as a *separate
channel type* (not a replacement for the native Telegram / Gotify /
Discord / Email channels), so installs that already have a working
native channel don't need to migrate — Apprise is opt-in for users
who want to reach a service we don't support natively.
The library is loaded lazily on first send. Older deployments that
haven't installed it yet surface a clean validation error instead
of crashing the notification manager at import time.
"""
def __init__(self, url: str):
super().__init__()
self.url = (url or '').strip()
# Lazy import so installs that haven't picked up the new dep yet
# don't crash on module load. Each call re-imports cheaply — Python
# caches the module reference after the first hit.
def _load_apprise(self):
try:
import apprise # type: ignore
return apprise
except ImportError:
return None
def validate_config(self) -> Tuple[bool, str]:
if not self.url:
return False, 'Apprise URL is required'
apprise = self._load_apprise()
if apprise is None:
return False, (
'apprise library not installed in this deployment. '
'Reinstall ProxMenux Monitor or run `pip install apprise` '
'inside the AppImage environment.'
)
# `add(url)` returns True only if Apprise recognised the scheme
# — useful as a syntactic validation without sending anything.
try:
apobj = apprise.Apprise()
ok = apobj.add(self.url)
if not ok:
return False, 'Apprise rejected the URL (unrecognised scheme or bad format)'
except Exception as e:
return False, f'Apprise rejected the URL: {e}'
return True, ''
def _severity_to_notify_type(self, apprise_mod, severity: str):
"""Map ProxMenux severities to Apprise NotifyType constants so
services that render severity (e.g. Pushover priority, ntfy
priority headers) get the right indicator."""
sev = (severity or '').upper()
if sev == 'CRITICAL':
return apprise_mod.NotifyType.FAILURE
if sev == 'WARNING':
return apprise_mod.NotifyType.WARNING
if sev == 'SUCCESS':
return apprise_mod.NotifyType.SUCCESS
return apprise_mod.NotifyType.INFO
def send(self, title: str, message: str, severity: str = 'INFO',
data: Optional[Dict] = None) -> Dict[str, Any]:
ok, err = self.validate_config()
if not ok:
return {'success': False, 'error': err, 'channel': 'apprise'}
# Rate limit (shared with the other channels) before dispatch.
def _send_via_apprise() -> Tuple[int, str]:
apprise = self._load_apprise()
if apprise is None:
# Shouldn't happen — validate_config caught it above —
# but defend in depth so the retry loop reports cleanly.
return 0, 'apprise library not available'
try:
apobj = apprise.Apprise()
apobj.add(self.url)
sent = apobj.notify(
body=message or '',
title=title or '',
notify_type=self._severity_to_notify_type(apprise, severity),
)
# `notify` returns True iff at least one target accepted
# the message. False means every URL endpoint rejected
# — we don't get a per-URL status code back, hence the
# opaque "Apprise rejected the notification".
if sent:
return 200, ''
return 500, 'Apprise rejected the notification (transport failure)'
except Exception as e:
return 0, str(e)
result = self._send_with_retry(_send_via_apprise)
result['channel'] = 'apprise'
return result
def test(self) -> Tuple[bool, str]:
result = self.send(
title='ProxMenux Monitor — Test',
message='Apprise channel is configured correctly. If you can read this, the URL is valid and the service accepted the notification.',
severity='INFO',
)
return bool(result.get('success')), result.get('error') or ''
# ─── Channel Factory ─────────────────────────────────────────────
CHANNEL_TYPES = {
'telegram': {
'name': 'Telegram',
'config_keys': ['bot_token', 'chat_id', 'topic_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,
},
'apprise': {
'name': 'Apprise',
'config_keys': ['url'],
'class': AppriseChannel,
},
}
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', 'discord', 'email', or 'apprise'
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', ''),
topic_id=config.get('topic_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)
elif channel_type == 'apprise':
return AppriseChannel(url=config.get('url', ''))
except Exception as e:
print(f"[NotificationChannels] Failed to create {channel_type}: {e}")
return None