mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-09-19 21:26:47 +00:00
Localize runtime notifications in Slovak
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
|
||||
@@ -199,6 +200,11 @@ const AI_PROVIDERS = [
|
||||
},
|
||||
]
|
||||
|
||||
const NOTIFICATION_LANGUAGES = [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "sk", label: "Slovenčina" },
|
||||
]
|
||||
|
||||
const AI_LANGUAGES = [
|
||||
{ value: "en", label: "English" },
|
||||
{ value: "sk", label: "Slovenčina" },
|
||||
@@ -284,6 +290,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 +408,7 @@ 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: data.config.notification_language || data.config.ai_language || "en",
|
||||
}
|
||||
// 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 +842,7 @@ export function NotificationSettings() {
|
||||
ai_enabled: String(cfg.ai_enabled),
|
||||
ai_provider: cfg.ai_provider,
|
||||
ai_model: cfg.ai_model,
|
||||
notification_language: cfg.notification_language,
|
||||
ai_language: cfg.ai_language,
|
||||
ai_ollama_url: cfg.ai_ollama_url,
|
||||
ai_openai_base_url: cfg.ai_openai_base_url,
|
||||
@@ -2176,6 +2185,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={config.notification_language || config.ai_language || "en"}
|
||||
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">
|
||||
|
||||
@@ -163,6 +163,10 @@ 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.
|
||||
mkdir -p "$APP_DIR/usr/share/proxmenux/messages/en" "$APP_DIR/usr/share/proxmenux/messages/sk"
|
||||
cp "$APPIMAGE_ROOT/messages/en/common.json" "$APP_DIR/usr/share/proxmenux/messages/en/common.json"
|
||||
cp "$APPIMAGE_ROOT/messages/sk/common.json" "$APP_DIR/usr/share/proxmenux/messages/sk/common.json"
|
||||
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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -645,11 +657,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):
|
||||
@@ -969,12 +981,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())
|
||||
@@ -1029,7 +1046,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>'''
|
||||
|
||||
@@ -1039,7 +1056,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;">
|
||||
@@ -1050,7 +1067,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>
|
||||
@@ -1070,7 +1087,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)}
|
||||
@@ -1090,7 +1107,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>
|
||||
@@ -1110,11 +1127,34 @@ 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 _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 = {
|
||||
@@ -1136,7 +1176,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'))
|
||||
|
||||
|
||||
@@ -17,9 +17,62 @@ 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
|
||||
|
||||
|
||||
# ─── vzdump message parser ───────────────────────────────────────
|
||||
|
||||
def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]:
|
||||
@@ -248,7 +301,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 +339,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 +356,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 +374,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,88 +388,67 @@ 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]:
|
||||
@@ -1568,7 +1601,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 +1610,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 +1660,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': '',
|
||||
@@ -1626,9 +1676,9 @@ 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')
|
||||
# Ensure important_list is never blank (fallback to localized "none")
|
||||
if not variables.get('important_list', '').strip():
|
||||
variables['important_list'] = '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 +1707,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 +1736,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 +1747,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 +1774,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 = []
|
||||
|
||||
Reference in New Issue
Block a user