mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-19 21:26:47 +00:00
Merge pull request #348 from Vaso73/fix/notification-runtime-i18n
i18n: localize runtime notification delivery
This commit is contained in:
@@ -77,6 +77,7 @@ interface NotificationConfig {
|
||||
ai_api_keys: Record<string, string> // Per-provider API keys
|
||||
ai_models: Record<string, string> // Per-provider selected models
|
||||
ai_model: string // Current active model (for the selected provider)
|
||||
notification_language: string
|
||||
ai_language: string
|
||||
ai_ollama_url: string
|
||||
ai_openai_base_url: string
|
||||
@@ -150,7 +151,7 @@ function validateGotifyUrl(url: string): { error?: string; warning?: string } {
|
||||
return {}
|
||||
}
|
||||
|
||||
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "other"].map(key => ({ key }))
|
||||
const EVENT_CATEGORIES = ["vm_ct", "backup", "resources", "storage", "network", "security", "cluster", "services", "health", "updates", "hardware", "system", "other"].map(key => ({ key }))
|
||||
|
||||
const CHANNEL_TYPES = ["telegram", "gotify", "discord", "email", "pushover", "apprise"] as const
|
||||
|
||||
@@ -199,6 +200,24 @@ const AI_PROVIDERS = [
|
||||
},
|
||||
]
|
||||
|
||||
const NOTIFICATION_LANGUAGES = [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "de", label: "Deutsch" },
|
||||
{ value: "es", label: "Español" },
|
||||
{ value: "fr", label: "Français" },
|
||||
{ value: "it", label: "Italiano" },
|
||||
{ value: "pt", label: "Português" },
|
||||
{ value: "sk", label: "Slovenčina" },
|
||||
{ value: "sv", label: "Svenska" },
|
||||
]
|
||||
|
||||
function normalizeNotificationLanguage(notificationLanguage?: string, legacyAiLanguage?: string): string {
|
||||
const runtimeLanguages = new Set(NOTIFICATION_LANGUAGES.map(language => language.value))
|
||||
if (notificationLanguage && runtimeLanguages.has(notificationLanguage)) return notificationLanguage
|
||||
if (legacyAiLanguage && runtimeLanguages.has(legacyAiLanguage)) return legacyAiLanguage
|
||||
return "en"
|
||||
}
|
||||
|
||||
const AI_LANGUAGES = [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "sk", label: "Slovenčina" },
|
||||
@@ -254,7 +273,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
event_categories: {
|
||||
vm_ct: true, backup: true, resources: true, storage: true,
|
||||
network: true, security: true, cluster: true, services: true,
|
||||
health: true, updates: true, other: true,
|
||||
health: true, updates: true, hardware: true, system: true, other: true,
|
||||
},
|
||||
event_toggles: {},
|
||||
event_types_by_group: {},
|
||||
@@ -284,6 +303,7 @@ const DEFAULT_CONFIG: NotificationConfig = {
|
||||
openrouter: "",
|
||||
},
|
||||
ai_model: "",
|
||||
notification_language: "en",
|
||||
ai_language: "en",
|
||||
ai_ollama_url: "http://localhost:11434",
|
||||
ai_openai_base_url: "",
|
||||
@@ -401,6 +421,10 @@ export function NotificationSettings() {
|
||||
ai_prompt_mode: data.config.ai_prompt_mode || "default",
|
||||
ai_custom_prompt: data.config.ai_custom_prompt || "",
|
||||
ai_allow_suggestions: data.config.ai_allow_suggestions || "false",
|
||||
notification_language: normalizeNotificationLanguage(
|
||||
data.config.notification_language,
|
||||
data.config.ai_language,
|
||||
),
|
||||
}
|
||||
// If ai_model exists but ai_models doesn't have it, save it
|
||||
if (configWithDefaults.ai_model && !configWithDefaults.ai_models[configWithDefaults.ai_provider]) {
|
||||
@@ -834,6 +858,7 @@ export function NotificationSettings() {
|
||||
ai_enabled: String(cfg.ai_enabled),
|
||||
ai_provider: cfg.ai_provider,
|
||||
ai_model: cfg.ai_model,
|
||||
notification_language: normalizeNotificationLanguage(cfg.notification_language, cfg.ai_language),
|
||||
ai_language: cfg.ai_language,
|
||||
ai_ollama_url: cfg.ai_ollama_url,
|
||||
ai_openai_base_url: cfg.ai_openai_base_url,
|
||||
@@ -2176,6 +2201,31 @@ export function NotificationSettings() {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Runtime notification language (independent of AI) ── */}
|
||||
<div className="space-y-2 pb-3 border-b border-border/50">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="h-4 w-4 text-green-400" />
|
||||
<Label className="text-xs sm:text-sm text-foreground/80">{t("settings.notifications.ui.notificationLanguage")}</Label>
|
||||
</div>
|
||||
<Select
|
||||
value={normalizeNotificationLanguage(config.notification_language, config.ai_language)}
|
||||
onValueChange={value => updateConfig(previous => ({ ...previous, notification_language: value }))}
|
||||
disabled={!editMode}
|
||||
>
|
||||
<SelectTrigger className="h-9 text-sm">
|
||||
<SelectValue placeholder={t("settings.notifications.ui.selectNotificationLanguage")} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{NOTIFICATION_LANGUAGES.map(language => (
|
||||
<SelectItem key={language.value} value={language.value}>{language.label}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.notifications.ui.notificationLanguageHint")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* ── Advanced: AI Enhancement ── */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between py-1">
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -163,6 +163,13 @@ cp "$SCRIPT_DIR/proxmox_known_errors.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo
|
||||
cp "$SCRIPT_DIR/ai_context_enrichment.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ ai_context_enrichment.py not found"
|
||||
cp "$SCRIPT_DIR/startup_grace.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ startup_grace.py not found"
|
||||
cp "$SCRIPT_DIR/flask_notification_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_notification_routes.py not found"
|
||||
# Backend notification rendering reuses the canonical Monitor catalogs.
|
||||
# Keep every supported locale in the bundle so the runtime selector and its
|
||||
# fallback behavior exactly match the Monitor UI.
|
||||
for locale in en de es fr it pt sk sv; do
|
||||
mkdir -p "$APP_DIR/usr/share/proxmenux/messages/$locale"
|
||||
cp "$APPIMAGE_ROOT/messages/$locale/common.json" "$APP_DIR/usr/share/proxmenux/messages/$locale/common.json"
|
||||
done
|
||||
cp "$SCRIPT_DIR/oci_manager.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ oci_manager.py not found"
|
||||
cp "$SCRIPT_DIR/flask_oci_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_oci_routes.py not found"
|
||||
cp "$SCRIPT_DIR/flask_audit_routes.py" "$APP_DIR/usr/bin/" 2>/dev/null || echo "⚠️ flask_audit_routes.py not found"
|
||||
|
||||
@@ -93,7 +93,7 @@ from flask_security_routes import security_bp # noqa: E402
|
||||
from flask_notification_routes import notification_bp # noqa: E402
|
||||
from flask_oci_routes import oci_bp # noqa: E402
|
||||
from flask_audit_routes import audit_bp # noqa: E402
|
||||
from notification_manager import notification_manager # noqa: E402
|
||||
from notification_manager import notification_manager, resolve_notification_hostname # noqa: E402
|
||||
import post_install_versions # noqa: E402 — Sprint 12A: detect post-install function updates
|
||||
from jwt_middleware import require_auth, require_auth_or_ticket, require_admin_scope # noqa: E402
|
||||
import auth_manager # noqa: E402
|
||||
@@ -1464,6 +1464,11 @@ def _health_collector_loop():
|
||||
if not hostname:
|
||||
import socket as _sock
|
||||
hostname = _sock.gethostname()
|
||||
# The health collector builds its title before the event
|
||||
# reaches NotificationManager, so normalize here as well.
|
||||
hostname = resolve_notification_hostname(
|
||||
hostname, notification_manager._config,
|
||||
)
|
||||
|
||||
# Capture journal context for AI enrichment
|
||||
# Extract category keys and reasons for keyword matching
|
||||
@@ -1526,6 +1531,7 @@ def _health_collector_loop():
|
||||
'count': str(len(degraded)),
|
||||
'title': title,
|
||||
'reason': body,
|
||||
'health_degraded': {'categories': degraded},
|
||||
'_journal_context': journal_context,
|
||||
},
|
||||
source='health_monitor',
|
||||
@@ -14297,6 +14303,20 @@ def _finalize_lxc_update(
|
||||
'result': result_words[status],
|
||||
'duration': duration,
|
||||
'details': details,
|
||||
'lxc_update': {
|
||||
'status': status,
|
||||
'source': source,
|
||||
'targets': executed,
|
||||
'labels': labels,
|
||||
'deferred_targets': deferred,
|
||||
'duration': duration,
|
||||
'reason': str(reason)[:500] if reason else None,
|
||||
'before': before,
|
||||
'after': after,
|
||||
'verification_pending': verification_pending,
|
||||
'verification_errors': verification_errors,
|
||||
'reboot_required': reboot_required,
|
||||
},
|
||||
},
|
||||
source=source,
|
||||
entity='ct',
|
||||
|
||||
@@ -36,6 +36,18 @@ _KNOWN_SSRF_TARGETS = {
|
||||
_BLOCKED_LOOPBACK_PORTS = {'8006', '8007'} # PVE API HTTPS / HTTPS-alt
|
||||
|
||||
|
||||
def _runtime_notification_text(key: str, data: Optional[Dict] = None,
|
||||
**values: Any) -> str:
|
||||
"""Resolve runtime text lazily to avoid the manager/channel import cycle."""
|
||||
from notification_templates import runtime_message
|
||||
language = str((data or {}).get('_notification_language', 'en'))
|
||||
return runtime_message(key, language, **values)
|
||||
|
||||
|
||||
def _runtime_text(key: str, data: Optional[Dict] = None, **values: Any) -> str:
|
||||
return _runtime_notification_text(f'channels.{key}', data, **values)
|
||||
|
||||
|
||||
def _validate_user_webhook_url(url: str) -> Tuple[bool, str]:
|
||||
"""Lightweight SSRF guard for Gotify-style channels.
|
||||
|
||||
@@ -308,18 +320,102 @@ class TelegramChannel(NotificationChannel):
|
||||
return self._http_request(url, payload, {'Content-Type': 'application/json'})
|
||||
|
||||
def _split_message(self, text: str) -> list:
|
||||
"""Split Telegram HTML without cutting entities or formatting tags.
|
||||
|
||||
Open formatting tags are closed at the end of a chunk and reopened in
|
||||
the next one, so every API request is valid HTML on its own.
|
||||
"""
|
||||
if len(text) <= self.MAX_LENGTH:
|
||||
return [text]
|
||||
chunks = []
|
||||
while text:
|
||||
if len(text) <= self.MAX_LENGTH:
|
||||
chunks.append(text)
|
||||
|
||||
token_re = re.compile(
|
||||
r'&(?:#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]+);|<[^<>]+>|.',
|
||||
re.DOTALL,
|
||||
)
|
||||
entity_re = re.compile(r'&(?:#[0-9]+|#x[0-9A-Fa-f]+|[A-Za-z][A-Za-z0-9]+);')
|
||||
tag_re = re.compile(r'<\s*(/?)\s*([A-Za-z0-9-]+)(?:\s[^<>]*)?>')
|
||||
void_tags = {'br'}
|
||||
|
||||
def _advance(stack, token):
|
||||
match = tag_re.fullmatch(token)
|
||||
if not match:
|
||||
return list(stack)
|
||||
closing, name = match.groups()
|
||||
name = name.lower()
|
||||
next_stack = list(stack)
|
||||
if closing:
|
||||
if next_stack and next_stack[-1][0] == name:
|
||||
next_stack.pop()
|
||||
elif not token.rstrip().endswith('/>') and name not in void_tags:
|
||||
next_stack.append((name, token))
|
||||
return next_stack
|
||||
|
||||
def _closers(stack):
|
||||
return ''.join(f'</{name}>' for name, _ in reversed(stack))
|
||||
|
||||
def _openers(stack):
|
||||
return ''.join(opener for _, opener in stack)
|
||||
|
||||
def _plain_chunks(tokens):
|
||||
"""Drop unsafe formatting while preserving safe visible HTML text."""
|
||||
safe_tokens = []
|
||||
for token in tokens:
|
||||
if tag_re.fullmatch(token):
|
||||
continue
|
||||
if entity_re.fullmatch(token) and len(token) <= self.MAX_LENGTH:
|
||||
safe_tokens.append(token)
|
||||
continue
|
||||
if entity_re.fullmatch(token) or token in {'&', '<', '>'}:
|
||||
safe_tokens.extend(token_re.findall(self._escape_html(token)))
|
||||
else:
|
||||
safe_tokens.append(token)
|
||||
|
||||
plain_chunks = []
|
||||
current = ''
|
||||
for token in safe_tokens:
|
||||
if current and len(current) + len(token) > self.MAX_LENGTH:
|
||||
plain_chunks.append(current)
|
||||
current = ''
|
||||
current += token
|
||||
if current:
|
||||
plain_chunks.append(current)
|
||||
return plain_chunks
|
||||
|
||||
tokens = token_re.findall(text)
|
||||
probe_tags = []
|
||||
unsafe_html = False
|
||||
for token in tokens:
|
||||
match = tag_re.fullmatch(token)
|
||||
if match and match.group(1):
|
||||
name = match.group(2).lower()
|
||||
if not probe_tags or probe_tags[-1][0] != name:
|
||||
unsafe_html = True
|
||||
break
|
||||
next_tags = _advance(probe_tags, token)
|
||||
minimum_chunk = len(_openers(probe_tags)) + len(token) + len(_closers(next_tags))
|
||||
if len(token) > self.MAX_LENGTH or minimum_chunk > self.MAX_LENGTH:
|
||||
unsafe_html = True
|
||||
break
|
||||
split_at = text.rfind('\n', 0, self.MAX_LENGTH)
|
||||
if split_at == -1:
|
||||
split_at = self.MAX_LENGTH
|
||||
chunks.append(text[:split_at])
|
||||
text = text[split_at:].lstrip('\n')
|
||||
probe_tags = next_tags
|
||||
|
||||
if unsafe_html:
|
||||
return _plain_chunks(tokens)
|
||||
|
||||
chunks = []
|
||||
current = ''
|
||||
open_tags = []
|
||||
for token in tokens:
|
||||
next_tags = _advance(open_tags, token)
|
||||
if current and len(current) + len(token) + len(_closers(next_tags)) > self.MAX_LENGTH:
|
||||
chunks.append(current + _closers(open_tags))
|
||||
current = _openers(open_tags)
|
||||
current += token
|
||||
open_tags = _advance(open_tags, token)
|
||||
|
||||
if current:
|
||||
chunks.append(current + _closers(open_tags))
|
||||
if any(len(chunk) > self.MAX_LENGTH for chunk in chunks):
|
||||
return _plain_chunks(tokens)
|
||||
return chunks
|
||||
|
||||
@staticmethod
|
||||
@@ -612,11 +708,11 @@ class DiscordChannel(NotificationChannel):
|
||||
]
|
||||
elif data:
|
||||
if data.get('category'):
|
||||
fields.append({'name': 'Category', 'value': data['category'], 'inline': True})
|
||||
fields.append({'name': _runtime_text('discord.category', data), 'value': data['category'], 'inline': True})
|
||||
if data.get('hostname'):
|
||||
fields.append({'name': 'Host', 'value': data['hostname'], 'inline': True})
|
||||
fields.append({'name': _runtime_text('discord.host', data), 'value': data['hostname'], 'inline': True})
|
||||
if data.get('severity'):
|
||||
fields.append({'name': 'Severity', 'value': data['severity'], 'inline': True})
|
||||
fields.append({'name': _runtime_text('discord.severity', data), 'value': data['severity'], 'inline': True})
|
||||
|
||||
embeds: List[Dict[str, Any]] = []
|
||||
for idx, chunk in enumerate(chunks):
|
||||
@@ -936,12 +1032,17 @@ class EmailChannel(NotificationChannel):
|
||||
import time as _time
|
||||
|
||||
data = data or {}
|
||||
sev = self._SEV_STYLE.get(severity, self._SEV_DEFAULT)
|
||||
sev = dict(self._SEV_STYLE.get(severity, self._SEV_DEFAULT))
|
||||
severity_key = severity.lower() if severity in self._SEV_STYLE else 'default'
|
||||
sev['label'] = _runtime_text(f'email.severity.{severity_key}', data)
|
||||
|
||||
# Determine group for section header
|
||||
event_type = data.get('_event_type', '')
|
||||
group = data.get('_group', 'other')
|
||||
section_label = self._GROUP_LABELS.get(group, 'System Notification')
|
||||
section_label = _runtime_text(f'email.groups.{group}', data)
|
||||
report_label = _runtime_text('email.report', data, group=section_label)
|
||||
host_label = _runtime_text('email.host', data)
|
||||
footer_label = _runtime_text('email.footer', data)
|
||||
|
||||
# Timestamp
|
||||
ts = data.get('timestamp', '') or _time.strftime('%Y-%m-%d %H:%M:%S UTC', _time.gmtime())
|
||||
@@ -949,6 +1050,19 @@ class EmailChannel(NotificationChannel):
|
||||
# ── Build structured detail rows from known data fields ──
|
||||
detail_rows = self._build_detail_rows(data, event_type, group, html_mod)
|
||||
|
||||
# Vzdump bodies are authoritative multi-item inventories. Keep their
|
||||
# lines exactly once, but retain the localized structured status row;
|
||||
# the remaining structured backup metadata only duplicates the report.
|
||||
if event_type in {'backup_complete', 'backup_fail'}:
|
||||
status_label = html_mod.escape(
|
||||
_runtime_text('email.fields.status', data)
|
||||
)
|
||||
detail_rows = [row for row in detail_rows if row[0] == status_label]
|
||||
detail_rows.extend(
|
||||
('', html_mod.escape(line.strip()))
|
||||
for line in body.split('\n') if line.strip()
|
||||
)
|
||||
|
||||
# ── Fallback: if no structured rows, render body text lines ──
|
||||
if not detail_rows:
|
||||
for line in body.split('\n'):
|
||||
@@ -983,7 +1097,7 @@ class EmailChannel(NotificationChannel):
|
||||
if reason and len(reason) > 80:
|
||||
reason_html = f'''
|
||||
<div style="margin:16px 0 0;padding:12px 16px;border:1px solid #d1d5db;border-radius:6px;">
|
||||
<p style="margin:0 0 4px;font-size:11px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:0.05em;">Details</p>
|
||||
<p style="margin:0 0 4px;font-size:11px;font-weight:600;color:#374151;text-transform:uppercase;letter-spacing:0.05em;">{_runtime_text('email.details', data)}</p>
|
||||
<p style="margin:0;font-size:13px;color:#1f2937;line-height:1.6;white-space:pre-wrap;">{html_mod.escape(reason)}</p>
|
||||
</div>'''
|
||||
|
||||
@@ -993,7 +1107,7 @@ class EmailChannel(NotificationChannel):
|
||||
display_title = display_title.replace(prefix, '').strip()
|
||||
|
||||
return f'''<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<html lang="{html_mod.escape(str(data.get('_notification_language', 'en')))}">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1.0"></head>
|
||||
<body style="margin:0;padding:0;background-color:#f3f4f6;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif;">
|
||||
<div style="max-width:640px;margin:24px auto;background:#ffffff;border-radius:8px;overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,0.1);border:1px solid #d1d5db;">
|
||||
@@ -1004,7 +1118,7 @@ class EmailChannel(NotificationChannel):
|
||||
<tr>
|
||||
<td>
|
||||
<h1 style="margin:0;font-size:18px;font-weight:700;color:#111827;letter-spacing:-0.02em;">ProxMenux Monitor</h1>
|
||||
<p style="margin:4px 0 0;font-size:12px;color:#4b5563;">{html_mod.escape(section_label)} Report</p>
|
||||
<p style="margin:4px 0 0;font-size:12px;color:#4b5563;">{html_mod.escape(report_label)}</p>
|
||||
</td>
|
||||
<td style="text-align:right;vertical-align:top;">
|
||||
<span style="display:inline-block;padding:4px 12px;border-radius:4px;font-size:11px;font-weight:600;letter-spacing:0.05em;color:{sev['color']};background:{sev['bg']};border:1px solid {sev['border']};">{sev['label'].upper()}</span>
|
||||
@@ -1024,7 +1138,7 @@ class EmailChannel(NotificationChannel):
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="margin-bottom:16px;">
|
||||
<tr>
|
||||
<td style="font-size:12px;color:#4b5563;">
|
||||
Host: <strong style="color:#111827;">{html_mod.escape(data.get('hostname', ''))}</strong>
|
||||
{html_mod.escape(host_label)}: <strong style="color:#111827;">{html_mod.escape(data.get('hostname', ''))}</strong>
|
||||
</td>
|
||||
<td style="font-size:12px;color:#4b5563;text-align:right;">
|
||||
{html_mod.escape(ts)}
|
||||
@@ -1044,7 +1158,7 @@ class EmailChannel(NotificationChannel):
|
||||
<div style="padding:14px 28px;border-top:1px solid #d1d5db;">
|
||||
<table width="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td style="font-size:11px;color:#4b5563;">ProxMenux Notification Service</td>
|
||||
<td style="font-size:11px;color:#4b5563;">{html_mod.escape(footer_label)}</td>
|
||||
<td style="font-size:11px;color:#4b5563;text-align:right;">proxmenux.com</td>
|
||||
</tr>
|
||||
</table>
|
||||
@@ -1064,11 +1178,37 @@ class EmailChannel(NotificationChannel):
|
||||
"""
|
||||
esc = html_mod.escape
|
||||
rows = []
|
||||
field_keys = {
|
||||
'VM/CT ID': 'vmCtId', 'Name': 'name', 'Action': 'action',
|
||||
'Target Node': 'targetNode', 'Reason': 'reason', 'Storage': 'storage',
|
||||
'Status': 'status', 'Size': 'size', 'Duration': 'duration',
|
||||
'Snapshot': 'snapshot', 'Metric': 'metric', 'Current Value': 'currentValue',
|
||||
'Threshold': 'threshold', 'CPU Cores': 'cpuCores', 'Memory': 'memory',
|
||||
'Temperature': 'temperature', 'Mount Point': 'mountPoint', 'Usage': 'usage',
|
||||
'Available': 'available', 'Device': 'device', 'Severity': 'severity',
|
||||
'Storage Name': 'storageName', 'Type': 'type', 'Interface': 'interface',
|
||||
'Latency': 'latency', 'Event': 'event', 'Source IP': 'sourceIp',
|
||||
'Username': 'username', 'Service': 'service', 'Jail': 'jail',
|
||||
'Failures': 'failures', 'Change': 'change', 'Node': 'node',
|
||||
'Quorum': 'quorum', 'Nodes Affected': 'nodesAffected', 'Process': 'process',
|
||||
'Details': 'reason', 'Category': 'category',
|
||||
'Previous Severity': 'previousSeverity', 'Active Issues': 'activeIssues',
|
||||
'Total Updates': 'totalUpdates', 'Security Updates': 'securityUpdates',
|
||||
'Proxmox Updates': 'proxmoxUpdates', 'Kernel Updates': 'kernelUpdates',
|
||||
'Important Packages': 'importantPackages', 'Current Version': 'currentVersion',
|
||||
'New Version': 'newVersion',
|
||||
}
|
||||
language_data = data
|
||||
|
||||
def _event_label() -> str:
|
||||
return _runtime_notification_text(f'templates.{event_type}.label', language_data)
|
||||
|
||||
def _add(label: str, value, fmt: str = ''):
|
||||
"""Add a row if value is truthy."""
|
||||
"""Add a localized row if value is truthy."""
|
||||
original_label = label
|
||||
label = _runtime_text(f"email.fields.{field_keys[label]}", language_data)
|
||||
v = str(value).strip() if value else ''
|
||||
if not v or v == '0' and label not in ('Failures',):
|
||||
if not v or v == '0' and original_label not in ('Failures',):
|
||||
return
|
||||
if fmt == 'severity':
|
||||
sev_colors = {
|
||||
@@ -1090,7 +1230,8 @@ class EmailChannel(NotificationChannel):
|
||||
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())
|
||||
action = _runtime_notification_text(f'templates.{event_type}.label', data)
|
||||
_add('Action', action)
|
||||
_add('Target Node', data.get('target_node'))
|
||||
_add('Reason', data.get('reason'))
|
||||
|
||||
@@ -1102,7 +1243,8 @@ class EmailChannel(NotificationChannel):
|
||||
# tell which target the backup ran against. Reported gap: emails
|
||||
# showed no way to distinguish which PBS failed with 2+ configured.
|
||||
_add('Storage', data.get('storage') or data.get('storage_name'), 'code')
|
||||
_add('Status', 'Failed' if 'fail' in event_type else 'Completed' if 'complete' in event_type else 'Started',
|
||||
status_key = 'failed' if 'fail' in event_type else 'completed' if 'complete' in event_type else 'started'
|
||||
_add('Status', _runtime_text(f'email.status.{status_key}', language_data),
|
||||
'severity' if 'fail' in event_type else '')
|
||||
_add('Size', data.get('size'))
|
||||
_add('Duration', data.get('duration'))
|
||||
@@ -1114,7 +1256,7 @@ class EmailChannel(NotificationChannel):
|
||||
|
||||
# ── Resources ──
|
||||
elif group == 'resources':
|
||||
_add('Metric', event_type.replace('_', ' ').title())
|
||||
_add('Metric', _event_label())
|
||||
_add('Current Value', data.get('value'), 'bold')
|
||||
_add('Threshold', data.get('threshold'))
|
||||
_add('CPU Cores', data.get('cores'))
|
||||
@@ -1148,7 +1290,7 @@ class EmailChannel(NotificationChannel):
|
||||
|
||||
# ── Security ──
|
||||
elif group == 'security':
|
||||
_add('Event', event_type.replace('_', ' ').title())
|
||||
_add('Event', _event_label())
|
||||
_add('Source IP', data.get('source_ip'), 'code')
|
||||
_add('Username', data.get('username'), 'code')
|
||||
_add('Service', data.get('service'))
|
||||
@@ -1158,7 +1300,7 @@ class EmailChannel(NotificationChannel):
|
||||
|
||||
# ── Cluster ──
|
||||
elif group == 'cluster':
|
||||
_add('Event', event_type.replace('_', ' ').title())
|
||||
_add('Event', _event_label())
|
||||
_add('Node', data.get('node_name'), 'bold')
|
||||
_add('Quorum', data.get('quorum'))
|
||||
_add('Nodes Affected', data.get('entity_list'))
|
||||
@@ -1167,7 +1309,7 @@ class EmailChannel(NotificationChannel):
|
||||
elif group == 'services':
|
||||
_add('Service', data.get('service_name'), 'code')
|
||||
_add('Process', data.get('process'), 'code')
|
||||
_add('Event', event_type.replace('_', ' ').title())
|
||||
_add('Event', _event_label())
|
||||
reason = data.get('reason', '')
|
||||
if reason and len(reason) <= 80:
|
||||
_add('Details', reason)
|
||||
@@ -1199,7 +1341,7 @@ class EmailChannel(NotificationChannel):
|
||||
f'<code style="padding:1px 5px;background:#f3f4f6;border-radius:3px;font-family:monospace;font-size:12px;">{esc(p)}</code>'
|
||||
for p in pkg_lines
|
||||
)
|
||||
rows.append((esc('Important Packages'), pkg_html))
|
||||
rows.append((esc(_runtime_text('email.fields.importantPackages', language_data)), pkg_html))
|
||||
_add('Current Version', data.get('current_version'), 'code')
|
||||
# `new_version` is the field used by generic package-update events;
|
||||
# driver-update templates (nvidia, coral) populate `latest_version`.
|
||||
@@ -1207,6 +1349,13 @@ class EmailChannel(NotificationChannel):
|
||||
# title/body already printed the new version.
|
||||
_add('New Version', data.get('new_version') or data.get('latest_version'), 'code')
|
||||
|
||||
# ── Generic system events ──
|
||||
elif group == 'system':
|
||||
_add('Event', _event_label())
|
||||
reason = data.get('reason', '')
|
||||
if reason and len(reason) <= 80:
|
||||
_add('Details', reason)
|
||||
|
||||
# ── Other / unknown ──
|
||||
else:
|
||||
reason = data.get('reason', '')
|
||||
|
||||
@@ -3517,7 +3517,9 @@ class PollingCollector:
|
||||
'security_count': str(len(security_pkgs)),
|
||||
'pve_count': str(len(pve_pkgs)),
|
||||
'kernel_count': str(len(kernel_pkgs)),
|
||||
'important_list': '\n'.join(f' \u2022 {l}' for l in important_lines) if important_lines else 'none',
|
||||
# An empty value is intentionally rendered later by the
|
||||
# notification template as the selected locale's “none”.
|
||||
'important_list': '\n'.join(f' \u2022 {l}' for l in important_lines),
|
||||
'package_list': ', '.join(important_lines[:6]) if important_lines else '',
|
||||
}
|
||||
|
||||
|
||||
@@ -41,7 +41,8 @@ if BASE_DIR not in sys.path:
|
||||
from notification_channels import create_channel, CHANNEL_TYPES
|
||||
from notification_templates import (
|
||||
render_template, format_with_ai, format_with_ai_full, enrich_with_emojis, TEMPLATES,
|
||||
EVENT_GROUPS, get_event_types_by_group, get_default_enabled_events
|
||||
EVENT_GROUPS, CATEGORY_EMOJI, EVENT_EMOJI, get_event_types_by_group,
|
||||
get_default_enabled_events, runtime_message,
|
||||
)
|
||||
from notification_events import (
|
||||
JournalWatcher, TaskWatcher, PollingCollector, NotificationEvent,
|
||||
@@ -61,6 +62,17 @@ except ImportError:
|
||||
DB_PATH = Path('/usr/local/share/proxmenux/health_monitor.db')
|
||||
SETTINGS_PREFIX = 'notification.'
|
||||
ENCRYPTION_KEY_FILE = Path('/usr/local/share/proxmenux/.notification_key')
|
||||
RUNTIME_NOTIFICATION_LANGUAGES = ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv')
|
||||
ALLOWED_AI_LANGUAGES = (
|
||||
'en', 'sk', 'es', 'fr', 'de', 'it', 'pt', 'ru', 'sv', 'no',
|
||||
'ja', 'zh', 'ko', 'pl', 'nl', 'tr', 'ar',
|
||||
)
|
||||
_AI_BYPASS_EVENTS = frozenset({'backup_complete', 'backup_fail'})
|
||||
|
||||
|
||||
def _should_bypass_ai(event_type: str) -> bool:
|
||||
"""Keep complete backup inventories deterministic on every send path."""
|
||||
return event_type in _AI_BYPASS_EVENTS
|
||||
|
||||
# Keys that contain sensitive data and should be encrypted
|
||||
SENSITIVE_KEYS = {
|
||||
@@ -200,6 +212,40 @@ def _resolve_display_hostname(config: Optional[Dict[str, str]] = None) -> str:
|
||||
return socket.gethostname()
|
||||
|
||||
|
||||
def resolve_notification_hostname(value: Any = None,
|
||||
config: Optional[Dict[str, str]] = None) -> str:
|
||||
"""Return the configured display name for a hostname of this local node.
|
||||
|
||||
Some event producers pass ``socket.gethostname()`` explicitly while
|
||||
others let the notification layer resolve it. A configured display name
|
||||
must have the same result in both cases. Keep a hostname that is not an
|
||||
alias of this machine intact: it can identify a remote node forwarded to
|
||||
this monitor.
|
||||
"""
|
||||
configured_name = (config or {}).get('hostname', '')
|
||||
configured_name = str(configured_name or '').strip()
|
||||
candidate = str(value or '').strip()
|
||||
|
||||
if not configured_name:
|
||||
return candidate or _resolve_display_hostname(config)
|
||||
if not candidate:
|
||||
return configured_name
|
||||
|
||||
local_aliases = set()
|
||||
for resolver in (socket.gethostname, socket.getfqdn):
|
||||
try:
|
||||
hostname = str(resolver() or '').strip()
|
||||
except Exception:
|
||||
hostname = ''
|
||||
if hostname:
|
||||
local_aliases.add(hostname.casefold())
|
||||
local_aliases.add(hostname.split('.', 1)[0].casefold())
|
||||
|
||||
if candidate.casefold() in local_aliases:
|
||||
return configured_name
|
||||
return candidate
|
||||
|
||||
|
||||
# ─── Encryption for Sensitive Data ───────────────────────────────
|
||||
#
|
||||
# Audit Tier 4 #24 flagged the previous implementation as trivially reversible:
|
||||
@@ -934,6 +980,21 @@ class NotificationManager:
|
||||
or self._config.get('ai_model', '')
|
||||
)
|
||||
|
||||
def _notification_language(self) -> str:
|
||||
"""Return a bundled runtime locale, with a safe English fallback.
|
||||
|
||||
Older installations used the AI language for every notification. Keep
|
||||
that value as a migration fallback only; notification text is now
|
||||
independent of whether AI enhancement is enabled.
|
||||
"""
|
||||
selected = str(self._config.get('notification_language', '')).strip().lower()
|
||||
if selected in RUNTIME_NOTIFICATION_LANGUAGES:
|
||||
return selected
|
||||
legacy = str(self._config.get('ai_language', '')).strip().lower()
|
||||
if legacy in RUNTIME_NOTIFICATION_LANGUAGES:
|
||||
return legacy
|
||||
return 'en'
|
||||
|
||||
def _build_ai_config(self) -> Dict[str, Any]:
|
||||
"""Build the shared AI config passed to notification rewriters."""
|
||||
ai_provider = self._config.get('ai_provider', 'groq')
|
||||
@@ -1197,6 +1258,13 @@ class NotificationManager:
|
||||
|
||||
def _dispatch_event(self, event: NotificationEvent):
|
||||
"""Shared dispatch pipeline: cooldown -> rate limit -> render -> send."""
|
||||
# Event sources may supply the local kernel hostname themselves.
|
||||
# Normalize it here so every delivery path honours the configured
|
||||
# notification display name, including newly added event producers.
|
||||
event.data['hostname'] = resolve_notification_hostname(
|
||||
event.data.get('hostname'), self._config,
|
||||
)
|
||||
|
||||
# Suppress VM/CT start/stop during active backups (second layer of defense).
|
||||
# The primary filter is in TaskWatcher, but timing gaps can let events
|
||||
# slip through. This catch-all filter checks at dispatch time.
|
||||
@@ -1232,9 +1300,13 @@ class NotificationManager:
|
||||
|
||||
severity = event.severity
|
||||
event.data['severity'] = severity
|
||||
rendered = render_template(event.event_type, event.data)
|
||||
notification_language = self._notification_language()
|
||||
rendered = render_template(
|
||||
event.event_type, event.data, language=notification_language,
|
||||
)
|
||||
|
||||
enriched_data = dict(event.data)
|
||||
enriched_data['_notification_language'] = notification_language
|
||||
enriched_data['_rendered_fields'] = rendered.get('fields', [])
|
||||
enriched_data['_body_html'] = rendered.get('body_html', '')
|
||||
enriched_data['_event_type'] = event.event_type
|
||||
@@ -1376,27 +1448,32 @@ class NotificationManager:
|
||||
# raw template-formatted notification. Audit Tier 6 —
|
||||
# `_dispatch_to_channels`: AI failure dropped the notification.
|
||||
try:
|
||||
enriched_context = enrich_context_for_ai(
|
||||
title=ch_title,
|
||||
body=ch_body,
|
||||
event_type=event_type,
|
||||
data=data,
|
||||
journal_context=raw_journal_context,
|
||||
detail_level=detail_level
|
||||
)
|
||||
# Backup reports are authoritative inventories. A model can
|
||||
# neither be trusted to avoid repeating all guest rows nor to
|
||||
# preserve every value, so these two events bypass AI entirely.
|
||||
ai_result = None
|
||||
if not _should_bypass_ai(event_type):
|
||||
enriched_context = enrich_context_for_ai(
|
||||
title=ch_title,
|
||||
body=ch_body,
|
||||
event_type=event_type,
|
||||
data=data,
|
||||
journal_context=raw_journal_context,
|
||||
detail_level=detail_level
|
||||
)
|
||||
|
||||
# Wrap the AI rewrite with a hard timeout so a slow Ollama
|
||||
# call (90-120 s on slow CPUs) doesn't stall the dispatch
|
||||
# thread and delay every other queued event. On timeout we
|
||||
# ship the non-AI title/body — the user still gets the
|
||||
# notification, just without LLM polish. Audit Tier 3.2 #2.
|
||||
ai_result = _format_with_ai_bounded(
|
||||
format_with_ai_full,
|
||||
ch_title, ch_body, severity, channel_ai_config,
|
||||
detail_level=detail_level,
|
||||
journal_context=enriched_context,
|
||||
use_emojis=use_rich_format,
|
||||
)
|
||||
# Wrap the AI rewrite with a hard timeout so a slow Ollama
|
||||
# call (90-120 s on slow CPUs) doesn't stall the dispatch
|
||||
# thread and delay every other queued event. On timeout we
|
||||
# ship the non-AI title/body — the user still gets the
|
||||
# notification, just without LLM polish. Audit Tier 3.2 #2.
|
||||
ai_result = _format_with_ai_bounded(
|
||||
format_with_ai_full,
|
||||
ch_title, ch_body, severity, channel_ai_config,
|
||||
detail_level=detail_level,
|
||||
journal_context=enriched_context,
|
||||
use_emojis=use_rich_format,
|
||||
)
|
||||
if ai_result is not None:
|
||||
ch_title = ai_result.get('title', ch_title)
|
||||
ch_body = ai_result.get('body', ch_body)
|
||||
@@ -1525,6 +1602,10 @@ class NotificationManager:
|
||||
'vm_fail', 'ct_fail',
|
||||
'system_shutdown', 'system_reboot',
|
||||
})
|
||||
# A task completion can be observed twice through adjacent collectors.
|
||||
# Keep the daily digest useful by coalescing only byte-for-byte identical
|
||||
# buffered INFO entries that arrive close together.
|
||||
_DIGEST_DUPLICATE_WINDOW = 300 # seconds
|
||||
|
||||
def _should_buffer_for_digest(self, ch_name: str, severity: str,
|
||||
event_type: str) -> bool:
|
||||
@@ -1555,12 +1636,42 @@ class NotificationManager:
|
||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||
conn.execute('PRAGMA journal_mode=WAL')
|
||||
conn.execute('PRAGMA busy_timeout=5000')
|
||||
# Adjacent collectors can observe one completed LXC update with
|
||||
# different transport details (for example source or duration).
|
||||
# The rendered title already identifies its LXC and result, so
|
||||
# coalesce that narrowly. Other event types retain the stricter
|
||||
# title-and-body comparison so distinct updates stay visible.
|
||||
now = int(time.time())
|
||||
if event_type == 'lxc_update_applied':
|
||||
duplicate = conn.execute(
|
||||
'SELECT 1 FROM digest_pending '
|
||||
'WHERE channel = ? AND event_type = ? AND event_group = ? '
|
||||
'AND severity = ? AND title = ? AND ts >= ? LIMIT 1',
|
||||
(
|
||||
ch_name, event_type, event_group, severity, title,
|
||||
now - self._DIGEST_DUPLICATE_WINDOW,
|
||||
),
|
||||
).fetchone()
|
||||
else:
|
||||
duplicate = conn.execute(
|
||||
'SELECT 1 FROM digest_pending '
|
||||
'WHERE channel = ? AND event_type = ? AND event_group = ? '
|
||||
'AND severity = ? AND title = ? AND body = ? AND ts >= ? '
|
||||
'LIMIT 1',
|
||||
(
|
||||
ch_name, event_type, event_group, severity, title, body,
|
||||
now - self._DIGEST_DUPLICATE_WINDOW,
|
||||
),
|
||||
).fetchone()
|
||||
if duplicate:
|
||||
conn.close()
|
||||
return
|
||||
conn.execute(
|
||||
'INSERT INTO digest_pending '
|
||||
'(channel, event_type, event_group, severity, ts, title, body) '
|
||||
'VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
(ch_name, event_type, event_group, severity,
|
||||
int(time.time()), title, body),
|
||||
now, title, body),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
@@ -1618,9 +1729,14 @@ class NotificationManager:
|
||||
(issue #233).
|
||||
"""
|
||||
host = _resolve_display_hostname(self._config)
|
||||
summary_title = (
|
||||
f"{host}: 24h summary ({now.strftime('%Y-%m-%d %H:%M')})"
|
||||
language = self._notification_language()
|
||||
summary_title = runtime_message(
|
||||
'digest.title', language, hostname=host,
|
||||
timestamp=now.strftime('%Y-%m-%d %H:%M'),
|
||||
)
|
||||
rich_format = self._config.get(f'{ch_name}.rich_format', 'false') == 'true'
|
||||
if rich_format:
|
||||
summary_title = f'📋 {summary_title}'
|
||||
|
||||
try:
|
||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||
@@ -1637,7 +1753,7 @@ class NotificationManager:
|
||||
print(f"[NotificationManager] digest read failed for {ch_name}: {e}")
|
||||
self._record_history(
|
||||
'digest', ch_name, summary_title,
|
||||
f'digest read failed: {e}', 'INFO',
|
||||
runtime_message('digest.readFailed', language, error=e), 'INFO',
|
||||
False, str(e), 'digest_scheduler',
|
||||
)
|
||||
self._stats['total_errors'] += 1
|
||||
@@ -1655,17 +1771,18 @@ class NotificationManager:
|
||||
# just nothing INFO non-exempt to summarize.
|
||||
self._record_history(
|
||||
'digest', ch_name, summary_title,
|
||||
'No INFO events buffered for this digest window.',
|
||||
runtime_message('digest.empty', language),
|
||||
'INFO', True, '', 'digest_scheduler',
|
||||
)
|
||||
return
|
||||
|
||||
summary_body = self._compose_digest_body(rows)
|
||||
summary_body = self._compose_digest_body(rows, use_icons=rich_format)
|
||||
|
||||
result: dict = {'success': False, 'error': ''}
|
||||
try:
|
||||
result = channel.send(summary_title, summary_body, severity='INFO',
|
||||
data={'_digest': True, '_count': len(rows)}) or result
|
||||
data={'_digest': True, '_count': len(rows),
|
||||
'_notification_language': language}) or result
|
||||
except Exception as e:
|
||||
print(f"[NotificationManager] digest send failed for "
|
||||
f"{ch_name}: {e}")
|
||||
@@ -1703,7 +1820,7 @@ class NotificationManager:
|
||||
print(f"[NotificationManager] digest cleanup failed for "
|
||||
f"{ch_name}: {e}")
|
||||
|
||||
def _compose_digest_body(self, rows: list) -> str:
|
||||
def _compose_digest_body(self, rows: list, use_icons: bool = False) -> str:
|
||||
"""Render a grouped summary body. rows is a list of
|
||||
(id, event_type, event_group, ts, title, body) tuples ordered
|
||||
by timestamp ASC.
|
||||
@@ -1714,20 +1831,25 @@ class NotificationManager:
|
||||
label = group or 'other'
|
||||
groups.setdefault(label, []).append((ts, ev_type, title))
|
||||
|
||||
lines = [f"{len(rows)} INFO events grouped by category:\n"]
|
||||
language = self._notification_language()
|
||||
lines = [runtime_message('digest.lead', language, count=len(rows))]
|
||||
for group, items in groups.items():
|
||||
lines.append(f"{group.title()}: {len(items)}")
|
||||
group_label = runtime_message(f'digest.groups.{group}', language) or group.title()
|
||||
group_icon = CATEGORY_EMOJI.get(group, '') if use_icons else ''
|
||||
group_prefix = f'{group_icon} ' if group_icon else ''
|
||||
lines.append(f"{group_prefix}{group_label}: {len(items)}")
|
||||
for ts, ev_type, title in items[:8]:
|
||||
hhmm = datetime.fromtimestamp(ts).strftime('%H:%M')
|
||||
short_title = title.split(': ', 1)[-1] if ': ' in title else title
|
||||
lines.append(f" • {hhmm} {short_title}")
|
||||
event_icon = (
|
||||
EVENT_EMOJI.get(ev_type) or CATEGORY_EMOJI.get(group, '')
|
||||
) if use_icons else ''
|
||||
event_prefix = f'{event_icon} ' if event_icon else ''
|
||||
lines.append(f" • {event_prefix}{hhmm} {short_title}")
|
||||
if len(items) > 8:
|
||||
lines.append(f" • … and {len(items) - 8} more")
|
||||
lines.append(runtime_message('digest.more', language, count=len(items) - 8))
|
||||
lines.append('')
|
||||
lines.append(
|
||||
'(Critical/Warning events arrived at the time they happened, '
|
||||
'not in this digest.)'
|
||||
)
|
||||
lines.append(runtime_message('digest.footer', language))
|
||||
return '\n'.join(lines).rstrip() + '\n'
|
||||
|
||||
# ─── Quiet Hours buffer + flush ────────────────────────────
|
||||
@@ -1857,16 +1979,19 @@ class NotificationManager:
|
||||
return
|
||||
|
||||
host = _resolve_display_hostname(self._config)
|
||||
summary_title = (
|
||||
f"{host}: {len(rows)} events buffered during Quiet Hours"
|
||||
language = self._notification_language()
|
||||
summary_title = runtime_message(
|
||||
'digest.quietTitle', language, hostname=host, count=len(rows),
|
||||
)
|
||||
summary_body = self._compose_digest_body(rows)
|
||||
use_icons = self._config.get(f'{ch_name}.rich_format', 'false') == 'true'
|
||||
summary_body = self._compose_digest_body(rows, use_icons=use_icons)
|
||||
|
||||
result: dict = {'success': False, 'error': ''}
|
||||
try:
|
||||
result = channel.send(
|
||||
summary_title, summary_body, severity='INFO',
|
||||
data={'_quiet_hours_summary': True, '_count': len(rows)},
|
||||
data={'_quiet_hours_summary': True, '_count': len(rows),
|
||||
'_notification_language': language},
|
||||
) or result
|
||||
except Exception as e:
|
||||
print(f"[NotificationManager] quiet send failed for "
|
||||
@@ -2336,12 +2461,23 @@ class NotificationManager:
|
||||
'skipped': True,
|
||||
}
|
||||
|
||||
runtime_data = dict(data or {})
|
||||
runtime_data['hostname'] = resolve_notification_hostname(
|
||||
runtime_data.get('hostname'), self._config,
|
||||
)
|
||||
runtime_data.setdefault('_notification_language', self._notification_language())
|
||||
|
||||
# Render template if available
|
||||
if event_type in TEMPLATES and not message:
|
||||
rendered = render_template(event_type, data or {})
|
||||
rendered = render_template(
|
||||
event_type, runtime_data,
|
||||
language=runtime_data['_notification_language'],
|
||||
)
|
||||
title = title or rendered['title']
|
||||
message = rendered['body']
|
||||
severity = severity or rendered['severity']
|
||||
|
||||
data = runtime_data
|
||||
|
||||
# AI config for enhancement
|
||||
ai_config = self._build_ai_config()
|
||||
@@ -2364,13 +2500,16 @@ class NotificationManager:
|
||||
|
||||
# Pass channel_type so AI knows whether to append original (email only)
|
||||
channel_ai_config = {**ai_config, 'channel_type': ch_name}
|
||||
ai_result = format_with_ai_full(
|
||||
title, message, severity, channel_ai_config,
|
||||
detail_level=detail_level,
|
||||
use_emojis=use_rich_format
|
||||
)
|
||||
ch_title = ai_result.get('title', title)
|
||||
ch_message = ai_result.get('body', message)
|
||||
if _should_bypass_ai(event_type):
|
||||
ch_title, ch_message = title, message
|
||||
else:
|
||||
ai_result = format_with_ai_full(
|
||||
title, message, severity, channel_ai_config,
|
||||
detail_level=detail_level,
|
||||
use_emojis=use_rich_format
|
||||
)
|
||||
ch_title = ai_result.get('title', title)
|
||||
ch_message = ai_result.get('body', message)
|
||||
|
||||
result = channel.send(ch_title, ch_message, severity, data)
|
||||
results[ch_name] = result
|
||||
@@ -2403,6 +2542,32 @@ class NotificationManager:
|
||||
return self.send_notification(
|
||||
'custom', severity, title, message, source=source
|
||||
)
|
||||
|
||||
def _build_test_message(self, use_rich_format: bool, ai_enabled: bool,
|
||||
ai_info: str) -> tuple:
|
||||
"""Build the channel test payload in the selected notification language."""
|
||||
language = self._notification_language()
|
||||
icon_key = 'test.iconsEnabled' if use_rich_format else 'test.iconsDisabled'
|
||||
ai_key = 'test.aiEnabled' if ai_enabled else 'test.aiDisabled'
|
||||
icon_status = runtime_message(icon_key, language)
|
||||
ai_status = runtime_message(ai_key, language, info=ai_info)
|
||||
if use_rich_format:
|
||||
icon_status = f'✅ {icon_status}'
|
||||
ai_status = f'✅ {ai_status}' if ai_enabled else f'❌ {ai_status}'
|
||||
body = '\n\n'.join([
|
||||
runtime_message('test.welcome', language),
|
||||
runtime_message('test.verify', language),
|
||||
'\n'.join([
|
||||
runtime_message('test.configuration', language),
|
||||
icon_status,
|
||||
ai_status,
|
||||
]),
|
||||
runtime_message('test.alerts', language),
|
||||
])
|
||||
return (
|
||||
runtime_message('test.title', language), body,
|
||||
runtime_message('test.photoCaption', language),
|
||||
)
|
||||
|
||||
def test_channel(self, channel_name: str = 'all') -> Dict[str, Any]:
|
||||
"""Test one or all configured channels with AI enhancement."""
|
||||
@@ -2448,7 +2613,6 @@ class NotificationManager:
|
||||
|
||||
# ProxMenux logo for welcome message
|
||||
logo_url = 'https://proxmenux.com/telegram.png'
|
||||
logo_caption = 'You can use this image as the profile photo for your notification bot.'
|
||||
|
||||
for ch_name, channel in targets.items():
|
||||
try:
|
||||
@@ -2459,25 +2623,8 @@ class NotificationManager:
|
||||
rich_key = f'{ch_name}.rich_format'
|
||||
use_rich_format = self._config.get(rich_key, 'false') == 'true'
|
||||
|
||||
# Build status indicators for icons and AI, adapted to channel format
|
||||
if use_rich_format:
|
||||
icon_status = '✅ Icons: enabled'
|
||||
ai_status = f'✅ AI: enabled ({ai_info})' if ai_enabled else '❌ AI: disabled'
|
||||
else:
|
||||
icon_status = 'Icons: disabled'
|
||||
ai_status = f'AI: enabled ({ai_info})' if ai_enabled else 'AI: disabled'
|
||||
|
||||
# Base test message — shows current channel config
|
||||
# NOTE: narrative lines are intentionally unlabeled so the AI
|
||||
# does not prepend "Message:" or other spurious field labels.
|
||||
base_title = 'ProxMenux Test'
|
||||
base_message = (
|
||||
'Welcome to ProxMenux Monitor!\n\n'
|
||||
'This is a test message to verify your notification channel is working correctly.\n\n'
|
||||
'Channel configuration:\n'
|
||||
f'{icon_status}\n'
|
||||
f'{ai_status}\n\n'
|
||||
'You will receive alerts about system events, health status changes, and security incidents.'
|
||||
base_title, base_message, logo_caption = self._build_test_message(
|
||||
use_rich_format, ai_enabled, ai_info,
|
||||
)
|
||||
|
||||
# Apply AI enhancement (translates to configured language)
|
||||
@@ -2492,7 +2639,11 @@ class NotificationManager:
|
||||
enhanced_message = ai_result.get('body', base_message)
|
||||
|
||||
# Send message
|
||||
send_result = channel.send(enhanced_title, enhanced_message, 'INFO')
|
||||
send_result = channel.send(
|
||||
enhanced_title, enhanced_message, 'INFO',
|
||||
data={'_notification_language': self._notification_language(),
|
||||
'_event_type': 'test', '_group': 'other'},
|
||||
)
|
||||
success = send_result.get('success', False)
|
||||
error = send_result.get('error', '')
|
||||
|
||||
@@ -2865,6 +3016,7 @@ class NotificationManager:
|
||||
'ai_api_keys': ai_api_keys,
|
||||
'ai_models': ai_models,
|
||||
'ai_model': self._active_ai_model(current_provider),
|
||||
'notification_language': self._notification_language(),
|
||||
'ai_language': self._config.get('ai_language', 'en'),
|
||||
'ai_ollama_url': self._config.get('ai_ollama_url', 'http://localhost:11434'),
|
||||
'ai_openai_base_url': self._config.get('ai_openai_base_url', ''),
|
||||
@@ -2888,6 +3040,7 @@ class NotificationManager:
|
||||
def save_settings(self, settings: Dict[str, str]) -> Dict[str, Any]:
|
||||
"""Save multiple notification settings at once."""
|
||||
try:
|
||||
previous_config = dict(self._config)
|
||||
conn = sqlite3.connect(str(DB_PATH), timeout=10)
|
||||
conn.execute('PRAGMA journal_mode=WAL')
|
||||
conn.execute('PRAGMA busy_timeout=5000')
|
||||
@@ -2944,6 +3097,11 @@ class NotificationManager:
|
||||
if short_key == 'ai_language':
|
||||
if str(value) not in _ALLOWED_AI_LANGUAGES:
|
||||
raise ValueError(f"Invalid ai_language: must be one of {_ALLOWED_AI_LANGUAGES}")
|
||||
if short_key == 'notification_language':
|
||||
if str(value) not in RUNTIME_NOTIFICATION_LANGUAGES:
|
||||
raise ValueError(
|
||||
f"Invalid notification_language: must be one of {RUNTIME_NOTIFICATION_LANGUAGES}"
|
||||
)
|
||||
|
||||
# Encrypt sensitive values before storing. Skip if the value is
|
||||
# already in either encrypted form — `encrypt_sensitive_value`
|
||||
@@ -2975,6 +3133,38 @@ class NotificationManager:
|
||||
VALUES (?, ?, ?)
|
||||
''', (marker_key, 'true', now))
|
||||
self._config[f'event_explicit.{event_type}'] = 'true'
|
||||
|
||||
# A digest time can be changed after today's digest has already
|
||||
# been sent. Retaining digest_last_at in that case silently
|
||||
# makes the newly selected, still-future time wait until tomorrow.
|
||||
# Reset the guard only for a genuine enable/time change to a later
|
||||
# time today; ordinary saves and past times remain rate-limited.
|
||||
now = datetime.now()
|
||||
current_minute = now.hour * 60 + now.minute
|
||||
for ch_type in CHANNEL_TYPES:
|
||||
enabled_key = f'{ch_type}.digest_enabled'
|
||||
time_key = f'{ch_type}.digest_time'
|
||||
last_key = f'{ch_type}.digest_last_at'
|
||||
if self._config.get(enabled_key, 'false') != 'true':
|
||||
continue
|
||||
changed = (
|
||||
previous_config.get(enabled_key, 'false') != 'true'
|
||||
or previous_config.get(time_key, '09:00') != self._config.get(time_key, '09:00')
|
||||
)
|
||||
if not changed:
|
||||
continue
|
||||
try:
|
||||
hour, minute = (int(part) for part in self._config.get(time_key, '09:00').split(':', 1))
|
||||
target_minute = hour * 60 + minute
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
if not (current_minute < target_minute < 24 * 60):
|
||||
continue
|
||||
cursor.execute('''
|
||||
INSERT OR REPLACE INTO user_settings (setting_key, setting_value, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
''', (f'{SETTINGS_PREFIX}{last_key}', '', now.isoformat()))
|
||||
self._config[last_key] = ''
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
@@ -17,9 +17,193 @@ import socket
|
||||
import time
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Dict, Any, Optional, List, Tuple
|
||||
|
||||
|
||||
_SCRIPT_ROOT = Path(__file__).resolve().parents[1]
|
||||
_BUNDLED_CATALOG_DIR = _SCRIPT_ROOT / 'share' / 'proxmenux' / 'messages'
|
||||
_SOURCE_CATALOG_DIR = _SCRIPT_ROOT / 'messages'
|
||||
RUNTIME_CATALOG_DIR = (
|
||||
_BUNDLED_CATALOG_DIR
|
||||
if _BUNDLED_CATALOG_DIR.is_dir()
|
||||
else _SOURCE_CATALOG_DIR
|
||||
if _SOURCE_CATALOG_DIR.is_dir()
|
||||
else Path('/usr/share/proxmenux/messages')
|
||||
)
|
||||
|
||||
|
||||
class _SafeFormatDict(dict):
|
||||
def __missing__(self, key):
|
||||
return ''
|
||||
|
||||
|
||||
@lru_cache(maxsize=8)
|
||||
def _load_runtime_catalog(language: str) -> Dict[str, Any]:
|
||||
"""Load one existing Monitor catalog's runtime notification namespace."""
|
||||
path = RUNTIME_CATALOG_DIR / language / 'common.json'
|
||||
try:
|
||||
with path.open(encoding='utf-8') as handle:
|
||||
return json.load(handle).get('runtime', {}).get('notifications', {})
|
||||
except (OSError, ValueError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _catalog_value(catalog: Dict[str, Any], dotted_key: str) -> Optional[str]:
|
||||
value: Any = catalog
|
||||
for part in dotted_key.split('.'):
|
||||
if not isinstance(value, dict) or part not in value:
|
||||
return None
|
||||
value = value[part]
|
||||
return value if isinstance(value, str) and value else None
|
||||
|
||||
|
||||
def runtime_message(key: str, language: str = 'en', **values: Any) -> str:
|
||||
"""Resolve runtime text with per-key English fallback and safe placeholders."""
|
||||
requested = (language or 'en').split('-', 1)[0].lower()
|
||||
value = _catalog_value(_load_runtime_catalog(requested), key)
|
||||
if value is None:
|
||||
value = _catalog_value(_load_runtime_catalog('en'), key)
|
||||
if value is None:
|
||||
return ''
|
||||
try:
|
||||
return value.format_map(_SafeFormatDict(values))
|
||||
except (ValueError, IndexError):
|
||||
return value
|
||||
|
||||
|
||||
def _format_lxc_update_details(data: Dict[str, Any], language: str) -> str:
|
||||
"""Render an LXC update outcome from structured data in the selected locale."""
|
||||
update = data.get('lxc_update')
|
||||
if not isinstance(update, dict):
|
||||
return str(data.get('details') or '')
|
||||
|
||||
def _mapping(value: Any) -> Dict[str, Any]:
|
||||
return value if isinstance(value, dict) else {}
|
||||
|
||||
def _items(value: Any) -> List[str]:
|
||||
return [str(item) for item in value if str(item)] if isinstance(value, list) else []
|
||||
|
||||
source = 'scheduled' if update.get('source') == 'scheduled' else 'manual'
|
||||
labels = _items(update.get('labels')) or _items(update.get('targets'))
|
||||
lines = [
|
||||
runtime_message('lxcUpdate.sourceLabel', language)
|
||||
+ ': ' + runtime_message(f'lxcUpdate.source.{source}', language),
|
||||
runtime_message('lxcUpdate.targets', language) + ': ' + ', '.join(labels),
|
||||
]
|
||||
|
||||
targets = _items(update.get('targets'))
|
||||
before = _mapping(update.get('before'))
|
||||
after = _mapping(update.get('after'))
|
||||
if 'os' in targets:
|
||||
before_count = before.get('os_pending')
|
||||
after_count = after.get('os_pending')
|
||||
if isinstance(before_count, int) and isinstance(after_count, int):
|
||||
lines.append(runtime_message(
|
||||
'lxcUpdate.osPending', language, before=before_count, after=after_count,
|
||||
))
|
||||
else:
|
||||
lines.append(runtime_message('lxcUpdate.osUnverified', language))
|
||||
|
||||
before_apps = _mapping(before.get('apps'))
|
||||
after_apps = _mapping(after.get('apps'))
|
||||
app_ids = {
|
||||
target.split(':', 1)[1]
|
||||
for target in targets if target.startswith('app:')
|
||||
}
|
||||
if 'apps' in targets:
|
||||
app_ids.update(str(app_id) for app_id in before_apps)
|
||||
app_ids.update(str(app_id) for app_id in after_apps)
|
||||
app_lines = []
|
||||
for app_id in sorted(app_ids):
|
||||
old = _mapping(before_apps.get(app_id))
|
||||
new = _mapping(after_apps.get(app_id))
|
||||
old_version = old.get('installed_version')
|
||||
new_version = new.get('installed_version')
|
||||
if old_version and new_version and old_version != new_version:
|
||||
app_lines.append(
|
||||
f"{new.get('name') or old.get('name') or app_id}: "
|
||||
f'{old_version} → {new_version}'
|
||||
)
|
||||
if app_lines:
|
||||
lines.append(runtime_message(
|
||||
'lxcUpdate.applications', language, items='; '.join(app_lines[:8]),
|
||||
))
|
||||
elif app_ids:
|
||||
lines.append(runtime_message('lxcUpdate.applicationsUnverified', language))
|
||||
|
||||
docker_requested = any(target.startswith('docker-') for target in targets)
|
||||
before_docker = _mapping(before.get('docker_inventory'))
|
||||
after_docker = _mapping(after.get('docker_inventory'))
|
||||
if 'docker-engine' in targets:
|
||||
old_engine = before_docker.get('engine_version')
|
||||
new_engine = after_docker.get('engine_version')
|
||||
if old_engine and new_engine and old_engine != new_engine:
|
||||
lines.append(runtime_message(
|
||||
'lxcUpdate.dockerEngineChange', language, before=old_engine, after=new_engine,
|
||||
))
|
||||
elif new_engine:
|
||||
lines.append(runtime_message(
|
||||
'lxcUpdate.dockerEngineVerified', language, version=new_engine,
|
||||
))
|
||||
else:
|
||||
lines.append(runtime_message('lxcUpdate.dockerEngineUnverified', language))
|
||||
if docker_requested and any(target != 'docker-engine' for target in targets):
|
||||
before_pending = before_docker.get('update_count')
|
||||
after_pending = after_docker.get('update_count')
|
||||
if isinstance(before_pending, int) and isinstance(after_pending, int):
|
||||
lines.append(runtime_message(
|
||||
'lxcUpdate.dockerImagesPending', language,
|
||||
before=before_pending, after=after_pending,
|
||||
))
|
||||
after_by_ref = {
|
||||
str(item.get('reference')): item
|
||||
for item in after_docker.get('images') or []
|
||||
if isinstance(item, dict) and item.get('reference')
|
||||
}
|
||||
changed_images = []
|
||||
for old_image in before_docker.get('images') or []:
|
||||
if not isinstance(old_image, dict):
|
||||
continue
|
||||
reference = str(old_image.get('reference') or '')
|
||||
new_image = _mapping(after_by_ref.get(reference))
|
||||
if reference and old_image.get('local_digest') != new_image.get('local_digest'):
|
||||
changed_images.append(reference)
|
||||
if changed_images:
|
||||
lines.append(runtime_message(
|
||||
'lxcUpdate.dockerImagesChanged', language,
|
||||
images=', '.join(changed_images[:8]),
|
||||
))
|
||||
|
||||
deferred = _items(update.get('deferred_targets'))
|
||||
if deferred:
|
||||
lines.append(runtime_message(
|
||||
'lxcUpdate.deferredTargets', language, targets=', '.join(deferred),
|
||||
))
|
||||
reason = str(update.get('reason') or '').strip()
|
||||
if reason:
|
||||
lines.append(runtime_message('lxcUpdate.reason', language, reason=reason))
|
||||
reboot_required = update.get('reboot_required')
|
||||
if reboot_required is not None:
|
||||
value = runtime_message(
|
||||
'lxcUpdate.yes' if reboot_required else 'lxcUpdate.no', language,
|
||||
)
|
||||
lines.append(runtime_message(
|
||||
'lxcUpdate.restartRequired', language, value=value,
|
||||
))
|
||||
if update.get('verification_pending'):
|
||||
lines.append(runtime_message('lxcUpdate.verificationPending', language))
|
||||
for error in _items(update.get('verification_errors'))[:4]:
|
||||
lines.append(runtime_message(
|
||||
'lxcUpdate.verificationWarning', language, error=error,
|
||||
))
|
||||
lines.append(runtime_message(
|
||||
'lxcUpdate.duration', language, duration=update.get('duration') or '',
|
||||
))
|
||||
return '\n'.join(lines)
|
||||
|
||||
|
||||
# ─── vzdump message parser ───────────────────────────────────────
|
||||
|
||||
def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -248,7 +432,8 @@ def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]:
|
||||
}
|
||||
|
||||
|
||||
def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool) -> str:
|
||||
def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool,
|
||||
language: str = 'en') -> str:
|
||||
"""Format parsed vzdump data into a clean Telegram-friendly message."""
|
||||
parts = []
|
||||
|
||||
@@ -285,9 +470,9 @@ def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool) -> str:
|
||||
# Size and Duration on same line with icons
|
||||
detail_line = []
|
||||
if vm.get('size'):
|
||||
detail_line.append(f"\U0001F4CF Size: {vm['size']}")
|
||||
detail_line.append(f"\U0001F4CF {runtime_message('vzdump.size', language, value=vm['size'])}")
|
||||
if vm.get('time'):
|
||||
detail_line.append(f"\u23F1\uFE0F Duration: {vm['time']}")
|
||||
detail_line.append(f"\u23F1\uFE0F {runtime_message('vzdump.duration', language, value=vm['time'])}")
|
||||
if detail_line:
|
||||
parts.append(' | '.join(detail_line))
|
||||
|
||||
@@ -302,7 +487,7 @@ def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool) -> str:
|
||||
label = storage_name if storage_name else 'PBS'
|
||||
parts.append(f"\U0001F5C4\uFE0F {label}: {fname}")
|
||||
else:
|
||||
label = storage_name if storage_name else 'File'
|
||||
label = storage_name if storage_name else runtime_message('vzdump.file', language)
|
||||
parts.append(f"\U0001F4C1 {label}: {fname}")
|
||||
|
||||
# Error reason if failed
|
||||
@@ -320,13 +505,13 @@ def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool) -> str:
|
||||
|
||||
summary_parts = []
|
||||
if vm_count:
|
||||
summary_parts.append(f"\U0001F4CA {vm_count} backups")
|
||||
summary_parts.append(f"\U0001F4CA {runtime_message('vzdump.backups', language, count=vm_count)}")
|
||||
if fail_count:
|
||||
summary_parts.append(f"\u274C {fail_count} failed")
|
||||
summary_parts.append(f"\u274C {runtime_message('vzdump.failed', language, count=fail_count)}")
|
||||
if parsed.get('total_size'):
|
||||
summary_parts.append(f"\U0001F4E6 Total: {parsed['total_size']}")
|
||||
summary_parts.append(f"\U0001F4E6 {runtime_message('vzdump.total', language, value=parsed['total_size'])}")
|
||||
if parsed.get('total_time'):
|
||||
summary_parts.append(f"\u23F1\uFE0F Time: {parsed['total_time']}")
|
||||
summary_parts.append(f"\u23F1\uFE0F {runtime_message('vzdump.time', language, value=parsed['total_time'])}")
|
||||
|
||||
if summary_parts:
|
||||
parts.append(' | '.join(summary_parts))
|
||||
@@ -334,104 +519,92 @@ def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool) -> str:
|
||||
return '\n'.join(parts)
|
||||
|
||||
|
||||
def _format_system_startup(data: Dict[str, Any]) -> Tuple[str, str]:
|
||||
"""
|
||||
Format comprehensive system startup report.
|
||||
|
||||
Returns (title, body) tuple for the notification.
|
||||
Handles both simple startups (all OK) and those with issues.
|
||||
"""
|
||||
def _format_system_startup(data: Dict[str, Any], language: str = 'en') -> Tuple[str, str]:
|
||||
"""Format the comprehensive startup report using runtime catalogs."""
|
||||
hostname = data.get('hostname', 'unknown')
|
||||
has_issues = data.get('has_issues', False)
|
||||
|
||||
# Build title
|
||||
if has_issues:
|
||||
total_issues = (
|
||||
data.get('total_failed', 0) +
|
||||
len(data.get('services_failed', [])) +
|
||||
len(data.get('storage_unavailable', []))
|
||||
data.get('total_failed', 0)
|
||||
+ len(data.get('services_failed', []))
|
||||
+ len(data.get('storage_unavailable', []))
|
||||
)
|
||||
title = f"{hostname}: System startup - {total_issues} issue(s) detected"
|
||||
title = runtime_message('startup.issuesTitle', language, hostname=hostname, count=total_issues)
|
||||
else:
|
||||
title = f"{hostname}: System startup completed"
|
||||
|
||||
# Build body
|
||||
title = runtime_message('startup.completeTitle', language, hostname=hostname)
|
||||
|
||||
parts = []
|
||||
|
||||
# Overall status
|
||||
if not has_issues:
|
||||
parts.append("All systems operational.")
|
||||
|
||||
# VMs/CTs started
|
||||
parts.append(runtime_message('startup.operational', language))
|
||||
|
||||
vms_ok = len(data.get('vms_started', []))
|
||||
cts_ok = len(data.get('cts_started', []))
|
||||
if vms_ok or cts_ok:
|
||||
count_parts = []
|
||||
counts = []
|
||||
if vms_ok:
|
||||
count_parts.append(f"{vms_ok} VM{'s' if vms_ok > 1 else ''}")
|
||||
key = 'startup.vmCountOne' if vms_ok == 1 else 'startup.vmCountMany'
|
||||
counts.append(runtime_message(key, language, count=vms_ok))
|
||||
if cts_ok:
|
||||
count_parts.append(f"{cts_ok} CT{'s' if cts_ok > 1 else ''}")
|
||||
|
||||
# List names (up to 5)
|
||||
names = []
|
||||
for vm in data.get('vms_started', [])[:3]:
|
||||
names.append(f"{vm['name']} ({vm['vmid']})")
|
||||
for ct in data.get('cts_started', [])[:3]:
|
||||
names.append(f"{ct['name']} ({ct['vmid']})")
|
||||
|
||||
line = f"\u2705 {' and '.join(count_parts)} started"
|
||||
key = 'startup.ctCountOne' if cts_ok == 1 else 'startup.ctCountMany'
|
||||
counts.append(runtime_message(key, language, count=cts_ok))
|
||||
names = [
|
||||
f"{item['name']} ({item['vmid']})"
|
||||
for item in (data.get('vms_started', [])[:3] + data.get('cts_started', [])[:3])
|
||||
]
|
||||
line = runtime_message('startup.started', language, counts=', '.join(counts))
|
||||
if names:
|
||||
if len(names) <= 5:
|
||||
line += f": {', '.join(names)}"
|
||||
else:
|
||||
line += f": {', '.join(names[:5])}..."
|
||||
line += f": {', '.join(names[:5])}"
|
||||
if len(names) > 5:
|
||||
line += '…'
|
||||
parts.append(line)
|
||||
|
||||
# Failed VMs/CTs
|
||||
|
||||
unknown_error = runtime_message('startup.unknownError', language)
|
||||
for vm in data.get('vms_failed', []):
|
||||
reason = vm.get('reason', 'unknown error')
|
||||
parts.append(f"\u274C VM failed: {vm['name']} - {reason}")
|
||||
|
||||
parts.append(runtime_message('startup.vmFailed', language, name=vm['name'], reason=vm.get('reason', unknown_error)))
|
||||
for ct in data.get('cts_failed', []):
|
||||
reason = ct.get('reason', 'unknown error')
|
||||
parts.append(f"\u274C CT failed: {ct['name']} - {reason}")
|
||||
|
||||
# Storage issues
|
||||
parts.append(runtime_message('startup.ctFailed', language, name=ct['name'], reason=ct.get('reason', unknown_error)))
|
||||
|
||||
storage_unavailable = data.get('storage_unavailable', [])
|
||||
if storage_unavailable:
|
||||
names = [s['name'] for s in storage_unavailable[:3]]
|
||||
parts.append(f"\u26A0\uFE0F Storage: {len(storage_unavailable)} unavailable ({', '.join(names)})")
|
||||
|
||||
# Service issues
|
||||
parts.append(runtime_message(
|
||||
'startup.storageUnavailable', language, count=len(storage_unavailable),
|
||||
names=', '.join(item['name'] for item in storage_unavailable[:3]),
|
||||
))
|
||||
services_failed = data.get('services_failed', [])
|
||||
if services_failed:
|
||||
names = [s['name'] for s in services_failed[:3]]
|
||||
parts.append(f"\u26A0\uFE0F Services: {len(services_failed)} failed ({', '.join(names)})")
|
||||
|
||||
# Startup duration
|
||||
parts.append(runtime_message(
|
||||
'startup.servicesFailed', language, count=len(services_failed),
|
||||
names=', '.join(item['name'] for item in services_failed[:3]),
|
||||
))
|
||||
duration = data.get('startup_duration_seconds', 0)
|
||||
if duration:
|
||||
minutes = int(duration // 60)
|
||||
parts.append(f"\u23F1\uFE0F Startup completed in {minutes} min")
|
||||
|
||||
body = '\n'.join(parts)
|
||||
return title, body
|
||||
parts.append(runtime_message('startup.duration', language, minutes=int(duration // 60)))
|
||||
return title, '\n'.join(parts)
|
||||
|
||||
|
||||
def _format_app_update_available(data: Dict[str, Any]) -> Tuple[str, str]:
|
||||
def _format_app_update_available(data: Dict[str, Any],
|
||||
language: str = 'en') -> Tuple[str, str]:
|
||||
"""Render one app update or a scheduled multi-app summary."""
|
||||
hostname = str(data.get("hostname") or _get_hostname())
|
||||
updates = data.get("updates")
|
||||
hostname = str(data.get('hostname') or _get_hostname())
|
||||
app_fallback = runtime_message('appUpdates.app', language) or 'app'
|
||||
unknown = runtime_message('appUpdates.unknown', language) or 'unknown'
|
||||
updates = data.get('updates')
|
||||
if not isinstance(updates, list) or len(updates) < 2:
|
||||
app_name = str(data.get("app_name") or "app")
|
||||
vmid = data.get("vmid", "")
|
||||
ct_name = str(data.get("ct_name") or f"CT-{vmid}")
|
||||
installed = str(data.get("installed") or "unknown")
|
||||
latest = str(data.get("latest") or "unknown")
|
||||
app_name = str(data.get('app_name') or app_fallback)
|
||||
vmid = data.get('vmid', '')
|
||||
ct_name = str(data.get('ct_name') or f'CT-{vmid}')
|
||||
installed = str(data.get('installed') or unknown)
|
||||
latest = str(data.get('latest') or unknown)
|
||||
return (
|
||||
f"{hostname}: {app_name} update available on CT {vmid}",
|
||||
f"{app_name} on CT {vmid} ({ct_name}) has a new version:\n"
|
||||
f" {installed} → {latest}",
|
||||
runtime_message(
|
||||
'appUpdates.singleTitle', language,
|
||||
hostname=hostname, app_name=app_name, vmid=vmid,
|
||||
),
|
||||
runtime_message(
|
||||
'appUpdates.singleBody', language,
|
||||
app_name=app_name, vmid=vmid, ct_name=ct_name,
|
||||
installed=installed, latest=latest,
|
||||
),
|
||||
)
|
||||
|
||||
clean_updates = []
|
||||
@@ -443,26 +616,31 @@ def _format_app_update_available(data: Dict[str, Any]) -> Tuple[str, str]:
|
||||
except (TypeError, ValueError):
|
||||
continue
|
||||
clean_updates.append({
|
||||
"vmid": vmid,
|
||||
"app_name": str(item.get("app_name") or "app"),
|
||||
"installed": str(item.get("installed") or "unknown"),
|
||||
"latest": str(item.get("latest") or "unknown"),
|
||||
'vmid': vmid,
|
||||
'app_name': str(item.get('app_name') or app_fallback),
|
||||
'installed': str(item.get('installed') or unknown),
|
||||
'latest': str(item.get('latest') or unknown),
|
||||
})
|
||||
clean_updates.sort(
|
||||
key=lambda item: (item["vmid"], item["app_name"].casefold())
|
||||
)
|
||||
if not clean_updates:
|
||||
return (
|
||||
f"{hostname}: Application updates available",
|
||||
"Application updates are available.",
|
||||
runtime_message('appUpdates.emptyTitle', language, hostname=hostname),
|
||||
runtime_message('appUpdates.emptyBody', language),
|
||||
)
|
||||
|
||||
count = len(clean_updates)
|
||||
container_count = len({item["vmid"] for item in clean_updates})
|
||||
title = f"{hostname}: {count} application updates available"
|
||||
lead = (
|
||||
f"{count} applications in {container_count} LXC "
|
||||
f"container{'s' if container_count != 1 else ''} have a newer version:"
|
||||
title = runtime_message(
|
||||
'appUpdates.batchTitle', language, hostname=hostname, count=count,
|
||||
)
|
||||
lead_key = (
|
||||
'appUpdates.batchLeadOneContainer'
|
||||
if container_count == 1 else 'appUpdates.batchLeadManyContainers'
|
||||
)
|
||||
lead = runtime_message(
|
||||
lead_key, language, count=count, container_count=container_count,
|
||||
)
|
||||
sections = []
|
||||
omitted = 0
|
||||
@@ -481,10 +659,79 @@ def _format_app_update_available(data: Dict[str, Any]) -> Tuple[str, str]:
|
||||
continue
|
||||
sections.append("\n".join(section))
|
||||
if omitted:
|
||||
sections.append(f"… {omitted} additional application(s)")
|
||||
sections.append(runtime_message('appUpdates.additional', language, count=omitted))
|
||||
return title, "\n\n".join([lead, *sections])
|
||||
|
||||
|
||||
_CPU_SUSTAINED_REASON = re.compile(
|
||||
r'^CPU >(?P<threshold>[0-9.]+)% sustained for (?P<duration>\d+)s$'
|
||||
)
|
||||
|
||||
|
||||
def _format_health_degraded(data: Dict[str, Any],
|
||||
language: str = 'en') -> Tuple[str, str]:
|
||||
"""Render Monitor-generated health degradations in the chosen locale."""
|
||||
payload = data.get('health_degraded')
|
||||
if not isinstance(payload, dict):
|
||||
return str(data.get('title') or ''), str(data.get('reason') or '')
|
||||
|
||||
categories = payload.get('categories')
|
||||
if not isinstance(categories, list) or not categories:
|
||||
return str(data.get('title') or ''), str(data.get('reason') or '')
|
||||
|
||||
def category_label(item: Dict[str, Any]) -> str:
|
||||
category = str(item.get('key') or '')
|
||||
return (
|
||||
runtime_message(f'healthDegraded.categories.{category}', language)
|
||||
or str(item.get('category') or category)
|
||||
)
|
||||
|
||||
def severity_label(item: Dict[str, Any]) -> str:
|
||||
severity = str(item.get('status') or data.get('severity') or 'WARNING').lower()
|
||||
return (
|
||||
runtime_message(f'healthDegraded.severity.{severity}', language)
|
||||
or str(item.get('status') or data.get('severity') or 'WARNING')
|
||||
)
|
||||
|
||||
def localized_reason(item: Dict[str, Any]) -> str:
|
||||
reason = str(item.get('reason') or '')
|
||||
if str(item.get('key') or '') == 'cpu':
|
||||
match = _CPU_SUSTAINED_REASON.fullmatch(reason)
|
||||
if match:
|
||||
return runtime_message(
|
||||
'healthDegraded.reasons.cpuSustained', language,
|
||||
threshold=match.group('threshold'), duration=match.group('duration'),
|
||||
) or reason
|
||||
return reason
|
||||
|
||||
hostname = str(data.get('hostname') or _get_hostname())
|
||||
if len(categories) == 1:
|
||||
item = categories[0] if isinstance(categories[0], dict) else {}
|
||||
title = runtime_message(
|
||||
'healthDegraded.singleTitle', language,
|
||||
hostname=hostname, severity=severity_label(item), category=category_label(item),
|
||||
)
|
||||
entity = str(item.get('entity') or '').strip()
|
||||
if entity:
|
||||
title = f'{title} — {entity}'
|
||||
return title, localized_reason(item)
|
||||
|
||||
title = runtime_message(
|
||||
'healthDegraded.multipleTitle', language,
|
||||
hostname=hostname, count=len(categories),
|
||||
)
|
||||
lines = []
|
||||
for item in categories:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
lines.append(runtime_message(
|
||||
'healthDegraded.multipleLine', language,
|
||||
severity=severity_label(item), category=category_label(item),
|
||||
reason=localized_reason(item),
|
||||
))
|
||||
return title, '\n'.join(line for line in lines if line)
|
||||
|
||||
|
||||
# ─── Severity Icons ──────────────────────────────────────────────
|
||||
|
||||
SEVERITY_ICONS = {
|
||||
@@ -557,6 +804,7 @@ TEMPLATES = {
|
||||
'label': 'Health check degraded',
|
||||
'group': 'health',
|
||||
'default_enabled': True,
|
||||
'formatter': '_format_health_degraded',
|
||||
},
|
||||
|
||||
# ── VM / CT events ──
|
||||
@@ -1522,6 +1770,8 @@ EVENT_GROUPS = {
|
||||
'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'},
|
||||
'hardware': {'label': 'Hardware', 'description': 'GPU, PCIe and hardware events'},
|
||||
'system': {'label': 'System', 'description': 'System and internal service events'},
|
||||
'other': {'label': 'Other', 'description': 'Uncategorized notifications'},
|
||||
}
|
||||
|
||||
@@ -1568,7 +1818,8 @@ def _format_bytes_human(n: Any) -> str:
|
||||
return f'{size:.1f} {units[i]}'
|
||||
|
||||
|
||||
def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
def render_template(event_type: str, data: Dict[str, Any],
|
||||
language: str = 'en') -> Dict[str, Any]:
|
||||
"""Render a template into a structured notification object.
|
||||
|
||||
Returns structured output usable by all channels:
|
||||
@@ -1576,19 +1827,35 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""
|
||||
import html as html_mod
|
||||
|
||||
template = TEMPLATES.get(event_type)
|
||||
if not template:
|
||||
source_template = TEMPLATES.get(event_type)
|
||||
if not source_template:
|
||||
# Catch-all: unknown event types always get delivered (group 'other')
|
||||
# so no Proxmox notification is ever silently dropped.
|
||||
fallback_body = data.get('message', data.get('reason', str(data)))
|
||||
severity = data.get('severity', 'INFO')
|
||||
return {
|
||||
'title': f"{_get_hostname()}: {event_type}",
|
||||
'title': runtime_message(
|
||||
'fallback.unknownTitle', language,
|
||||
hostname=_get_hostname(), event_type=event_type,
|
||||
),
|
||||
'body': fallback_body, 'body_text': fallback_body,
|
||||
'body_html': f'<p>{html_mod.escape(str(fallback_body))}</p>',
|
||||
'fields': [], 'tags': [severity, 'other', event_type],
|
||||
'severity': severity, 'group': 'other',
|
||||
}
|
||||
|
||||
template = dict(source_template)
|
||||
requested_language = (language or 'en').split('-', 1)[0].lower()
|
||||
requested_catalog = _load_runtime_catalog(requested_language)
|
||||
english_catalog = _load_runtime_catalog('en')
|
||||
for field in ('title', 'body', 'label'):
|
||||
key = f'templates.{event_type}.{field}'
|
||||
localized = (
|
||||
_catalog_value(requested_catalog, key)
|
||||
or _catalog_value(english_catalog, key)
|
||||
)
|
||||
if localized:
|
||||
template[field] = localized
|
||||
|
||||
# Ensure hostname is always available
|
||||
variables = {
|
||||
@@ -1610,7 +1877,7 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
'packages': '', 'pve_packages': '', 'version': '',
|
||||
'issue_list': '', 'error_key': '',
|
||||
'storage_name': '', 'storage_type': '',
|
||||
'important_list': 'none',
|
||||
'important_list': runtime_message('fallback.none', language),
|
||||
# Host Backup specifics (run_scheduled_backup.sh + backup_host.sh).
|
||||
'job_id': '', 'backend': '', 'backend_label': '',
|
||||
'destination': '', 'profile_mode': '',
|
||||
@@ -1619,6 +1886,13 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
}
|
||||
variables.update(data)
|
||||
|
||||
if event_type == 'lxc_update_applied' and isinstance(data.get('lxc_update'), dict):
|
||||
status = str(data['lxc_update'].get('status') or '')
|
||||
localized_status = runtime_message(f'lxcUpdate.status.{status}', language)
|
||||
if localized_status:
|
||||
variables['result'] = localized_status
|
||||
variables['details'] = _format_lxc_update_details(data, language)
|
||||
|
||||
# Humanise raw-byte fields so templates can render '35.3 GiB' instead
|
||||
# of '37952020480'. Producers keep emitting raw ints (needed by APIs
|
||||
# and the dashboard); the humanised twin is derived on the fly here.
|
||||
@@ -1626,9 +1900,10 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if _byte_key in data:
|
||||
variables[f'{_byte_key}_human'] = _format_bytes_human(data[_byte_key])
|
||||
|
||||
# Ensure important_list is never blank (fallback to 'none')
|
||||
if not variables.get('important_list', '').strip():
|
||||
variables['important_list'] = 'none'
|
||||
# Ensure important_list is never blank (fallback to localized "none")
|
||||
important_list = str(variables.get('important_list', '')).strip()
|
||||
if not important_list or important_list.casefold() == 'none':
|
||||
variables['important_list'] = runtime_message('fallback.none', language)
|
||||
|
||||
# Derive the affected object's display name for titles that use it.
|
||||
# Priority: caller-supplied `entity` (health_monitor.emit_event) →
|
||||
@@ -1657,7 +1932,8 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
_caller_title = str(variables.get('title', '')).strip()
|
||||
if not _caller_title:
|
||||
hn = variables.get('hostname', '')
|
||||
_caller_title = f'{hn}: Health check degraded' if hn else 'Health check degraded'
|
||||
degraded = runtime_message('fallback.healthCheckDegraded', language)
|
||||
_caller_title = f'{hn}: {degraded}' if hn else degraded
|
||||
variables['title_or_default'] = _caller_title
|
||||
|
||||
# `format_map` with a SafeDict avoids the KeyError → "show raw template
|
||||
@@ -1685,7 +1961,7 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
if formatter_name and formatter_name in globals():
|
||||
formatter_func = globals()[formatter_name]
|
||||
try:
|
||||
title, body_text = formatter_func(data)
|
||||
title, body_text = formatter_func(data, language=language)
|
||||
except Exception:
|
||||
# Fallback to standard formatting if formatter fails
|
||||
try:
|
||||
@@ -1696,9 +1972,10 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
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:
|
||||
body_text = _format_vzdump_body(parsed, is_success, language=language)
|
||||
# Preserve PVE's source title for English, but never leak it into a
|
||||
# deterministic localized notification.
|
||||
if pve_title and requested_language == 'en':
|
||||
title = pve_title
|
||||
else:
|
||||
# Couldn't parse -- use PVE raw message as body
|
||||
@@ -1722,15 +1999,15 @@ def render_template(event_type: str, data: Dict[str, Any]) -> Dict[str, Any]:
|
||||
# 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'),
|
||||
('vmid', 'fields.vmid'), ('vmname', 'fields.name'), ('device', 'fields.device'),
|
||||
('source_ip', 'fields.sourceIp'), ('node_name', 'fields.node'), ('category', 'fields.category'),
|
||||
('service_name', 'fields.service'), ('jail', 'fields.jail'), ('username', 'fields.user'),
|
||||
('count', 'fields.count'), ('window', 'fields.window'), ('entity_list', 'fields.affected'),
|
||||
]
|
||||
for key, label in field_map:
|
||||
for key, label_key in field_map:
|
||||
val = variables.get(key, '')
|
||||
if val:
|
||||
fields.append((label, str(val)))
|
||||
fields.append((runtime_message(label_key, language), str(val)))
|
||||
|
||||
# Build HTML body with escaped content
|
||||
body_html_parts = []
|
||||
@@ -1919,6 +2196,7 @@ FIELD_EMOJI = {
|
||||
'hostname': '\U0001F4BB', # laptop
|
||||
'vmid': '\U0001F194', # ID button
|
||||
'vmname': '\U0001F3F7\uFE0F', # label
|
||||
'ct_name': '\U0001F4E6', # package / container
|
||||
'device': '\U0001F4BD', # disk
|
||||
'mount': '\U0001F4C2', # open folder
|
||||
'source_ip': '\U0001F310', # globe
|
||||
@@ -1943,12 +2221,89 @@ FIELD_EMOJI = {
|
||||
'kernel_count': '\u2699\uFE0F',
|
||||
'important_list': '\U0001F4CB', # clipboard
|
||||
'current_version': '\U0001F4E6', # package \u2014 installed version
|
||||
'new_version': '\U0001F195', # NEW button \u2014 offered version
|
||||
'latest_version': '\U0001F195', # NEW button \u2014 upstream version
|
||||
'kernel': '\u2699\uFE0F', # gear \u2014 running kernel
|
||||
'menu_label': '\U0001F4D6', # open book \u2014 menu navigation hint
|
||||
}
|
||||
|
||||
|
||||
_TEMPLATE_FIELD_RE = re.compile(r'\{([a-zA-Z_][a-zA-Z0-9_]*)[^}]*\}')
|
||||
|
||||
|
||||
def _localized_template_labels(event_type: str, language: str) -> Dict[str, List[str]]:
|
||||
"""Return the visible labels paired with template fields in one locale.
|
||||
|
||||
The old emoji pass compared rendered English words such as ``Duration``
|
||||
and ``Total updates``. That necessarily stops matching after a template
|
||||
is translated. The template itself still knows which field each label
|
||||
describes, so derive the visible wording from that localized template.
|
||||
"""
|
||||
requested = (language or 'en').split('-', 1)[0].lower()
|
||||
key = f'templates.{event_type}.body'
|
||||
# Use the raw catalog entry rather than runtime_message(): the latter
|
||||
# deliberately formats unknown placeholders away, while this helper needs
|
||||
# to inspect those placeholders to associate each label with its field.
|
||||
body = (
|
||||
_catalog_value(_load_runtime_catalog(requested), key)
|
||||
or _catalog_value(_load_runtime_catalog('en'), key)
|
||||
)
|
||||
if not body:
|
||||
body = TEMPLATES.get(event_type, {}).get('body', '')
|
||||
|
||||
labels: Dict[str, List[str]] = {}
|
||||
lines = body.splitlines()
|
||||
for index, line in enumerate(lines):
|
||||
matches = list(_TEMPLATE_FIELD_RE.finditer(line))
|
||||
for match in matches:
|
||||
label = line[:match.start()].strip().rstrip(':').strip()
|
||||
if label:
|
||||
labels.setdefault(match.group(1), []).append(label)
|
||||
|
||||
# A heading on its own line (for example "Important packages:")
|
||||
# labels the variable rendered on the following line.
|
||||
if line.strip().endswith(':') and index + 1 < len(lines):
|
||||
next_matches = list(_TEMPLATE_FIELD_RE.finditer(lines[index + 1]))
|
||||
if len(next_matches) == 1:
|
||||
label = line.strip().rstrip(':').strip()
|
||||
if label:
|
||||
labels.setdefault(next_matches[0].group(1), []).append(label)
|
||||
return labels
|
||||
|
||||
|
||||
def _lxc_update_label_icons(language: str) -> Dict[str, str]:
|
||||
"""Return localized LXC-update detail prefixes with stable icons."""
|
||||
values = {
|
||||
'before': '0', 'after': '0', 'items': 'item', 'targets': 'target',
|
||||
'reason': 'reason', 'value': 'value', 'error': 'error', 'duration': '0s',
|
||||
}
|
||||
definitions = (
|
||||
('lxcUpdate.sourceLabel', '🧭'),
|
||||
('lxcUpdate.targets', '🎯'),
|
||||
('lxcUpdate.osPending', '📦'),
|
||||
('lxcUpdate.osUnverified', '📦'),
|
||||
('lxcUpdate.applications', '🧩'),
|
||||
('lxcUpdate.applicationsUnverified', '🧩'),
|
||||
('lxcUpdate.dockerEngineChange', '🐳'),
|
||||
('lxcUpdate.dockerEngineVerified', '🐳'),
|
||||
('lxcUpdate.dockerEngineUnverified', '🐳'),
|
||||
('lxcUpdate.dockerImagesPending', '🐳'),
|
||||
('lxcUpdate.dockerImagesChanged', '🐳'),
|
||||
('lxcUpdate.deferredTargets', '⏳'),
|
||||
('lxcUpdate.reason', '📝'),
|
||||
('lxcUpdate.restartRequired', '🔄'),
|
||||
('lxcUpdate.verificationPending', '⏳'),
|
||||
('lxcUpdate.verificationWarning', '⚠️'),
|
||||
('lxcUpdate.duration', '⏱️'),
|
||||
)
|
||||
result = {}
|
||||
for key, icon in definitions:
|
||||
rendered = runtime_message(key, language, **values).strip()
|
||||
if rendered:
|
||||
result[rendered.split(':', 1)[0].strip()] = icon
|
||||
return result
|
||||
|
||||
|
||||
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.
|
||||
@@ -2009,6 +2364,13 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
|
||||
preprocessed = re.sub(r'^\n+', '', preprocessed)
|
||||
preprocessed = preprocessed.strip()
|
||||
|
||||
language = str(data.get('_notification_language') or 'en')
|
||||
localized_labels = _localized_template_labels(event_type, language)
|
||||
lxc_update_labels = (
|
||||
_lxc_update_label_icons(language)
|
||||
if event_type == 'lxc_update_applied' else {}
|
||||
)
|
||||
|
||||
# ── Extended emoji mappings for health/disk messages ──
|
||||
HEALTH_EMOJI_MAP = {
|
||||
# Disk patterns
|
||||
@@ -2033,12 +2395,41 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
|
||||
# Build enriched body: prepend field emojis to recognizable lines
|
||||
lines = preprocessed.split('\n')
|
||||
enriched_lines = []
|
||||
app_update_is_single = (
|
||||
not isinstance(data.get('updates'), list) or len(data['updates']) < 2
|
||||
)
|
||||
app_update_lead_added = False
|
||||
app_update_version = None
|
||||
if event_type == 'app_update_available' and app_update_is_single:
|
||||
app_update_version = f"{data.get('installed', '')} → {data.get('latest', '')}".strip()
|
||||
|
||||
for line in lines:
|
||||
stripped = line.strip()
|
||||
if not stripped:
|
||||
enriched_lines.append(line)
|
||||
continue
|
||||
|
||||
# App-update notifications deliberately use a compact, prose-like
|
||||
# template rather than field labels. Keep their two structured lines
|
||||
# as readable as other update notifications without depending on a
|
||||
# translated phrase to recognize them.
|
||||
if event_type == 'app_update_available' and app_update_is_single:
|
||||
if not app_update_lead_added:
|
||||
enriched_lines.append(f'📦 {stripped}')
|
||||
app_update_lead_added = True
|
||||
continue
|
||||
if app_update_version and stripped == app_update_version:
|
||||
enriched_lines.append(f'🔄 {stripped}')
|
||||
continue
|
||||
|
||||
# The Proxmox VE detector emits the manager version as a concise,
|
||||
# structured technical detail outside the localized template fields.
|
||||
# Mark only that known detail; arbitrary detector text remains intact.
|
||||
if event_type == 'pve_update' and re.match(
|
||||
r'^pve-manager\s+\S+\s+(?:→|->)\s+\S+$', stripped,
|
||||
):
|
||||
enriched_lines.append(f'🔧 {stripped}')
|
||||
continue
|
||||
|
||||
# First, check health-specific patterns
|
||||
health_enriched = False
|
||||
@@ -2054,6 +2445,30 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
|
||||
|
||||
if health_enriched:
|
||||
continue
|
||||
|
||||
# LXC update outcomes are assembled from structured details rather
|
||||
# than one static template line. Their translated labels come from
|
||||
# the runtime catalog, so keep the mapping semantic rather than
|
||||
# comparing an English translation.
|
||||
matched_lxc_label = next(
|
||||
(label for label in lxc_update_labels
|
||||
if stripped.lower().startswith(label.lower())),
|
||||
None,
|
||||
)
|
||||
if matched_lxc_label:
|
||||
icon = lxc_update_labels[matched_lxc_label]
|
||||
if not stripped.startswith(icon):
|
||||
enriched_lines.append(f'{icon} {stripped}')
|
||||
else:
|
||||
enriched_lines.append(stripped)
|
||||
continue
|
||||
|
||||
# Docker's engine inventory is generated by the detector, not the
|
||||
# translated template. "Docker Engine" is its product name and stays
|
||||
# stable across locales, so it is safe to decorate directly.
|
||||
if event_type == 'docker_stack_update_available' and stripped.startswith('• Docker Engine:'):
|
||||
enriched_lines.append(f'🐳 {stripped}')
|
||||
continue
|
||||
|
||||
# Try to match "FieldName: value" patterns
|
||||
enriched = False
|
||||
@@ -2078,6 +2493,7 @@ def enrich_with_emojis(event_type: str, title: str, body: str,
|
||||
}
|
||||
if field_key in _LABEL_MAP:
|
||||
label_variants.append(_LABEL_MAP[field_key])
|
||||
label_variants.extend(localized_labels.get(field_key, []))
|
||||
|
||||
for label in label_variants:
|
||||
if stripped.lower().startswith(label.lower() + ':'):
|
||||
|
||||
@@ -135,6 +135,31 @@ class AppUpdateNotificationBatchTests(unittest.TestCase):
|
||||
self.assertIn("• Docmost: 0.2 → 0.9", rendered["body"])
|
||||
self.assertIn("• Redis: 7.0 → 8.1", rendered["body"])
|
||||
|
||||
def test_batch_formatter_uses_slovak_runtime_messages(self):
|
||||
data = {
|
||||
"hostname": "HomeLAB_2",
|
||||
"updates": [
|
||||
{"vmid": 115, "app_name": "Redis", "installed": "7.0", "latest": "8.1"},
|
||||
{"vmid": 100, "app_name": "AdGuard Home", "installed": "1.0", "latest": "1.1"},
|
||||
{"vmid": 115, "app_name": "Docmost", "installed": "0.2", "latest": "0.9"},
|
||||
],
|
||||
}
|
||||
|
||||
rendered = notification_templates.render_template(
|
||||
"app_update_available", data, language="sk",
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
rendered["title"],
|
||||
"HomeLAB_2: Dostupné aktualizácie aplikácií: 3",
|
||||
)
|
||||
self.assertIn(
|
||||
"Aplikácie s dostupnou novšou verziou: 3 v 2 kontajneroch LXC:",
|
||||
rendered["body"],
|
||||
)
|
||||
self.assertIn("• AdGuard Home: 1.0 → 1.1", rendered["body"])
|
||||
self.assertNotIn("application updates available", rendered["title"])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
||||
@@ -0,0 +1,807 @@
|
||||
import json
|
||||
import re
|
||||
import sqlite3
|
||||
import string
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
APPIMAGE_DIR = SCRIPTS_DIR.parent
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import notification_manager
|
||||
import notification_templates
|
||||
import notification_channels
|
||||
|
||||
|
||||
def _placeholders(value):
|
||||
return {
|
||||
field_name
|
||||
for _literal, field_name, _format_spec, _conversion
|
||||
in string.Formatter().parse(value)
|
||||
if field_name
|
||||
}
|
||||
|
||||
|
||||
class RuntimeCatalogTests(unittest.TestCase):
|
||||
RUNTIME_LANGUAGES = ("en", "de", "es", "fr", "it", "pt", "sk", "sv")
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
cls.catalogs = {}
|
||||
for language in cls.RUNTIME_LANGUAGES:
|
||||
path = APPIMAGE_DIR / "messages" / language / "common.json"
|
||||
cls.catalogs[language] = json.loads(path.read_text(encoding="utf-8"))["runtime"]["notifications"]
|
||||
|
||||
def test_runtime_catalog_covers_every_template_dynamically(self):
|
||||
expected = set(notification_templates.TEMPLATES)
|
||||
for language, catalog in self.catalogs.items():
|
||||
templates = catalog["templates"]
|
||||
self.assertEqual(set(templates), expected, language)
|
||||
for event_type, source in notification_templates.TEMPLATES.items():
|
||||
self.assertEqual(set(templates[event_type]), {"title", "body", "label"})
|
||||
for field in ("title", "body", "label"):
|
||||
self.assertIsInstance(templates[event_type][field], str)
|
||||
self.assertTrue(templates[event_type][field])
|
||||
if field in source:
|
||||
self.assertEqual(
|
||||
_placeholders(templates[event_type][field]),
|
||||
_placeholders(source[field]),
|
||||
f"{language}:{event_type}:{field}",
|
||||
)
|
||||
|
||||
def test_runtime_catalog_keys_and_placeholders_match(self):
|
||||
def flatten(value, prefix=""):
|
||||
result = {}
|
||||
for key, child in value.items():
|
||||
dotted = f"{prefix}.{key}" if prefix else key
|
||||
if isinstance(child, dict):
|
||||
result.update(flatten(child, dotted))
|
||||
else:
|
||||
result[dotted] = child
|
||||
return result
|
||||
|
||||
en = flatten(self.catalogs["en"])
|
||||
for language, catalog in self.catalogs.items():
|
||||
translated = flatten(catalog)
|
||||
self.assertEqual(set(translated), set(en), language)
|
||||
for key in en:
|
||||
self.assertEqual(_placeholders(translated[key]), _placeholders(en[key]), f"{language}:{key}")
|
||||
|
||||
def test_notification_language_ui_keys_exist_in_both_catalogs(self):
|
||||
required = {
|
||||
"notificationLanguage",
|
||||
"selectNotificationLanguage",
|
||||
"notificationLanguageHint",
|
||||
}
|
||||
for language in self.RUNTIME_LANGUAGES:
|
||||
path = APPIMAGE_DIR / "messages" / language / "common.json"
|
||||
common = json.loads(path.read_text(encoding="utf-8"))
|
||||
ui = common["settings"]["notifications"]["ui"]
|
||||
self.assertTrue(required.issubset(ui), language)
|
||||
for key in required:
|
||||
self.assertIsInstance(ui[key], str)
|
||||
self.assertTrue(ui[key].strip(), f"{language}:{key}")
|
||||
|
||||
def test_slovak_catalog_preserves_placeholders_and_translates_static_text(self):
|
||||
en = self.catalogs["en"]["templates"]
|
||||
sk = self.catalogs["sk"]["templates"]
|
||||
for event_type, source in notification_templates.TEMPLATES.items():
|
||||
for field in ("title", "body", "label"):
|
||||
static_text = source.get(field, "")
|
||||
for placeholder in _placeholders(static_text):
|
||||
static_text = static_text.replace("{" + placeholder + "}", "")
|
||||
if any(ch.isalpha() for ch in static_text):
|
||||
self.assertNotEqual(sk[event_type][field], en[event_type][field], f"{event_type}:{field}")
|
||||
|
||||
def test_every_template_renders_in_slovak_and_preserves_dynamic_values(self):
|
||||
values = {
|
||||
name: f"DYNAMIC_{name.upper()}"
|
||||
for template in notification_templates.TEMPLATES.values()
|
||||
for field in ("title", "body")
|
||||
for name in _placeholders(template.get(field, ""))
|
||||
}
|
||||
values.update({"hostname": "HOST-ŽILINA", "severity": "WARNING"})
|
||||
with mock.patch.object(notification_templates, "_get_hostname", return_value="HOST-ŽILINA"):
|
||||
for event_type in notification_templates.TEMPLATES:
|
||||
rendered = notification_templates.render_template(event_type, values, language="sk")
|
||||
combined = rendered["title"] + "\n" + rendered["body"]
|
||||
if notification_templates.TEMPLATES[event_type].get("formatter"):
|
||||
continue
|
||||
for name in _placeholders(
|
||||
notification_templates.TEMPLATES[event_type]["title"]
|
||||
+ notification_templates.TEMPLATES[event_type]["body"]
|
||||
):
|
||||
if name not in {"entity_suffix", "title_or_default"}:
|
||||
self.assertIn(str(values[name]), combined, f"{event_type}:{name}")
|
||||
|
||||
def test_lxc_update_result_and_details_render_in_slovak(self):
|
||||
data = {
|
||||
"hostname": "homelab",
|
||||
"ct_name": "iventoy",
|
||||
"vmid": "105",
|
||||
"result": "succeeded",
|
||||
"details": "Source: Manual",
|
||||
"lxc_update": {
|
||||
"status": "success",
|
||||
"source": "manual",
|
||||
"targets": ["app:iventoy"],
|
||||
"labels": ["iVentoy"],
|
||||
"duration": "16s",
|
||||
"before": {
|
||||
"apps": {"iventoy": {"name": "iVentoy", "installed_version": "1.0.42"}},
|
||||
},
|
||||
"after": {
|
||||
"apps": {"iventoy": {"name": "iVentoy", "installed_version": "1.0.43"}},
|
||||
},
|
||||
"verification_pending": False,
|
||||
"verification_errors": [],
|
||||
"reboot_required": False,
|
||||
},
|
||||
}
|
||||
rendered = notification_templates.render_template("lxc_update_applied", data, language="sk")
|
||||
self.assertEqual(rendered["title"], "homelab: LXC iventoy (105) aktualizácia úspešne dokončená")
|
||||
self.assertIn("Zdroj: Manuálne", rendered["body"])
|
||||
self.assertIn("Ciele: iVentoy", rendered["body"])
|
||||
self.assertIn("Aplikácie: iVentoy: 1.0.42 → 1.0.43", rendered["body"])
|
||||
self.assertIn("Vyžaduje sa reštart: nie", rendered["body"])
|
||||
self.assertIn("Trvanie: 16s", rendered["body"])
|
||||
self.assertNotIn("Source:", rendered["body"])
|
||||
self.assertNotIn("succeeded", rendered["title"])
|
||||
|
||||
_title, enriched_body = notification_templates.enrich_with_emojis(
|
||||
"lxc_update_applied", rendered["title"], rendered["body"],
|
||||
{**data, "_notification_language": "sk", "severity": "INFO"},
|
||||
)
|
||||
self.assertIn("🧭 Zdroj: Manuálne", enriched_body)
|
||||
self.assertIn("🎯 Ciele: iVentoy", enriched_body)
|
||||
self.assertIn("🧩 Aplikácie: iVentoy: 1.0.42 → 1.0.43", enriched_body)
|
||||
self.assertIn("🔄 Vyžaduje sa reštart: nie", enriched_body)
|
||||
self.assertIn("⏱️ Trvanie: 16s", enriched_body)
|
||||
|
||||
for language in self.RUNTIME_LANGUAGES:
|
||||
rendered_locale = notification_templates.render_template(
|
||||
"lxc_update_applied", data, language=language,
|
||||
)
|
||||
_title, body_locale = notification_templates.enrich_with_emojis(
|
||||
"lxc_update_applied", rendered_locale["title"], rendered_locale["body"],
|
||||
{**data, "_notification_language": language, "severity": "INFO"},
|
||||
)
|
||||
self.assertIn("🧭", body_locale, language)
|
||||
self.assertIn("⏱️", body_locale, language)
|
||||
|
||||
def test_update_summary_body_icons_follow_the_selected_language(self):
|
||||
data = {
|
||||
"hostname": "pve01", "total_count": "2", "security_count": "0",
|
||||
"pve_count": "1", "kernel_count": "0", "important_list": "none",
|
||||
"severity": "INFO", "_notification_language": "sk",
|
||||
}
|
||||
rendered = notification_templates.render_template("update_summary", data, language="sk")
|
||||
_title, enriched_body = notification_templates.enrich_with_emojis(
|
||||
"update_summary", rendered["title"], rendered["body"], data,
|
||||
)
|
||||
self.assertIn("📦 Aktualizácie spolu: 2", enriched_body)
|
||||
self.assertIn("🛡️ Bezpečnostné aktualizácie: 0", enriched_body)
|
||||
self.assertIn("⚙️ Aktualizácie jadra: 0", enriched_body)
|
||||
self.assertIn("📋 Dôležité balíky:", enriched_body)
|
||||
self.assertIn("žiadne", enriched_body)
|
||||
self.assertNotIn("\nnone", enriched_body)
|
||||
|
||||
for language in self.RUNTIME_LANGUAGES:
|
||||
locale_data = {**data, "_notification_language": language}
|
||||
rendered_locale = notification_templates.render_template(
|
||||
"update_summary", locale_data, language=language,
|
||||
)
|
||||
_title, body_locale = notification_templates.enrich_with_emojis(
|
||||
"update_summary", rendered_locale["title"], rendered_locale["body"], locale_data,
|
||||
)
|
||||
total_label = notification_templates._localized_template_labels(
|
||||
"update_summary", language,
|
||||
)["total_count"][0]
|
||||
self.assertIn(f"📦 {total_label}: 2", body_locale, language)
|
||||
|
||||
def test_docker_update_body_icons_preserve_localized_container_label(self):
|
||||
data = {
|
||||
"hostname": "pve01", "vmid": "210", "ct_name": "repopulse-labs-test",
|
||||
"count": "1", "details": "• Docker Engine: 29.8.0 → 29.8.1",
|
||||
"severity": "INFO", "_notification_language": "sk",
|
||||
}
|
||||
rendered = notification_templates.render_template(
|
||||
"docker_stack_update_available", data, language="sk",
|
||||
)
|
||||
_title, enriched_body = notification_templates.enrich_with_emojis(
|
||||
"docker_stack_update_available", rendered["title"], rendered["body"], data,
|
||||
)
|
||||
self.assertIn("📦 Kontajner repopulse-labs-test (CT 210) má 1 aktualizácií Docker:", enriched_body)
|
||||
self.assertIn("🐳 • Docker Engine: 29.8.0 → 29.8.1", enriched_body)
|
||||
|
||||
def test_app_and_proxmox_update_body_icons_cover_versions(self):
|
||||
app_data = {
|
||||
"hostname": "HomeLAB_1", "app_name": "Uptime Kuma", "vmid": "108",
|
||||
"ct_name": "uptime-kuma", "installed": "2.5.4", "latest": "2.5.5",
|
||||
"severity": "INFO", "_notification_language": "sk",
|
||||
}
|
||||
app = notification_templates.render_template(
|
||||
"app_update_available", app_data, language="sk",
|
||||
)
|
||||
_title, app_body = notification_templates.enrich_with_emojis(
|
||||
"app_update_available", app["title"], app["body"], app_data,
|
||||
)
|
||||
self.assertIn("📦 Aplikácia Uptime Kuma na CT 108 (uptime-kuma) má novú verziu:", app_body)
|
||||
self.assertIn("🔄 2.5.4 → 2.5.5", app_body)
|
||||
|
||||
pve_data = {
|
||||
"hostname": "HomeLAB_1", "current_version": "9.2.18",
|
||||
"new_version": "9.2.20", "details": "pve-manager 9.2.18 → 9.2.20",
|
||||
"severity": "INFO", "_notification_language": "sk",
|
||||
}
|
||||
pve = notification_templates.render_template("pve_update", pve_data, language="sk")
|
||||
_title, pve_body = notification_templates.enrich_with_emojis(
|
||||
"pve_update", pve["title"], pve["body"], pve_data,
|
||||
)
|
||||
self.assertIn("📦 Aktuálna: 9.2.18", pve_body)
|
||||
self.assertIn("🆕 Nová: 9.2.20", pve_body)
|
||||
self.assertIn("🔧 pve-manager 9.2.18 → 9.2.20", pve_body)
|
||||
|
||||
for language in self.RUNTIME_LANGUAGES:
|
||||
locale_data = {**pve_data, "_notification_language": language}
|
||||
locale = notification_templates.render_template(
|
||||
"pve_update", locale_data, language=language,
|
||||
)
|
||||
_title, locale_body = notification_templates.enrich_with_emojis(
|
||||
"pve_update", locale["title"], locale["body"], locale_data,
|
||||
)
|
||||
label = notification_templates._localized_template_labels(
|
||||
"pve_update", language,
|
||||
)["new_version"][0]
|
||||
self.assertIn(f"🆕 {label}: 9.2.20", locale_body, language)
|
||||
|
||||
def test_missing_slovak_key_falls_back_to_english(self):
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
root = Path(directory)
|
||||
(root / "en").mkdir()
|
||||
(root / "sk").mkdir()
|
||||
(root / "en" / "common.json").write_text(
|
||||
json.dumps({"runtime": {"notifications": {"fallback": {"unknownTitle": "{hostname}: {event_type}"}}}}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
(root / "sk" / "common.json").write_text(
|
||||
json.dumps({"runtime": {"notifications": {}}}), encoding="utf-8"
|
||||
)
|
||||
with mock.patch.object(notification_templates, "RUNTIME_CATALOG_DIR", root):
|
||||
notification_templates._load_runtime_catalog.cache_clear()
|
||||
self.assertEqual(
|
||||
notification_templates.runtime_message(
|
||||
"fallback.unknownTitle", "sk", hostname="pve01", event_type="vendor_event"
|
||||
),
|
||||
"pve01: vendor_event",
|
||||
)
|
||||
notification_templates._load_runtime_catalog.cache_clear()
|
||||
|
||||
def test_special_formatters_digest_and_test_message_are_slovak(self):
|
||||
startup = notification_templates.render_template(
|
||||
"system_startup",
|
||||
{"hostname": "pve01", "has_issues": False, "vms_started": [{"name": "alpha", "vmid": 100}]},
|
||||
language="sk",
|
||||
)
|
||||
self.assertIn("Spustenie systému", startup["title"])
|
||||
self.assertIn("Všetky systémy sú funkčné", startup["body"])
|
||||
self.assertIn("alpha", startup["body"])
|
||||
|
||||
app = notification_templates.render_template(
|
||||
"app_update_available",
|
||||
{"hostname": "pve01", "app_name": "Redis", "vmid": 115, "ct_name": "cache", "installed": "7.0", "latest": "8.1"},
|
||||
language="sk",
|
||||
)
|
||||
self.assertIn("dostupná aktualizácia", app["title"])
|
||||
self.assertIn("Redis", app["body"])
|
||||
self.assertIn("7.0 → 8.1", app["body"])
|
||||
|
||||
backup = notification_templates.render_template(
|
||||
"backup_complete",
|
||||
{
|
||||
"hostname": "pve01", "storage": "pbs-main", "vmname": "alpha", "vmid": "100",
|
||||
"pve_title": "Backup job finished",
|
||||
"pve_message": (
|
||||
"INFO: Starting Backup of VM 100 (qemu)\n"
|
||||
"INFO: VM Name: alpha\n"
|
||||
"INFO: transferred 1.5 GiB in 10 seconds\n"
|
||||
"INFO: Finished Backup of VM 100 (00:00:10)"
|
||||
),
|
||||
},
|
||||
language="sk",
|
||||
)
|
||||
self.assertIn("Záloha dokončená", backup["title"])
|
||||
self.assertNotIn("Backup job finished", backup["title"])
|
||||
self.assertIn("Veľkosť: 1.5 GiB", backup["body"])
|
||||
self.assertIn("Trvanie: 00:00:10", backup["body"])
|
||||
|
||||
manager = notification_manager.NotificationManager()
|
||||
manager._config = {"notification_language": "sk"}
|
||||
rows = [(1, "cpu_high", "resources", 0, "pve01: Vysoké využitie CPU", "body")]
|
||||
digest = manager._compose_digest_body(rows)
|
||||
self.assertIn("udalostí INFO zoskupených podľa kategórie", digest)
|
||||
self.assertIn("Zdroje", digest)
|
||||
rich_digest = manager._compose_digest_body(rows, use_icons=True)
|
||||
self.assertIn("📊 Zdroje: 1", rich_digest)
|
||||
self.assertIn("• 🔥 01:00 Vysoké využitie CPU", rich_digest)
|
||||
title, body, caption = manager._build_test_message(False, False, "groq / sk")
|
||||
self.assertEqual(title, "Test ProxMenux")
|
||||
self.assertIn("Vitajte", body)
|
||||
self.assertIn("profilovú fotografiu", caption)
|
||||
|
||||
def test_monitor_generated_cpu_health_degradation_is_slovak(self):
|
||||
data = {
|
||||
"hostname": "HomeLAB_2",
|
||||
"severity": "CRITICAL",
|
||||
"title": "HomeLAB_2: Health CRITICAL - CPU Usage & Temperature",
|
||||
"reason": "CPU >95.0% sustained for 296s",
|
||||
"health_degraded": {
|
||||
"categories": [{
|
||||
"key": "cpu",
|
||||
"status": "CRITICAL",
|
||||
"reason": "CPU >95.0% sustained for 296s",
|
||||
"entity": "",
|
||||
}],
|
||||
},
|
||||
}
|
||||
rendered = notification_templates.render_template(
|
||||
"health_degraded", data, language="sk",
|
||||
)
|
||||
self.assertEqual(
|
||||
rendered["title"],
|
||||
"HomeLAB_2: Kritický stav – Využitie CPU a teplota",
|
||||
)
|
||||
self.assertEqual(rendered["body"], "CPU je nad 95.0 % už 296 s.")
|
||||
self.assertNotIn("Health CRITICAL", rendered["title"])
|
||||
self.assertNotIn("sustained", rendered["body"])
|
||||
|
||||
enriched_title, _body = notification_templates.enrich_with_emojis(
|
||||
"health_degraded", rendered["title"], rendered["body"],
|
||||
{**data, "_notification_language": "sk"},
|
||||
)
|
||||
self.assertTrue(enriched_title.startswith("⚠️ "))
|
||||
|
||||
def test_batch_app_updates_render_from_every_runtime_catalog(self):
|
||||
data = {
|
||||
"hostname": "HOST-ŽILINA",
|
||||
"updates": [
|
||||
{"vmid": 100, "app_name": "AdGuard Home", "installed": "1.0", "latest": "1.1"},
|
||||
{"vmid": 115, "app_name": "Redis", "installed": "7.0", "latest": "8.1"},
|
||||
],
|
||||
}
|
||||
for language in self.RUNTIME_LANGUAGES:
|
||||
rendered = notification_templates.render_template(
|
||||
"app_update_available", data, language=language,
|
||||
)
|
||||
self.assertEqual(
|
||||
rendered["title"],
|
||||
notification_templates.runtime_message(
|
||||
"appUpdates.batchTitle", language,
|
||||
hostname="HOST-ŽILINA", count=2,
|
||||
),
|
||||
language,
|
||||
)
|
||||
self.assertIn("CT 100", rendered["body"], language)
|
||||
self.assertIn("• Redis: 7.0 → 8.1", rendered["body"], language)
|
||||
|
||||
def test_ai_disabled_telegram_receives_deterministic_slovak(self):
|
||||
class RecordingTelegram:
|
||||
def __init__(self):
|
||||
self.payload = None
|
||||
|
||||
def send(self, title, body, severity, data=None):
|
||||
self.payload = (title, body, severity, data)
|
||||
return {"success": True, "error": ""}
|
||||
|
||||
channel = RecordingTelegram()
|
||||
manager = notification_manager.NotificationManager()
|
||||
manager._channels = {"telegram": channel}
|
||||
manager._config = {
|
||||
"notification_language": "sk",
|
||||
"ai_enabled": "false",
|
||||
"telegram.rich_format": "false",
|
||||
}
|
||||
with mock.patch.object(manager, "_record_history"):
|
||||
result = manager.send_notification(
|
||||
"vm_start", "INFO", "", "",
|
||||
data={"hostname": "pve01", "vmname": "účtovníctvo", "vmid": "123"},
|
||||
skip_toggle_check=True,
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("spustený", channel.payload[0])
|
||||
self.assertIn("účtovníctvo", channel.payload[1])
|
||||
self.assertNotIn("is now running", channel.payload[1])
|
||||
|
||||
def test_display_name_replaces_only_the_local_runtime_hostname(self):
|
||||
config = {"hostname": "HomeLAB_2"}
|
||||
with mock.patch.object(notification_manager.socket, "gethostname", return_value="homelab-2"), \
|
||||
mock.patch.object(notification_manager.socket, "getfqdn", return_value="homelab-2.home.lab"):
|
||||
self.assertEqual(
|
||||
notification_manager.resolve_notification_hostname("homelab-2", config),
|
||||
"HomeLAB_2",
|
||||
)
|
||||
self.assertEqual(
|
||||
notification_manager.resolve_notification_hostname("remote-pve", config),
|
||||
"remote-pve",
|
||||
)
|
||||
|
||||
class RecordingChannel:
|
||||
def __init__(self):
|
||||
self.payload = None
|
||||
|
||||
def send(self, title, body, severity, data=None):
|
||||
self.payload = (title, body, severity, data)
|
||||
return {"success": True}
|
||||
|
||||
manager = notification_manager.NotificationManager()
|
||||
channel = RecordingChannel()
|
||||
manager._channels = {"telegram": channel}
|
||||
manager._config = {
|
||||
"notification_language": "sk",
|
||||
"hostname": "HomeLAB_2",
|
||||
"ai_enabled": "false",
|
||||
"telegram.rich_format": "false",
|
||||
}
|
||||
with mock.patch.object(notification_manager.socket, "gethostname", return_value="homelab-2"), \
|
||||
mock.patch.object(notification_manager.socket, "getfqdn", return_value="homelab-2.home.lab"), \
|
||||
mock.patch.object(manager, "_record_history"):
|
||||
result = manager.send_notification(
|
||||
"docker_stack_update_available", "INFO", "", "",
|
||||
data={"hostname": "homelab-2", "vmid": "210", "ct_name": "repopulse", "count": "1", "details": "Docker Engine"},
|
||||
skip_toggle_check=True,
|
||||
)
|
||||
|
||||
self.assertTrue(result["success"])
|
||||
self.assertIn("HomeLAB_2: Na CT 210 sú dostupné aktualizácie Docker", channel.payload[0])
|
||||
self.assertNotIn("homelab-2:", channel.payload[0])
|
||||
|
||||
def test_email_channel_chrome_uses_the_runtime_catalog(self):
|
||||
channel = object.__new__(notification_channels.EmailChannel)
|
||||
channel.subject_prefix = "[ProxMenux]"
|
||||
html = channel._format_html(
|
||||
"[ProxMenux] pve01: Vysoké využitie CPU",
|
||||
"Využitie CPU dosiahlo 95 %.",
|
||||
"WARNING",
|
||||
{
|
||||
"_notification_language": "sk", "_event_type": "cpu_high",
|
||||
"_group": "resources", "hostname": "pve01", "value": "95",
|
||||
"threshold": "90", "cores": "8",
|
||||
},
|
||||
)
|
||||
self.assertIn("Systémové zdroje", html)
|
||||
self.assertIn("UPOZORNENIE", html)
|
||||
self.assertIn("Server:", html)
|
||||
self.assertIn("Aktuálna hodnota", html)
|
||||
self.assertNotIn(">Details<", html)
|
||||
self.assertNotIn("System Resources Report", html)
|
||||
|
||||
vm_html = channel._format_html(
|
||||
"pve01: VM účtovníctvo (123) spustený",
|
||||
"Virtuálny stroj účtovníctvo (ID: 123) je spustený.",
|
||||
"INFO",
|
||||
{
|
||||
"_notification_language": "sk", "_event_type": "vm_start",
|
||||
"_group": "vm_ct", "hostname": "pve01", "vmid": "123",
|
||||
"vmname": "účtovníctvo",
|
||||
},
|
||||
)
|
||||
self.assertIn("VM bola spustená", vm_html)
|
||||
|
||||
def test_notification_language_precedence_and_roundtrip(self):
|
||||
manager = notification_manager.NotificationManager()
|
||||
manager._config = {"notification_language": "sk", "ai_language": "de"}
|
||||
self.assertEqual(manager._notification_language(), "sk")
|
||||
manager._config = {"ai_language": "sk"}
|
||||
self.assertEqual(manager._notification_language(), "sk")
|
||||
manager._config = {}
|
||||
self.assertEqual(manager._notification_language(), "en")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
db_path = Path(directory) / "settings.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("CREATE TABLE user_settings (setting_key TEXT PRIMARY KEY, setting_value TEXT, updated_at TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with mock.patch.object(notification_manager, "DB_PATH", db_path):
|
||||
saved = manager.save_settings({"notification_language": "sk", "ai_language": "de"})
|
||||
self.assertTrue(saved["success"])
|
||||
loaded = notification_manager.NotificationManager()
|
||||
loaded._load_config()
|
||||
self.assertEqual(loaded.get_settings()["config"]["notification_language"], "sk")
|
||||
self.assertEqual(loaded.get_settings()["config"]["ai_language"], "de")
|
||||
|
||||
def test_future_digest_time_change_resets_only_the_digest_guard(self):
|
||||
class FixedDatetime:
|
||||
@classmethod
|
||||
def now(cls):
|
||||
return datetime(2026, 9, 17, 13, 30)
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
db_path = Path(directory) / "settings.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("CREATE TABLE user_settings (setting_key TEXT PRIMARY KEY, setting_value TEXT, updated_at TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
manager = notification_manager.NotificationManager()
|
||||
manager._config = {
|
||||
"telegram.digest_enabled": "true",
|
||||
"telegram.digest_time": "13:20",
|
||||
"telegram.digest_last_at": "2026-09-17T13:20:25",
|
||||
}
|
||||
with mock.patch.object(notification_manager, "DB_PATH", db_path), \
|
||||
mock.patch.object(notification_manager, "datetime", FixedDatetime):
|
||||
result = manager.save_settings({
|
||||
"telegram.digest_enabled": "true",
|
||||
"telegram.digest_time": "13:40",
|
||||
})
|
||||
|
||||
self.assertTrue(result["success"], result)
|
||||
self.assertEqual(manager._config["telegram.digest_last_at"], "")
|
||||
conn = sqlite3.connect(db_path)
|
||||
stored = conn.execute(
|
||||
"SELECT setting_value FROM user_settings WHERE setting_key = ?",
|
||||
("notification.telegram.digest_last_at",),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
self.assertEqual(stored[0], "")
|
||||
|
||||
manager = notification_manager.NotificationManager()
|
||||
manager._config = {
|
||||
"telegram.digest_enabled": "true",
|
||||
"telegram.digest_time": "13:40",
|
||||
"telegram.digest_last_at": "2026-09-17T13:20:25",
|
||||
}
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
db_path = Path(directory) / "settings.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("CREATE TABLE user_settings (setting_key TEXT PRIMARY KEY, setting_value TEXT, updated_at TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with mock.patch.object(notification_manager, "DB_PATH", db_path), \
|
||||
mock.patch.object(notification_manager, "datetime", FixedDatetime):
|
||||
result = manager.save_settings({
|
||||
"telegram.digest_enabled": "true",
|
||||
"telegram.digest_time": "13:40",
|
||||
})
|
||||
self.assertTrue(result["success"], result)
|
||||
self.assertEqual(manager._config["telegram.digest_last_at"], "2026-09-17T13:20:25")
|
||||
|
||||
def test_ai_language_is_independent_from_runtime_notification_language(self):
|
||||
manager = notification_manager.NotificationManager()
|
||||
manager._config = {
|
||||
"notification_language": "sk",
|
||||
"ai_language": "de",
|
||||
"ai_provider": "groq",
|
||||
}
|
||||
self.assertEqual(manager._notification_language(), "sk")
|
||||
self.assertEqual(manager._build_ai_config()["ai_language"], "de")
|
||||
|
||||
def test_legacy_runtime_capable_ai_language_is_preserved(self):
|
||||
manager = notification_manager.NotificationManager()
|
||||
manager._config = {"ai_language": "de"}
|
||||
self.assertEqual(manager._notification_language(), "de")
|
||||
self.assertEqual(manager.get_settings()["config"]["notification_language"], "de")
|
||||
|
||||
manager._config = {"notification_language": "invalid", "ai_language": "sk"}
|
||||
self.assertEqual(manager._notification_language(), "sk")
|
||||
manager._config = {"notification_language": "invalid", "ai_language": "de"}
|
||||
self.assertEqual(manager._notification_language(), "de")
|
||||
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
db_path = Path(directory) / "settings.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute("CREATE TABLE user_settings (setting_key TEXT PRIMARY KEY, setting_value TEXT, updated_at TEXT)")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with mock.patch.object(notification_manager, "DB_PATH", db_path):
|
||||
result = manager.save_settings({
|
||||
"notification_language": manager.get_settings()["config"]["notification_language"],
|
||||
"ai_language": "de",
|
||||
})
|
||||
self.assertTrue(result["success"], result)
|
||||
|
||||
def test_backup_email_localizes_status_and_important_packages(self):
|
||||
channel = object.__new__(notification_channels.EmailChannel)
|
||||
channel.subject_prefix = "[ProxMenux]"
|
||||
for event_type, localized_status, english_status in (
|
||||
("backup_fail", "Zlyhalo", "Failed"),
|
||||
("backup_complete", "Dokončené", "Completed"),
|
||||
("backup_start", "Spustené", "Started"),
|
||||
):
|
||||
backup_html = channel._format_html(
|
||||
"pve01: Záloha", "Stav zálohy VM 100.", "WARNING",
|
||||
{
|
||||
"_notification_language": "sk", "_event_type": event_type,
|
||||
"_group": "backup", "hostname": "pve01", "vmid": "100",
|
||||
"vmname": "alpha", "storage": "pbs-main",
|
||||
},
|
||||
)
|
||||
self.assertIn(f">{localized_status}<", backup_html)
|
||||
self.assertNotIn(f">{english_status}<", backup_html)
|
||||
|
||||
updates_html = channel._format_html(
|
||||
"pve01: Aktualizácie", "Dostupné aktualizácie.", "INFO",
|
||||
{
|
||||
"_notification_language": "sk", "_event_type": "system_updates",
|
||||
"_group": "updates", "hostname": "pve01",
|
||||
"important_list": "pve-manager\nproxmox-kernel",
|
||||
},
|
||||
)
|
||||
self.assertIn("Dôležité balíky", updates_html)
|
||||
self.assertNotIn("Important Packages", updates_html)
|
||||
|
||||
def test_metric_and_system_email_values_are_localized(self):
|
||||
channel = object.__new__(notification_channels.EmailChannel)
|
||||
channel.subject_prefix = "[ProxMenux]"
|
||||
metric_html = channel._format_html(
|
||||
"pve01: Vysoké využitie CPU", "CPU dosiahlo 95 %.", "WARNING",
|
||||
{
|
||||
"_notification_language": "sk", "_event_type": "cpu_high",
|
||||
"_group": "resources", "hostname": "pve01", "value": "95",
|
||||
},
|
||||
)
|
||||
self.assertIn("Vysoké využitie CPU", metric_html)
|
||||
self.assertNotIn("Cpu High", metric_html)
|
||||
|
||||
system_html = channel._format_html(
|
||||
"pve01: Systémový problém", "Zistil sa problém.", "WARNING",
|
||||
{
|
||||
"_notification_language": "sk", "_event_type": "system_problem",
|
||||
"_group": "system", "hostname": "pve01", "reason": "chyba",
|
||||
},
|
||||
)
|
||||
self.assertIn("Prehľad: Systém", system_html)
|
||||
self.assertNotIn("Prehľad: </p>", system_html)
|
||||
|
||||
def test_every_ui_ai_language_is_accepted_by_backend(self):
|
||||
source = (APPIMAGE_DIR / "components" / "notification-settings.tsx").read_text(encoding="utf-8")
|
||||
block = re.search(r"const AI_LANGUAGES = \[(.*?)\n\]", source, re.DOTALL)
|
||||
self.assertIsNotNone(block)
|
||||
ui_languages = set(re.findall(r'value: "([a-z]+)"', block.group(1)))
|
||||
self.assertIn("sv", ui_languages)
|
||||
self.assertIn("no", ui_languages)
|
||||
self.assertEqual(ui_languages - set(notification_manager.ALLOWED_AI_LANGUAGES), set())
|
||||
|
||||
def test_quiet_hours_digest_localizes_system_group(self):
|
||||
class RecordingChannel:
|
||||
def __init__(self):
|
||||
self.payload = None
|
||||
|
||||
def send(self, title, body, severity, data=None):
|
||||
self.payload = (title, body, severity, data)
|
||||
return {"success": True}
|
||||
|
||||
manager = notification_manager.NotificationManager()
|
||||
manager._config = {"notification_language": "sk", "hostname": "pve01"}
|
||||
channel = RecordingChannel()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
db_path = Path(directory) / "settings.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"CREATE TABLE quiet_pending ("
|
||||
"id INTEGER PRIMARY KEY, channel TEXT, event_type TEXT, event_group TEXT, "
|
||||
"ts REAL, title TEXT, body TEXT)"
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO quiet_pending "
|
||||
"(channel, event_type, event_group, ts, title, body) VALUES (?, ?, ?, ?, ?, ?)",
|
||||
("telegram", "ai_model_migrated", "system", 0, "pve01: AI model updated", "body"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
with mock.patch.object(notification_manager, "DB_PATH", db_path), mock.patch.object(
|
||||
manager, "_record_history"
|
||||
):
|
||||
manager._flush_quiet_for_channel("telegram", channel)
|
||||
|
||||
self.assertIsNotNone(channel.payload)
|
||||
self.assertIn("Systém: 1", channel.payload[1])
|
||||
self.assertNotIn("System", channel.payload[1])
|
||||
self.assertTrue(channel.payload[3]["_quiet_hours_summary"])
|
||||
|
||||
def test_digest_buffer_coalesces_lxc_results_despite_detail_changes(self):
|
||||
manager = notification_manager.NotificationManager()
|
||||
with tempfile.TemporaryDirectory() as directory:
|
||||
db_path = Path(directory) / "settings.db"
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"CREATE TABLE digest_pending ("
|
||||
"id INTEGER PRIMARY KEY, channel TEXT, event_type TEXT, "
|
||||
"event_group TEXT, severity TEXT, ts INTEGER, title TEXT, body TEXT)"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
with mock.patch.object(notification_manager, "DB_PATH", db_path), \
|
||||
mock.patch.object(notification_manager.time, "time", side_effect=(1000, 1060, 1070, 1080, 1301)):
|
||||
manager._buffer_digest_event(
|
||||
"telegram", "lxc_update_applied", "vm_ct", "INFO",
|
||||
"pve01: LXC wireguard (101) update completed", "Source: Manual",
|
||||
)
|
||||
# An adjacent collector can report the same completion with
|
||||
# a different source or duration. It must not appear twice.
|
||||
manager._buffer_digest_event(
|
||||
"telegram", "lxc_update_applied", "vm_ct", "INFO",
|
||||
"pve01: LXC wireguard (101) update completed", "Source: Scheduled",
|
||||
)
|
||||
# Other event types still require the full message to match.
|
||||
manager._buffer_digest_event(
|
||||
"telegram", "app_update_available", "applications", "INFO",
|
||||
"pve01: Update available", "Version: 1.0 → 1.1",
|
||||
)
|
||||
manager._buffer_digest_event(
|
||||
"telegram", "app_update_available", "applications", "INFO",
|
||||
"pve01: Update available", "Version: 1.0 → 1.2",
|
||||
)
|
||||
# The same result is allowed again outside the short window.
|
||||
manager._buffer_digest_event(
|
||||
"telegram", "lxc_update_applied", "vm_ct", "INFO",
|
||||
"pve01: LXC wireguard (101) update completed", "Source: Manual",
|
||||
)
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
rows = conn.execute(
|
||||
"SELECT ts, body FROM digest_pending ORDER BY id"
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
self.assertEqual(
|
||||
rows,
|
||||
[
|
||||
(1000, "Source: Manual"),
|
||||
(1070, "Version: 1.0 → 1.1"),
|
||||
(1080, "Version: 1.0 → 1.2"),
|
||||
(1301, "Source: Manual"),
|
||||
],
|
||||
)
|
||||
|
||||
def test_visible_templates_use_known_backend_and_frontend_groups(self):
|
||||
visible_groups = {
|
||||
template.get("group", "other")
|
||||
for template in notification_templates.TEMPLATES.values()
|
||||
if not template.get("hidden", False)
|
||||
}
|
||||
backend_groups = set(notification_templates.EVENT_GROUPS)
|
||||
|
||||
source = (APPIMAGE_DIR / "components" / "notification-settings.tsx").read_text(encoding="utf-8")
|
||||
match = re.search(r"const EVENT_CATEGORIES = \[(.*?)\]\.map", source)
|
||||
self.assertIsNotNone(match)
|
||||
frontend_groups = set(re.findall(r'"([a-z_]+)"', match.group(1)))
|
||||
|
||||
self.assertEqual(visible_groups - backend_groups, set())
|
||||
self.assertEqual(visible_groups - frontend_groups, set())
|
||||
self.assertEqual(frontend_groups, backend_groups)
|
||||
for language in self.RUNTIME_LANGUAGES:
|
||||
catalog = json.loads(
|
||||
(APPIMAGE_DIR / "messages" / language / "common.json").read_text(encoding="utf-8")
|
||||
)
|
||||
self.assertEqual(
|
||||
set(catalog["settings"]["notifications"]["categories"]),
|
||||
frontend_groups,
|
||||
language,
|
||||
)
|
||||
|
||||
def test_build_bundles_runtime_catalogs(self):
|
||||
build = (SCRIPTS_DIR / "build_appimage.sh").read_text(encoding="utf-8")
|
||||
for language in self.RUNTIME_LANGUAGES:
|
||||
self.assertIn(language, build)
|
||||
self.assertIn('$APP_DIR/usr/share/proxmenux/messages', build)
|
||||
|
||||
def test_missing_event_type_names_exist_in_both_ui_catalogs(self):
|
||||
for language in self.RUNTIME_LANGUAGES:
|
||||
path = APPIMAGE_DIR / "messages" / language / "common.json"
|
||||
event_types = json.loads(path.read_text(encoding="utf-8"))["settings"]["notifications"]["eventTypes"]
|
||||
self.assertIn("lxc_update_applied", event_types)
|
||||
self.assertIn("docker_stack_update_available", event_types)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,380 @@
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
SCRIPTS_DIR = Path(__file__).resolve().parents[1]
|
||||
if str(SCRIPTS_DIR) not in sys.path:
|
||||
sys.path.insert(0, str(SCRIPTS_DIR))
|
||||
|
||||
import notification_manager
|
||||
import notification_templates
|
||||
from notification_channels import EmailChannel, TelegramChannel
|
||||
from notification_manager import NotificationManager
|
||||
from notification_templates import render_template
|
||||
|
||||
|
||||
AI_CONFIG = {
|
||||
"ai_enabled": "true",
|
||||
"ai_provider": "ollama",
|
||||
"ai_ollama_url": "http://localhost:11434",
|
||||
"ai_model": "test-model",
|
||||
"ai_language": "en",
|
||||
}
|
||||
|
||||
|
||||
def _vzdump_report(count=49, failed=False):
|
||||
header = "{:<8}{:<22}{:<10}{:<10}{:<14}{}".format(
|
||||
"VMID", "Name", "Status", "Time", "Size", "Filename"
|
||||
)
|
||||
rows = []
|
||||
for index in range(count):
|
||||
vmid = 100 + index
|
||||
status = "ERROR" if failed and index == count - 1 else "OK"
|
||||
rows.append(
|
||||
"{:<8}{:<22}{:<10}{:<10}{:<14}{}".format(
|
||||
str(vmid),
|
||||
f"guest-{vmid}",
|
||||
status,
|
||||
f"00:00:{index + 10:02d}",
|
||||
f"{index + 1}.25 GiB",
|
||||
f"/mnt/pve/archive/dump/vzdump-lxc-{vmid}-2026_09_15-01_00_00.tar.zst",
|
||||
)
|
||||
)
|
||||
return (
|
||||
"Proxmox vzdump report\n\n"
|
||||
+ header
|
||||
+ "\n"
|
||||
+ "\n".join(rows)
|
||||
+ "\nTotal running time: 00:49:00\nTotal size: 1225.25 GiB\n"
|
||||
)
|
||||
|
||||
|
||||
def _render(event_type, failed=False):
|
||||
return render_template(
|
||||
event_type,
|
||||
{
|
||||
"hostname": "pve-a",
|
||||
"storage": "archive",
|
||||
"vmname": "49 guests",
|
||||
"vmid": "batch",
|
||||
"size": "1225.25 GiB",
|
||||
"reason": "last guest failed" if failed else "",
|
||||
"pve_title": "pve-a: vzdump backup status",
|
||||
"pve_message": _vzdump_report(failed=failed),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class _CapturingChannel:
|
||||
def __init__(self):
|
||||
self.calls = []
|
||||
|
||||
def send(self, title, body, severity, data):
|
||||
self.calls.append((title, body, severity, data))
|
||||
return {"success": True, "error": None}
|
||||
|
||||
|
||||
class _ReplacingEnhancer:
|
||||
calls = 0
|
||||
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def enhance(self, *args, **kwargs):
|
||||
type(self).calls += 1
|
||||
return {"title": "AI shortened title", "body": "AI kept only one guest."}
|
||||
|
||||
|
||||
class _FailingEnhancer:
|
||||
def __init__(self, config):
|
||||
self.config = config
|
||||
|
||||
def enhance(self, *args, **kwargs):
|
||||
return None
|
||||
|
||||
|
||||
class VzdumpAIIntegrityTests(unittest.TestCase):
|
||||
def setUp(self):
|
||||
with notification_templates._AI_CACHE_LOCK:
|
||||
notification_templates._AI_CACHE.clear()
|
||||
_ReplacingEnhancer.calls = 0
|
||||
|
||||
def _dispatch(self, event_type, rendered, enhancer):
|
||||
manager = NotificationManager()
|
||||
channel = _CapturingChannel()
|
||||
manager._channels = {"telegram": channel}
|
||||
with (
|
||||
patch.object(manager, "_build_ai_config", return_value=dict(AI_CONFIG)),
|
||||
patch.object(manager, "_record_history"),
|
||||
patch.object(notification_manager, "enrich_context_for_ai", return_value=""),
|
||||
patch.object(notification_templates, "AIEnhancer", enhancer),
|
||||
):
|
||||
delivered = manager._dispatch_to_channels(
|
||||
rendered["title"], rendered["body"],
|
||||
"CRITICAL" if event_type == "backup_fail" else "INFO",
|
||||
event_type, {}, "test",
|
||||
)
|
||||
self.assertTrue(delivered)
|
||||
self.assertEqual(len(channel.calls), 1)
|
||||
return channel.calls[0]
|
||||
|
||||
def _send_public(self, event_type, title, body):
|
||||
manager = NotificationManager()
|
||||
channel = _CapturingChannel()
|
||||
manager._channels = {"telegram": channel}
|
||||
manager._config = {"telegram.rich_format": "false"}
|
||||
with (
|
||||
patch.object(manager, "_build_ai_config", return_value=dict(AI_CONFIG)),
|
||||
patch.object(manager, "_record_history"),
|
||||
patch.object(notification_templates, "AIEnhancer", _ReplacingEnhancer),
|
||||
):
|
||||
result = manager.send_notification(
|
||||
event_type,
|
||||
"CRITICAL" if event_type == "backup_fail" else "INFO",
|
||||
title,
|
||||
body,
|
||||
source="test",
|
||||
skip_toggle_check=True,
|
||||
)
|
||||
self.assertTrue(result["success"])
|
||||
self.assertEqual(len(channel.calls), 1)
|
||||
return channel.calls[0]
|
||||
|
||||
def test_public_send_backup_complete_skips_ai_and_keeps_all_49_items(self):
|
||||
rendered = _render("backup_complete")
|
||||
|
||||
title, body, _, _ = self._send_public(
|
||||
"backup_complete", rendered["title"], rendered["body"]
|
||||
)
|
||||
|
||||
self.assertEqual(title, rendered["title"])
|
||||
self.assertEqual(body, rendered["body"])
|
||||
self.assertEqual(_ReplacingEnhancer.calls, 0)
|
||||
self.assertEqual(body.count("✅ CT guest-"), 49)
|
||||
self.assertIn("✅ CT guest-100 (100)", body)
|
||||
self.assertIn("✅ CT guest-148 (148)", body)
|
||||
|
||||
def test_public_send_backup_fail_skips_ai_and_keeps_all_49_items(self):
|
||||
rendered = _render("backup_fail", failed=True)
|
||||
|
||||
title, body, _, _ = self._send_public(
|
||||
"backup_fail", rendered["title"], rendered["body"]
|
||||
)
|
||||
|
||||
self.assertEqual(title, rendered["title"])
|
||||
self.assertEqual(body, rendered["body"])
|
||||
self.assertEqual(_ReplacingEnhancer.calls, 0)
|
||||
self.assertEqual(body.count("✅ CT guest-") + body.count("❌ CT guest-"), 49)
|
||||
self.assertIn("✅ CT guest-100 (100)", body)
|
||||
self.assertIn("❌ CT guest-148 (148)", body)
|
||||
|
||||
def test_public_send_non_backup_event_still_uses_ai(self):
|
||||
title, body, _, _ = self._send_public(
|
||||
"cpu_high", "pve-a: CPU high", "CPU reached 95%."
|
||||
)
|
||||
|
||||
self.assertEqual(title, "AI shortened title")
|
||||
self.assertEqual(body, "AI kept only one guest.")
|
||||
self.assertEqual(_ReplacingEnhancer.calls, 1)
|
||||
|
||||
def test_backup_complete_skips_ai_and_keeps_full_49_item_inventory(self):
|
||||
rendered = _render("backup_complete")
|
||||
|
||||
title, body, _, _ = self._dispatch(
|
||||
"backup_complete", rendered, _ReplacingEnhancer
|
||||
)
|
||||
|
||||
self.assertEqual(title, rendered["title"])
|
||||
self.assertEqual(body, rendered["body"])
|
||||
self.assertEqual(_ReplacingEnhancer.calls, 0)
|
||||
self.assertIn("✅ CT guest-100 (100)", body)
|
||||
self.assertIn("📏 Size: 1.25 GiB | ⏱️ Duration: 00:00:10", body)
|
||||
self.assertIn("✅ CT guest-148 (148)", body)
|
||||
self.assertIn("📏 Size: 49.25 GiB | ⏱️ Duration: 00:00:58", body)
|
||||
self.assertIn("📊 49 backups", body)
|
||||
self.assertEqual(body.count("✅ CT guest-"), 49)
|
||||
|
||||
def test_backup_fail_skips_ai_and_keeps_inventory_and_failure_details(self):
|
||||
rendered = _render("backup_fail", failed=True)
|
||||
|
||||
title, body, _, _ = self._dispatch(
|
||||
"backup_fail", rendered, _ReplacingEnhancer
|
||||
)
|
||||
|
||||
self.assertEqual(title, rendered["title"])
|
||||
self.assertEqual(body, rendered["body"])
|
||||
self.assertEqual(_ReplacingEnhancer.calls, 0)
|
||||
self.assertIn("✅ CT guest-100 (100)", body)
|
||||
self.assertIn("❌ CT guest-148 (148)", body)
|
||||
self.assertIn("📏 Size: 49.25 GiB | ⏱️ Duration: 00:00:58", body)
|
||||
self.assertIn("📊 49 backups | ❌ 1 failed", body)
|
||||
self.assertEqual(body.count("✅ CT guest-"), 48)
|
||||
|
||||
def test_backup_ai_failure_sends_original_title_and_body_unchanged(self):
|
||||
rendered = _render("backup_complete")
|
||||
|
||||
title, body, _, _ = self._dispatch(
|
||||
"backup_complete", rendered, _FailingEnhancer
|
||||
)
|
||||
|
||||
self.assertEqual(title, rendered["title"])
|
||||
self.assertEqual(body, rendered["body"])
|
||||
|
||||
def test_non_backup_event_retains_existing_ai_rewrite(self):
|
||||
rendered = {"title": "pve-a: CPU high", "body": "CPU reached 95%."}
|
||||
|
||||
title, body, _, _ = self._dispatch(
|
||||
"cpu_high", rendered, _ReplacingEnhancer
|
||||
)
|
||||
|
||||
self.assertEqual(title, "AI shortened title")
|
||||
self.assertEqual(body, "AI kept only one guest.")
|
||||
|
||||
def test_backup_complete_email_html_contains_each_inventory_edge_and_summary_once(self):
|
||||
rendered = _render("backup_complete")
|
||||
channel = EmailChannel({})
|
||||
data = {
|
||||
"_event_type": "backup_complete",
|
||||
"_group": "backup",
|
||||
"hostname": "pve-a",
|
||||
"storage": "archive",
|
||||
"vmname": "49 guests",
|
||||
"vmid": "batch",
|
||||
"size": "1225.25 GiB",
|
||||
}
|
||||
|
||||
html = channel._format_html(
|
||||
"[ProxMenux] [INFO] " + rendered["title"],
|
||||
rendered["body"], "INFO", data,
|
||||
)
|
||||
|
||||
for vmid in range(100, 149):
|
||||
self.assertEqual(html.count(f"guest-{vmid} ({vmid})"), 1, vmid)
|
||||
self.assertEqual(html.count("49 backups"), 1)
|
||||
|
||||
def test_backup_fail_email_html_keeps_inventory_and_localized_status_once(self):
|
||||
rendered = _render("backup_fail", failed=True)
|
||||
channel = EmailChannel({})
|
||||
data = {
|
||||
"_event_type": "backup_fail",
|
||||
"_group": "backup",
|
||||
"_notification_language": "sk",
|
||||
"hostname": "pve-a",
|
||||
"storage": "archive",
|
||||
"vmname": "49 guests",
|
||||
"vmid": "batch",
|
||||
"status": "failed",
|
||||
"size": "1225.25 GiB",
|
||||
"reason": "last guest failed",
|
||||
}
|
||||
|
||||
html = channel._format_html(
|
||||
"[ProxMenux] [CRITICAL] " + rendered["title"],
|
||||
rendered["body"], "CRITICAL", data,
|
||||
)
|
||||
|
||||
for vmid in range(100, 149):
|
||||
self.assertEqual(html.count(f"guest-{vmid} ({vmid})"), 1, vmid)
|
||||
self.assertEqual(html.count("49 backups"), 1)
|
||||
self.assertEqual(html.count("1 failed"), 1)
|
||||
self.assertEqual(html.count(">Zlyhalo<"), 1)
|
||||
self.assertNotIn(">Failed<", html)
|
||||
self.assertLessEqual(html.count("last guest failed"), 1)
|
||||
|
||||
def test_telegram_chunks_preserve_complete_49_item_message(self):
|
||||
rendered = _render("backup_complete")
|
||||
body = rendered["body"]
|
||||
channel = TelegramChannel("123:token", "456")
|
||||
html_message = (
|
||||
f"<b>🔵 {channel._escape_html(rendered['title'])}</b>\n\n"
|
||||
f"{channel._escape_html(body)}"
|
||||
)
|
||||
|
||||
chunks = channel._split_message(html_message)
|
||||
|
||||
self.assertGreater(len(chunks), 1)
|
||||
self.assertTrue(all(len(chunk) <= 4096 for chunk in chunks))
|
||||
joined = "".join(chunks)
|
||||
self.assertIn("guest-100 (100)", joined)
|
||||
self.assertIn("guest-148 (148)", joined)
|
||||
self.assertIn("49.25 GiB", joined)
|
||||
|
||||
def test_telegram_chunks_do_not_split_entities_or_open_tags(self):
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class _BalancedParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=False)
|
||||
self.stack = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.stack.append(tag)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if not self.stack or self.stack.pop() != tag:
|
||||
raise AssertionError(f"unbalanced closing tag: {tag}")
|
||||
|
||||
channel = TelegramChannel("123:token", "456")
|
||||
html_message = "<b>" + ("A & B " * 900) + "</b>"
|
||||
|
||||
chunks = channel._split_message(html_message)
|
||||
|
||||
self.assertGreater(len(chunks), 1)
|
||||
self.assertTrue(all(len(chunk) <= 4096 for chunk in chunks))
|
||||
for chunk in chunks:
|
||||
parser = _BalancedParser()
|
||||
parser.feed(chunk)
|
||||
parser.close()
|
||||
self.assertEqual(parser.stack, [])
|
||||
self.assertNotRegex(chunk, r"&(?:amp)?$")
|
||||
self.assertNotRegex(chunk, r"^amp;")
|
||||
|
||||
def test_telegram_chunks_bound_an_oversized_entity(self):
|
||||
channel = TelegramChannel("123:token", "456")
|
||||
chunks = channel._split_message("&" + ("entity" * 900) + ";")
|
||||
|
||||
self.assertTrue(chunks)
|
||||
self.assertTrue(all(len(chunk) <= 4096 for chunk in chunks))
|
||||
|
||||
def test_telegram_chunks_bound_an_oversized_tag(self):
|
||||
channel = TelegramChannel("123:token", "456")
|
||||
html_message = '<b data-value="' + ("x" * 5000) + '">visible text</b>'
|
||||
chunks = channel._split_message(html_message)
|
||||
|
||||
self.assertTrue(chunks)
|
||||
self.assertTrue(all(len(chunk) <= 4096 for chunk in chunks))
|
||||
self.assertIn("visible text", "".join(chunks))
|
||||
|
||||
def test_telegram_chunks_bound_deeply_nested_formatting(self):
|
||||
from html.parser import HTMLParser
|
||||
|
||||
class _BalancedParser(HTMLParser):
|
||||
def __init__(self):
|
||||
super().__init__(convert_charrefs=False)
|
||||
self.stack = []
|
||||
|
||||
def handle_starttag(self, tag, attrs):
|
||||
self.stack.append(tag)
|
||||
|
||||
def handle_endtag(self, tag):
|
||||
if not self.stack or self.stack.pop() != tag:
|
||||
raise AssertionError(f"unbalanced closing tag: {tag}")
|
||||
|
||||
html_message = ("<b>" * 700) + ("A" * 5000) + ("</b>" * 700)
|
||||
channel = TelegramChannel("123:token", "456")
|
||||
chunks = channel._split_message(html_message)
|
||||
|
||||
self.assertTrue(chunks)
|
||||
self.assertTrue(all(len(chunk) <= 4096 for chunk in chunks))
|
||||
self.assertEqual("".join(chunks).count("A"), 5000)
|
||||
for chunk in chunks:
|
||||
parser = _BalancedParser()
|
||||
parser.feed(chunk)
|
||||
parser.close()
|
||||
self.assertEqual(parser.stack, [])
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Reference in New Issue
Block a user