Merge pull request #408 from f3rs3n/fix/active-notification-payloads

Fix active health and temperature notification payloads
This commit is contained in:
MacRimi
2026-09-29 17:06:07 +02:00
committed by GitHub
17 changed files with 651 additions and 41 deletions
+1
View File
@@ -19,6 +19,7 @@ PYTHON_FILES = (
"tests/test_audit_catalog.py",
"tests/storage/test_nvme_status_message.py",
"tests/test_fastfetch_config_generation.py",
"tests/test_notification_active_payloads.py",
)
NODE_FILES = (
"tests/lxc_updates/test_docker_delegated_ui.cjs",
@@ -122,6 +122,15 @@ class CommandDescriptionsTests(unittest.TestCase):
"temperatureIncomplete", "temperatureUnavailableCount",
):
temporary["storage"].setdefault(key, catalog("en")["storage"][key])
# The temperature alert fallback is new English-only runtime
# text until locale migration. Seed only its four new keys
# in this disposable steady-state catalog; shipped locales
# remain unchanged and runtime fallback is tested separately.
source_fallback = catalog("en")["runtime"]["notifications"]["fallback"]
fallback = temporary["runtime"]["notifications"]["fallback"]
for key in ("temperatureAlertTitle", "temperatureAlertBody",
"recordedReason", "recordedDetails"):
fallback.setdefault(key, source_fallback[key])
path.write_text(json.dumps(temporary, ensure_ascii=False))
# Model steady state after the bot fills these intentional new
# messages; keep repository locales and all other leaves intact.
+1 -1
View File
@@ -54,7 +54,7 @@ jobs:
- uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Run seven qualified Python files
- name: Run eight qualified Python files
env:
PYTHONDONTWRITEBYTECODE: '1'
run: python3 -I -B .github/scripts/run_offline_qualified.py --lane python
+10 -3
View File
@@ -6446,8 +6446,8 @@
"label": "Hohe Speichernutzung"
},
"temp_high": {
"title": "{hostname}: Hohe CPU-Temperatur – {value}°C",
"body": "Die CPU-Temperatur hat {value}°C erreicht (Schwellenwert: {threshold}°C).\n{details}",
"title": "{hostname}: Hohe Sensortemperatur – {value}°C",
"body": "Die Sensortemperatur hat {value}°C erreicht (Schwellenwert: {threshold}°C).\n{details}",
"label": "Hohe Temperatur"
},
"disk_space_low": {
@@ -6781,7 +6781,11 @@
"fallback": {
"unknownTitle": "{hostname}: {event_type}",
"healthCheckDegraded": "Gesundheitsprüfung beeinträchtigt",
"none": "keine"
"none": "keine",
"temperatureAlertTitle": "{hostname}: Warnung zur Sensortemperatur",
"temperatureAlertBody": "Ein Temperaturalarm wurde ohne vollständige Messdaten erfasst.",
"recordedReason": "Erfasster Grund: {reason}",
"recordedDetails": "Erfasste Details: {details}"
},
"fields": {
"vmid": "VM/CT",
@@ -6981,6 +6985,9 @@
"host": "Gastgeber",
"footer": "ProxMenux Benachrichtigungsdienst"
}
},
"temperature": {
"sampleSpan": "Die hohen Messwerte erstrecken sich über {duration}."
}
}
}
File diff suppressed because one or more lines are too long
+10 -3
View File
@@ -6446,8 +6446,8 @@
"label": "Uso elevado de memoria"
},
"temp_high": {
"title": "{hostname}: Temperatura alta de la CPU: {value}°C",
"body": "La temperatura de la CPU ha alcanzado {value}°C (umbral: {threshold}°C).\n{details}",
"title": "{hostname}: Temperatura elevada del sensor — {value}°C",
"body": "La temperatura del sensor ha alcanzado {value}°C (umbral: {threshold}°C).\n{details}",
"label": "Alta temperatura"
},
"disk_space_low": {
@@ -6781,7 +6781,11 @@
"fallback": {
"unknownTitle": "{hostname}: {event_type}",
"healthCheckDegraded": "Comprobación de salud degradada",
"none": "ninguno"
"none": "ninguno",
"temperatureAlertTitle": "{hostname}: Alerta de temperatura del sensor",
"temperatureAlertBody": "Se registró una alerta de temperatura sin datos de medición completos.",
"recordedReason": "Motivo registrado: {reason}",
"recordedDetails": "Detalles registrados: {details}"
},
"fields": {
"vmid": "VM/CT",
@@ -6981,6 +6985,9 @@
"host": "Host",
"footer": "Servicio de notificaciones de ProxMenux"
}
},
"temperature": {
"sampleSpan": "Las muestras elevadas abarcan {duration}."
}
}
}
+10 -3
View File
@@ -6446,8 +6446,8 @@
"label": "Utilisation élevée de la mémoire"
},
"temp_high": {
"title": "{hostname} : température élevée du processeur – {value}°C",
"body": "La température du processeur a atteint {value}°C (seuil : {threshold}°C).\n{details}",
"title": "{hostname} : température élevée du capteur – {value}°C",
"body": "La température du capteur a atteint {value}°C (seuil : {threshold}°C).\n{details}",
"label": "Haute température"
},
"disk_space_low": {
@@ -6781,7 +6781,11 @@
"fallback": {
"unknownTitle": "{hostname} : {event_type}",
"healthCheckDegraded": "Bilan de santé dégradé",
"none": "aucun"
"none": "aucun",
"temperatureAlertTitle": "{hostname} : alerte de température du capteur",
"temperatureAlertBody": "Une alerte de température a été enregistrée sans données de mesure complètes.",
"recordedReason": "Motif enregistré : {reason}",
"recordedDetails": "Détails enregistrés : {details}"
},
"fields": {
"vmid": "VM/CT",
@@ -6981,6 +6985,9 @@
"host": "Hôte",
"footer": "ProxMenux Service de notification"
}
},
"temperature": {
"sampleSpan": "Les relevés élevés s'étendent sur {duration}."
}
}
}
+10 -3
View File
@@ -6446,8 +6446,8 @@
"label": "Utilizzo elevato della memoria"
},
"temp_high": {
"title": "{hostname}: temperatura elevata della CPU — {value}°C",
"body": "La temperatura della CPU ha raggiunto {value}°C (soglia: {threshold}°C).\n{details}",
"title": "{hostname}: temperatura elevata del sensore — {value}°C",
"body": "La temperatura del sensore ha raggiunto {value}°C (soglia: {threshold}°C).\n{details}",
"label": "Alta temperatura"
},
"disk_space_low": {
@@ -6781,7 +6781,11 @@
"fallback": {
"unknownTitle": "{hostname}: {event_type}",
"healthCheckDegraded": "Controllo sanitario degradato",
"none": "nessuno"
"none": "nessuno",
"temperatureAlertTitle": "{hostname}: avviso sulla temperatura del sensore",
"temperatureAlertBody": "È stato registrato un avviso di temperatura senza dati di misurazione completi.",
"recordedReason": "Motivo registrato: {reason}",
"recordedDetails": "Dettagli registrati: {details}"
},
"fields": {
"vmid": "VM/CT",
@@ -6981,6 +6985,9 @@
"host": "Ospite",
"footer": "ProxMenux Servizio di notifica"
}
},
"temperature": {
"sampleSpan": "Intervallo dei campioni sopra soglia: {duration}."
}
}
}
+10 -3
View File
@@ -6446,8 +6446,8 @@
"label": "Alto uso de memória"
},
"temp_high": {
"title": "{hostname}: Alta temperatura da CPU - {value}°C",
"body": "A temperatura da CPU atingiu {value}°C (limite: {threshold}°C).\n{details}",
"title": "{hostname}: Temperatura elevada do sensor — {value}°C",
"body": "A temperatura do sensor atingiu {value}°C (limite: {threshold}°C).\n{details}",
"label": "Alta temperatura"
},
"disk_space_low": {
@@ -6781,7 +6781,11 @@
"fallback": {
"unknownTitle": "{hostname}: {event_type}",
"healthCheckDegraded": "Exame de saúde degradado",
"none": "nenhum"
"none": "nenhum",
"temperatureAlertTitle": "{hostname}: Alerta de temperatura do sensor",
"temperatureAlertBody": "Foi registado um alerta de temperatura sem dados de medição completos.",
"recordedReason": "Motivo registado: {reason}",
"recordedDetails": "Detalhes registados: {details}"
},
"fields": {
"vmid": "VM/CT",
@@ -6981,6 +6985,9 @@
"host": "Anfitrião",
"footer": "Serviço de Notificação ProxMenux"
}
},
"temperature": {
"sampleSpan": "As amostras elevadas abrangem {duration}."
}
}
}
+10 -3
View File
@@ -6445,8 +6445,8 @@
"label": "Vysoké využitie pamäte"
},
"temp_high": {
"title": "{hostname}: Vysoká teplota CPU — {value}°C",
"body": "Teplota CPU dosiahla {value}°C (prahová hodnota: {threshold}°C).\n{details}",
"title": "{hostname}: Vysoká teplota snímača — {value}°C",
"body": "Teplota snímača dosiahla {value}°C (prahová hodnota: {threshold}°C).\n{details}",
"label": "Vysoká teplota"
},
"disk_space_low": {
@@ -6780,7 +6780,11 @@
"fallback": {
"unknownTitle": "{hostname}: {event_type}",
"healthCheckDegraded": "Zhoršený stav kontroly",
"none": "žiadne"
"none": "žiadne",
"temperatureAlertTitle": "{hostname}: Upozornenie na teplotu snímača",
"temperatureAlertBody": "Upozornenie na teplotu bolo zaznamenané bez úplných údajov o meraní.",
"recordedReason": "Zaznamenaný dôvod: {reason}",
"recordedDetails": "Zaznamenané podrobnosti: {details}"
},
"fields": {
"vmid": "VM/CT",
@@ -6980,6 +6984,9 @@
"started": "Spustené"
}
}
},
"temperature": {
"sampleSpan": "Vysoké vzorky pokrývajú interval {duration}."
}
}
}
+10 -3
View File
@@ -6446,8 +6446,8 @@
"label": "Hög minnesanvändning"
},
"temp_high": {
"title": "{hostname}: Hög CPU-temperatur — {value}°C",
"body": "CPU-temperaturen har nått {value}°C (tröskel: {threshold}°C).\n{details}",
"title": "{hostname}: Hög sensortemperatur — {value}°C",
"body": "Sensortemperaturen har nått {value}°C (tröskel: {threshold}°C).\n{details}",
"label": "Hög temperatur"
},
"disk_space_low": {
@@ -6781,7 +6781,11 @@
"fallback": {
"unknownTitle": "{hostname}: {event_type}",
"healthCheckDegraded": "Hälsokontroll försämrad",
"none": "ingen"
"none": "ingen",
"temperatureAlertTitle": "{hostname}: Varning om sensortemperatur",
"temperatureAlertBody": "En temperaturvarning registrerades utan fullständiga mätdata.",
"recordedReason": "Registrerad orsak: {reason}",
"recordedDetails": "Registrerade detaljer: {details}"
},
"fields": {
"vmid": "VM/CT",
@@ -6981,6 +6985,9 @@
"host": "Värd",
"footer": "ProxMenux Notification Service"
}
},
"temperature": {
"sampleSpan": "De höga mätvärdena sträcker sig över {duration}."
}
}
}
+13 -6
View File
@@ -1503,10 +1503,10 @@ class HealthMonitor:
def _check_cpu_temperature(self) -> Optional[Dict[str, Any]]:
"""
Check CPU temperature with temporal logic:
Check the maximum reported sensor temperature with temporal logic:
- WARNING if temp >80°C sustained for >3 minutes
- Auto-clears if temp ≤80°C for 30 seconds
- No dismiss button (non-dismissable)
- Recorded errors retain their current dismissable flag
"""
cache_key = 'cpu_temp'
current_time = time.time()
@@ -1574,15 +1574,22 @@ class HealthMonitor:
duration_str = f'{actual_minutes}m {actual_seconds}s' if actual_minutes > 0 else f'{actual_seconds}s'
status = 'WARNING'
reason = f'CPU temperature {max_temp}°C >80°C sustained for {duration_str}'
reason = f'Sensor temperature {max_temp}°C >80°C; high samples span {duration_str}'
# Record non-dismissable error
# Record the existing dismissable error contract
health_persistence.record_error(
error_key='cpu_temperature',
category='temperature',
severity='WARNING',
reason=reason,
details={'temperature': max_temp, 'duration': actual_duration, 'dismissable': True}
details={
'temperature': max_temp, 'duration': actual_duration,
'dismissable': True, 'value': max_temp, 'threshold': 80,
'details': f'High samples span {duration_str}.',
# Explicit provenance for render-time localization;
# older/manual detail strings remain verbatim.
'temperature_detail_kind': 'high_samples_span',
}
)
elif len(recovery_samples) >= 3:
# Temperature has been ≤80°C for 30 seconds - clear the error
@@ -1595,7 +1602,7 @@ class HealthMonitor:
if health_persistence.is_error_active('cpu_temperature', category='temperature'):
# Keep the warning active
status = 'WARNING'
reason = f'CPU temperature {max_temp}°C still elevated'
reason = f'Sensor temperature {max_temp}°C still elevated'
else:
# No active warning yet
status = 'OK'
+17 -5
View File
@@ -1038,6 +1038,11 @@ class EmailChannel(NotificationChannel):
# Determine group for section header
event_type = data.get('_event_type', '')
group = data.get('_group', 'other')
# Keep unbroken recorded text inside the temperature email's table.
# Both properties are inline for mail clients; other events retain
# their original markup and layout.
temp_cell_wrap = 'word-wrap:break-word;overflow-wrap:break-word;word-break:break-word;' if event_type == 'temp_high' else ''
temp_table_layout = 'table-layout:fixed;' if event_type == 'temp_high' else ''
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)
@@ -1049,6 +1054,13 @@ class EmailChannel(NotificationChannel):
# ── Build structured detail rows from known data fields ──
detail_rows = self._build_detail_rows(data, event_type, group, html_mod)
# The temperature renderer has already validated whether both
# measurements exist and localized its sampled-span detail. Its body
# is authoritative: raw fields can otherwise print a misleading °C
# value for an incomplete manual/persisted payload and hide the body.
if event_type == 'temp_high':
detail_rows = []
# 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.
@@ -1082,22 +1094,22 @@ class EmailChannel(NotificationChannel):
if label:
rows_html += f'''<tr>
<td style="padding:8px 12px;font-size:13px;color:#374151;font-weight:500;white-space:nowrap;vertical-align:top;border-bottom:1px solid #e5e7eb;">{label}</td>
<td style="padding:8px 12px;font-size:13px;color:#111827;border-bottom:1px solid #e5e7eb;">{value}</td>
<td style="padding:8px 12px;font-size:13px;color:#111827;border-bottom:1px solid #e5e7eb;{temp_cell_wrap}">{value}</td>
</tr>'''
else:
# Full-width row (no label, just description text)
rows_html += f'''<tr>
<td colspan="2" style="padding:8px 12px;font-size:13px;color:#1f2937;border-bottom:1px solid #e5e7eb;">{value}</td>
<td colspan="2" style="padding:8px 12px;font-size:13px;color:#1f2937;border-bottom:1px solid #e5e7eb;{temp_cell_wrap}">{value}</td>
</tr>'''
# ── Reason / details block (long text, displayed separately) ──
reason = data.get('reason', '')
reason_html = ''
if reason and len(reason) > 80:
if reason and len(reason) > 80 and not (event_type == 'temp_high' and reason in body):
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;">{_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>
<p style="margin:0;font-size:13px;color:#1f2937;line-height:1.6;white-space:pre-wrap;{temp_cell_wrap}">{html_mod.escape(reason)}</p>
</div>'''
# ── Clean subject for display (remove prefix if present) ──
@@ -1146,7 +1158,7 @@ class EmailChannel(NotificationChannel):
</table>
<!-- Detail table -->
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #d1d5db;border-radius:6px;overflow:hidden;">
<table width="100%" cellpadding="0" cellspacing="0" border="0" style="border:1px solid #d1d5db;border-radius:6px;overflow:hidden;{temp_table_layout}">
{rows_html}
</table>
+48 -4
View File
@@ -680,7 +680,7 @@ def _format_health_degraded(data: Dict[str, Any],
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 '')
category = str(item.get('key') or item.get('cat_key') or '')
return (
runtime_message(f'healthDegraded.categories.{category}', language)
or str(item.get('category') or category)
@@ -695,7 +695,7 @@ def _format_health_degraded(data: Dict[str, Any],
def localized_reason(item: Dict[str, Any]) -> str:
reason = str(item.get('reason') or '')
if str(item.get('key') or '') == 'cpu':
if str(item.get('key') or item.get('cat_key') or '') == 'cpu':
match = _CPU_SUSTAINED_REASON.fullmatch(reason)
if match:
return runtime_message(
@@ -1081,8 +1081,8 @@ TEMPLATES = {
'default_enabled': True,
},
'temp_high': {
'title': '{hostname}: High CPU temperature — {value}°C',
'body': 'CPU temperature has reached {value}°C (threshold: {threshold}°C).\n{details}',
'title': '{hostname}: High sensor temperature — {value}°C',
'body': 'Sensor temperature has reached {value}°C (threshold: {threshold}°C).\n{details}',
'label': 'High temperature',
'group': 'resources',
'default_enabled': True,
@@ -1885,6 +1885,50 @@ def render_template(event_type: str, data: Dict[str, Any],
}
variables.update(data)
# Old persisted errors and manual events may lack a complete reading.
# Accept plain numeric strings, but never interpret booleans or objects as
# measurements, nor display an infinite value or an overflowing integer.
if event_type == 'temp_high':
import math as _math
def finite_measurement(key):
raw = data.get(key)
if type(raw) not in (int, float, str):
return False
try:
return _math.isfinite(float(raw))
except (OverflowError, ValueError, TypeError):
return False
# Only the known sampler-generated detail is eligible: duration alone
# cannot establish provenance and manual/legacy text is not rewritten.
seconds = data.get('duration')
if (data.get('temperature_detail_kind') == 'high_samples_span'
and type(seconds) is int and 0 <= seconds <= 240):
span = (f'{seconds // 60}m {seconds % 60}s'
if seconds >= 60 else f'{seconds}s')
if data.get('details') == f'High samples span {span}.':
variables['details'] = runtime_message(
'temperature.sampleSpan', language, duration=span)
if not all(finite_measurement(key) for key in ('value', 'threshold')):
variables['_temperature_fallback_title'] = runtime_message(
'fallback.temperatureAlertTitle', language, hostname=variables['hostname'],
)
template['title'] = '{_temperature_fallback_title}'
lines = [runtime_message('fallback.temperatureAlertBody', language)]
reason = data.get('reason')
details = variables.get('details')
reason = reason.strip() if isinstance(reason, str) else ''
details = details.strip() if isinstance(details, str) else ''
if reason:
lines.append(runtime_message('fallback.recordedReason', language, reason=reason))
if details and details != reason:
lines.append(runtime_message('fallback.recordedDetails', language, details=details))
# Format the localized labels once; do not interpret braces in
# recorded raw text as template placeholders a second time.
variables['_temperature_fallback_body'] = '\n'.join(lines)
template['body'] = '{_temperature_fallback_body}'
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)
@@ -110,7 +110,12 @@ class RuntimeCatalogTests(unittest.TestCase):
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")
event_values = dict(values)
if event_type == "temp_high":
# Temperature is a measured numeric contract; arbitrary
# DYNAMIC_VALUE is correctly rejected by its fallback.
event_values.update(value="89", threshold="80")
rendered = notification_templates.render_template(event_type, event_values, language="sk")
combined = rendered["title"] + "\n" + rendered["body"]
if notification_templates.TEMPLATES[event_type].get("formatter"):
continue
@@ -119,7 +124,7 @@ class RuntimeCatalogTests(unittest.TestCase):
+ 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}")
self.assertIn(str(event_values[name]), combined, f"{event_type}:{name}")
def test_lxc_update_result_and_details_render_in_slovak(self):
data = {
+2 -1
View File
@@ -863,7 +863,8 @@ publication workflow and has read-only repository permissions.
#### Qualified offline `tests/` CI lanes
`.github/workflows/test-offline-qualified.yml` runs **only** the explicitly listed
seven Python files and ten Node files in `.github/scripts/run_offline_qualified.py`.
eight Python files (including the inert active-notification payload fixture) and ten
Node files in `.github/scripts/run_offline_qualified.py`.
Run the same allowlist locally from the root of a clean, disposable checkout
with no `AppImage/node_modules`. Do not run this over a normal Monitor install:
the commands refuse an existing directory, file, or symlink (including a
+482
View File
@@ -0,0 +1,482 @@
"""Inert actual-function tests for active notification payloads; no app imports."""
import ast
import copy
import html
import json
import math
import re
import sys
import time
import types
import unittest
from collections import defaultdict
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / 'AppImage/scripts'
MESSAGES = ROOT / 'AppImage/messages'
def extracted(path, name, cls=None, namespace=None):
tree = ast.parse((SCRIPTS / path).read_text())
nodes = tree.body
if cls:
nodes = next(n for n in nodes if isinstance(n, ast.ClassDef) and n.name == cls).body
node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name)
# Never execute module code or decorators; no operational module imports.
node = copy.deepcopy(node)
node.decorator_list = []
scope = namespace if namespace is not None else {}
exec(compile(ast.fix_missing_locations(ast.Module(body=[node], type_ignores=[])),
'<extracted ' + name + '>', 'exec'), scope)
return scope[name]
def render_fixture(language='en', overlays=None, missing=(), translations=None):
catalogs = {lang: json.loads((MESSAGES / lang / 'common.json').read_text())['runtime']['notifications']
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv')}
if language == 'synthetic':
catalogs[language] = {'templates': {'temp_high': {
'title': 'Synthetic {value}°C', 'body': 'Synthetic {threshold}°C\n{details}'}}}
if translations:
catalogs[language].update(translations)
for key in missing:
section, leaf = key.split('.', 1)
catalogs[language].get(section, {}).pop(leaf, None)
if overlays:
for lang, title, body in overlays:
catalogs[lang]['templates']['temp_high'].update(title=title, body=body)
def lookup(lang, key):
value = catalogs.get(lang, {})
for segment in key.split('.'):
value = value.get(segment) if isinstance(value, dict) else None
return value if isinstance(value, str) and value else None
src = ast.parse((SCRIPTS / 'notification_templates.py').read_text())
definition = next(n for n in src.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == 'TEMPLATES' for t in n.targets))
templates = ast.literal_eval(definition.value)
cpu_pattern = next(n for n in src.body if isinstance(n, ast.Assign)
and any(isinstance(t, ast.Name) and t.id == '_CPU_SUSTAINED_REASON'
for t in n.targets))
assert isinstance(cpu_pattern.value, ast.Call)
assert isinstance(cpu_pattern.value.func, ast.Attribute) and cpu_pattern.value.func.attr == 'compile'
assert len(cpu_pattern.value.args) == 1
scope = {'TEMPLATES': templates, '_load_runtime_catalog': lambda lang: catalogs.get(lang, {}),
'_get_hostname': lambda: 'node.example',
'time': time, 'html': html, 're': re, 'Dict': dict, 'Any': object,
'Optional': __import__('typing').Optional, 'Tuple': tuple,
'_CPU_SUSTAINED_REASON': re.compile(ast.literal_eval(cpu_pattern.value.args[0]))}
safe_dict = next(n for n in src.body if isinstance(n, ast.ClassDef) and n.name == '_SafeFormatDict')
exec(compile(ast.fix_missing_locations(ast.Module(body=[safe_dict], type_ignores=[])),
'<safe formatting>', 'exec'), scope)
extracted('notification_templates.py', '_catalog_value', namespace=scope)
extracted('notification_templates.py', 'runtime_message', namespace=scope)
extracted('notification_templates.py', '_format_health_degraded', namespace=scope)
render = extracted('notification_templates.py', 'render_template', namespace=scope)
return lambda kind, data: render(kind, data, language=language)
def temperature_case(history, reading=89., active=False):
class Persistence:
emitted = []
resolved = []
def record_error(self, **kw): self.emitted.append(kw)
def is_error_active(self, *a, **kw): return active
def resolve_error(self, *a, **kw): self.resolved.append((a, kw))
db = Persistence()
env = {'time': types.SimpleNamespace(time=lambda: 100000),
'subprocess': types.SimpleNamespace(run=lambda *a, **kw: types.SimpleNamespace(
returncode=0, stdout=f'temp1_input: {reading:.3f}\ntemp2_input: 45.000\n')),
'health_persistence': db, 'Optional': __import__('typing').Optional,
'Dict': dict, 'Any': object}
check = extracted('health_monitor.py', '_check_cpu_temperature', 'HealthMonitor', env)
monitor = types.SimpleNamespace(last_check_times={}, cached_results={}, state_history=defaultdict(list))
monitor.state_history['cpu_temp_history'] = list(history)
result = check(monitor)
return result, db
def produced_temperature():
result, db = temperature_case([
{'value': 89., 'time': 99900 + n * 5} for n in range(17)])
assert result['status'] == 'WARNING' and len(db.emitted) == 1
return db.emitted[0]
def polled_event(entry):
raw_row = {'error_key': entry['error_key'], 'category': entry['category'],
'severity': entry['severity'], 'reason': entry['reason'],
'details': json.dumps(entry['details']), 'last_seen': '',
'first_seen': '', 'acknowledged': 0}
class Cursor:
def execute(self, *a): pass
def fetchall(self): return [raw_row]
class Connection:
def __enter__(self): return self
def __exit__(self, *a): return False
def cursor(self): return Cursor()
typing = __import__('typing')
get_active = extracted('health_persistence.py', 'get_active_errors', 'HealthPersistence',
{'json': json, 'Optional': typing.Optional, 'List': typing.List,
'Dict': typing.Dict, 'Any': typing.Any})
persisted = get_active(types.SimpleNamespace(_db_connection=lambda **kw: Connection()))
assert len(persisted) == 1 and persisted[0]['details'] == entry['details']
class Persistence:
def get_active_errors(self): return persisted
class Event:
def __init__(self, event_type, severity, data, **kw):
self.event_type, self.severity, self.data = event_type, severity, data
q = types.SimpleNamespace(items=[], put=lambda event: q.items.append(event))
tree = ast.parse((SCRIPTS / 'notification_events.py').read_text())
cls = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'PollingCollector')
maps = {target.id: ast.literal_eval(n.value) for n in cls.body if isinstance(n, ast.Assign)
for target in n.targets if isinstance(target, ast.Name) and target.id in
('_CATEGORY_TO_EVENT_TYPE', '_ENTITY_MAP', '_CATEGORY_COOLDOWNS')}
collector = types.SimpleNamespace(_CATEGORY_TO_EVENT_TYPE=maps['_CATEGORY_TO_EVENT_TYPE'],
_ENTITY_MAP=maps['_ENTITY_MAP'], _CATEGORY_COOLDOWNS=maps['_CATEGORY_COOLDOWNS'],
DIGEST_INTERVAL=86400, SAME_ERROR_COOLDOWN=86400, _queue=q, _hostname='node.example',
_first_poll_done=True, _known_errors={}, _last_notified={}, _notified_severity={},
_is_usb_cache={}, _get_cooldown_from_db=lambda _: None,
_persist_last_notified=lambda *a: None, _save_known_errors_meta=lambda: None)
old = sys.modules.get('health_persistence')
sys.modules['health_persistence'] = types.SimpleNamespace(health_persistence=Persistence())
try:
poll = extracted('notification_events.py', '_check_persistent_health', 'PollingCollector',
{'time': types.SimpleNamespace(time=lambda: 100000), 'json': json,
'startup_grace': types.SimpleNamespace(should_suppress_category=lambda _: False),
'NotificationEvent': Event, 'Dict': dict})
poll(collector)
finally:
if old is None: sys.modules.pop('health_persistence', None)
else: sys.modules['health_persistence'] = old
assert len(q.items) == 1
return q.items[0]
class ActivePayloadTests(unittest.TestCase):
def test_temperature_sampling_boundaries_and_recovery_policy(self):
# These characterize the existing threshold/guard, not new policy.
highs = [{'value': 89., 'time': 99900 + n * 5} for n in range(17)]
below, db = temperature_case(highs[:-1]) # current sample makes 17
self.assertEqual(below['status'], 'OK')
self.assertEqual((db.emitted, db.resolved), ([], []))
warning, db = temperature_case(highs) # current sample makes 18
self.assertEqual(warning['status'], 'WARNING')
self.assertEqual(len(db.emitted), 1)
self.assertEqual(db.emitted[0]['details']['duration'], 100)
self.assertEqual((db.emitted[0]['category'], db.emitted[0]['severity'],
db.emitted[0]['details']['dismissable']), ('temperature', 'WARNING', True))
low = [{'value': 79., 'time': t} for t in (99980, 99990)]
pending, db = temperature_case(low[:1], reading=79., active=True)
self.assertEqual(pending['status'], 'WARNING')
self.assertEqual((db.emitted, db.resolved), ([], []))
recovered, db = temperature_case(low, reading=79., active=True)
self.assertEqual(recovered['status'], 'OK')
self.assertEqual(db.emitted, [])
self.assertEqual(db.resolved[0][0][0], 'cpu_temperature')
def test_temperature_record_queue_and_english_render_use_measured_sensor(self):
record = produced_temperature()
self.assertIn('sensor', record['reason'].lower())
self.assertEqual(record['details']['temperature'], 89.)
self.assertTrue(record['details']['dismissable'])
event = polled_event(record)
self.assertEqual((event.event_type, event.severity), ('temp_high', 'WARNING'))
self.assertEqual(event.data['value'], 89.)
self.assertEqual(event.data['threshold'], 80)
self.assertEqual(event.data['duration'], record['details']['duration'])
self.assertIn('High samples span', event.data['details'])
rendered = render_fixture()('temp_high', event.data)
self.assertIn('sensor temperature', rendered['title'].lower())
self.assertIn('89', rendered['title'])
self.assertIn('80', rendered['body'])
self.assertNotIn('CPU', rendered['title'] + rendered['body'])
def test_missing_temperature_measurement_never_renders_blank_celsius_claim(self):
event = polled_event({'error_key': 'cpu_temperature', 'category': 'temperature',
'severity': 'WARNING', 'reason': 'legacy raw reason',
'details': {'duration': 90}})
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
out = render_fixture(lang)('temp_high', event.data)
self.assertNotIn('°C', out['title'] + out['body'])
self.assertNotIn('CPU', out['title'] + out['body'])
expected = json.loads((MESSAGES / lang / 'common.json').read_text())['runtime']['notifications']['fallback']['temperatureAlertTitle']
self.assertIn(expected.replace('{hostname}', 'node.example'), out['title'])
def test_nonfinite_temperature_payload_is_not_presented_as_a_reading(self):
render = render_fixture()
for value, threshold in ((math.nan, 80), (math.inf, 80), (89, math.inf),
(True, 80), (10**400, 80), (89, 10**400)):
with self.subTest(value=value, threshold=threshold):
out = render('temp_high', {'value': value, 'threshold': threshold})
self.assertNotIn('°C', out['title'] + out['body'])
def test_numeric_strings_from_manual_payloads_retain_finite_readings(self):
out = render_fixture()('temp_high', {'value': '89.0', 'threshold': '80'})
self.assertIn('89.0°C', out['title'])
self.assertIn('80°C', out['body'])
for bad in ('', 'NaN', 'Infinity', '1e999', 'eighty', ' ', object()):
with self.subTest(value=repr(bad)):
out = render_fixture()('temp_high', {'value': bad, 'threshold': '80'})
self.assertNotIn('°C', out['title'] + out['body'])
def test_incomplete_measurement_preserves_recorded_context_once(self):
render = render_fixture()
for data, fragments in (
({'reason': 'CPU temperature 89°C >80°C sustained for 2m'},
('Recorded reason: CPU temperature 89°C >80°C sustained for 2m',)),
({'value': 89, 'details': 'High samples span 2m.'},
('Recorded details: High samples span 2m.',)),
({'value': 89, 'reason': 'old raw text', 'details': 'old raw text'},
('Recorded reason: old raw text',)),
):
with self.subTest(data=data):
out = render('temp_high', data)
self.assertNotIn('°C', out['title'] + out['body'].split('\n')[0])
self.assertIn('without a complete measurement', out['body'])
for fragment in fragments:
self.assertIn(fragment, out['body'])
if data.get('reason') == data.get('details'):
self.assertEqual(out['body'].count('old raw text'), 1)
self.assertNotIn('Recorded', render('temp_high', {})['body'])
def test_incomplete_title_keeps_raw_brace_bearing_hostname_in_every_locale(self):
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
fallback = json.loads((MESSAGES / lang / 'common.json').read_text())['runtime']['notifications']['fallback']['temperatureAlertTitle']
for hostname in ('host{value}', 'host{value.__class__}'):
with self.subTest(lang=lang, hostname=hostname):
out = render_fixture(lang)('temp_high', {'hostname': hostname})
self.assertEqual(out['title'], fallback.replace('{hostname}', hostname))
self.assertNotIn('°C', out['title'])
def test_incomplete_measurement_keeps_literal_braces_in_raw_context(self):
out = render_fixture()('temp_high', {
'reason': 'legacy {value} at {unknown}', 'details': 'sensor {threshold}',
})
self.assertIn('Recorded reason: legacy {value} at {unknown}', out['body'])
self.assertIn('Recorded details: sensor {threshold}', out['body'])
def test_shipped_locales_use_sensor_wording_and_localized_sample_span(self):
event = polled_event(produced_temperature())
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
with self.subTest(lang=lang):
out = render_fixture(lang)('temp_high', event.data)
self.assertNotIn('CPU', out['title'] + out['body'])
self.assertNotIn('High samples span', out['body'] if lang != 'en' else '')
self.assertIn('89', out['title'])
self.assertIn('80', out['body'])
expected = json.loads((MESSAGES / lang / 'common.json').read_text())['runtime']['notifications']['temperature']['sampleSpan'].replace('{duration}', '1m 40s')
self.assertIn(expected, out['body'])
def test_structured_span_does_not_replace_unrelated_or_unmarked_details(self):
producer = polled_event(produced_temperature()).data
for changed in ({'temperature_detail_kind': None}, {'details': 'Manual {duration} remains'},
{'duration': 'Infinity'}, {'duration': 10**400}, {'duration': -1},
{'duration': True}, {'duration': 1.5}):
with self.subTest(changed=changed):
data = {**producer, **changed}
out = render_fixture('it')('temp_high', data)
self.assertIn(data['details'], out['body'])
raw = render_fixture('it')('temp_high', {'value': 89, 'threshold': 80,
'duration': 100, 'details': 'Manual {duration} remains'})
self.assertIn('Manual {duration} remains', raw['body'])
def test_synthetic_locale_and_missing_key_fallback_for_every_temperature_key(self):
keys = ('fallback.temperatureAlertTitle', 'fallback.temperatureAlertBody',
'fallback.recordedReason', 'fallback.recordedDetails', 'temperature.sampleSpan')
producer = polled_event(produced_temperature()).data
synthetic = render_fixture('synthetic')
for key in keys:
with self.subTest(key=key):
data = producer if key.endswith('sampleSpan') else {'reason': 'raw R', 'details': 'raw D'}
output = synthetic('temp_high', data)
english = render_fixture('en')('temp_high', data)
if key.endswith('sampleSpan'):
self.assertIn('High samples span 1m 40s.', output['body'])
else:
self.assertEqual(output['body'] if not key.endswith('Title') else output['title'],
english['body'] if not key.endswith('Title') else english['title'])
for key in keys:
# Exercise actual fallback per missing key even after catalog migration.
with self.subTest(missing=key):
if key.endswith('sampleSpan'):
self.assertIn('High samples span 1m 40s.',
render_fixture('it', missing=[key])('temp_high', producer)['body'])
else:
data = {'reason': 'raw R', 'details': 'raw D'}
result = render_fixture('it', missing=[key])('temp_high', data)
en_value = json.loads((MESSAGES / 'en' / 'common.json').read_text())['runtime']['notifications']
self.assertIn(en_value['fallback'][key.split('.')[1]].format(
hostname='node.example', reason='raw R', details='raw D'),
result['title'] if key.endswith('Title') else result['body'])
# A synthetic translated value proves the runtime consumer uses lookup.
translated = render_fixture('synthetic', overlays=[('synthetic', 'Translated {value}°C',
'Translated {threshold}°C\n{details}')])('temp_high', producer)
self.assertIn('Translated', translated['title'])
custom = render_fixture('synthetic', translations={
'fallback': {'temperatureAlertTitle': 'CUSTOM {hostname}',
'temperatureAlertBody': 'CUSTOM incomplete.',
'recordedReason': 'CUSTOM R {reason}',
'recordedDetails': 'CUSTOM D {details}'},
'temperature': {'sampleSpan': 'CUSTOM span {duration}.'},
})
complete = custom('temp_high', producer)
self.assertIn('CUSTOM span 1m 40s.', complete['body'])
incomplete = custom('temp_high', {'reason': 'raw R', 'details': 'raw D'})
self.assertIn('CUSTOM node.example', incomplete['title'])
for fragment in ('CUSTOM incomplete.', 'CUSTOM R raw R', 'CUSTOM D raw D'):
self.assertIn(fragment, incomplete['body'])
def test_email_contains_complete_and_incomplete_localized_temperature_body(self):
# The actual channel formats structured fields; the temperature body
# must not disappear merely because those rows exist.
import importlib.util
spec = importlib.util.spec_from_file_location('notification_channels_fixture',
SCRIPTS / 'notification_channels.py')
channels = importlib.util.module_from_spec(spec)
spec.loader.exec_module(channels)
old = sys.path[:]
sys.path.insert(0, str(SCRIPTS))
try:
channel = object.__new__(channels.EmailChannel)
channel.subject_prefix = '[ProxMenux]'
produced = polled_event(produced_temperature()).data
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
for name, data in (('complete', produced), ('incomplete', {
'hostname': 'node.example', 'reason': 'legacy {token}',
'details': 'raw {details}', 'value': 89,
})):
with self.subTest(lang=lang, name=name):
rendered = render_fixture(lang)('temp_high', data)
message = channel._format_html(rendered['title'], rendered['body'],
'WARNING', {**data, '_notification_language': lang,
'_event_type': 'temp_high', '_group': rendered['group']})
# HTML escaping may replace apostrophes/angle brackets.
for line in rendered['body'].splitlines():
if ':' in line and len(line.partition(':')[0]) < 40:
label, _, value = line.partition(':')
self.assertIn(html.escape(label.strip()), message)
self.assertIn(html.escape(value.strip()), message)
else:
self.assertIn(html.escape(line), message)
if name == 'incomplete':
self.assertNotIn('89C', message)
finally:
sys.path[:] = old
def test_long_reason_email_once_if_temperature_body_already_contains_it(self):
import importlib.util
spec = importlib.util.spec_from_file_location('notification_channels_fixture',
SCRIPTS / 'notification_channels.py')
channels = importlib.util.module_from_spec(spec)
spec.loader.exec_module(channels)
old = sys.path[:]
sys.path.insert(0, str(SCRIPTS))
try:
channel = object.__new__(channels.EmailChannel)
channel.subject_prefix = '[ProxMenux]'
reason = 'Recorded observation ' + 'R' * 90 + ' <i> & {value}'
escaped = html.escape(reason)
for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'):
render = render_fixture(lang)
for kind, fields in (('incomplete', {'details': 'raw {details}'}),
('complete', {'value': 89, 'threshold': 80})):
with self.subTest(lang=lang, kind=kind):
data = {'hostname': 'host{value.__class__}', 'reason': reason, **fields}
rendered = render('temp_high', data)
message = channel._format_html(rendered['title'], rendered['body'],
'WARNING', {**data, '_notification_language': lang,
'_event_type': 'temp_high', '_group': rendered['group']})
self.assertEqual(message.count(escaped), 1)
self.assertNotIn('<i>', message)
self.assertIn(html.escape(rendered['title']), message)
if kind == 'incomplete':
self.assertIn(reason, rendered['body'])
self.assertIn('raw {details}', rendered['body'])
else:
self.assertNotIn(reason, rendered['body'])
self.assertIn('89', message)
# An unrelated event retains its existing long-reason block.
other = channel._format_html('Other alert', reason, 'WARNING',
{'reason': reason, '_event_type': 'other', '_group': 'other'})
self.assertEqual(other.count(escaped), 2)
finally:
sys.path[:] = old
def test_collector_schema_categories_and_cpu_reason_are_localized(self):
# Execute actual collector-to-formatter path against inert per-cycle inputs.
class Stop(Exception): pass
class Clock:
count = 0
def time(self): return 1.
def sleep(self, _):
self.count += 1
if self.count > 1: raise Stop
class Manager:
_enabled = True
_config = {}
events = []
def is_event_enabled(self, *_): return True
def emit_event(self, **kw): self.events.append(kw)
manager = Manager()
scope = {'notification_manager': manager, 'time': Clock(),
'startup_grace': types.SimpleNamespace(should_suppress_category=lambda *_: False),
'_capture_health_journal_context': lambda *_: '',
'resolve_notification_hostname': lambda name, *_: name,
'health_monitor': types.SimpleNamespace(get_detailed_status=lambda: {
'hostname': 'node.example', 'details': {'cpu': {'status': 'WARNING',
'reason': 'CPU >85% sustained for 300s'}}, 'overall': 'WARNING', 'summary': 'x'},
cached_results={}, last_check_times={})}
# Remove only infrastructure imports from the extracted function.
tree = ast.parse((SCRIPTS / 'flask_server.py').read_text())
fn = copy.deepcopy(next(n for n in tree.body if isinstance(n, ast.FunctionDef) and n.name == '_health_collector_loop'))
for node in ast.walk(fn):
for field, value in ast.iter_fields(node):
if isinstance(value, list):
setattr(node, field, [child for child in value if not (
isinstance(child, ast.ImportFrom) and child.module == 'health_monitor'
or isinstance(child, ast.Import) and any(a.name == 'startup_grace' for a in child.names))])
exec(compile(ast.fix_missing_locations(ast.Module(body=[fn], type_ignores=[])), '<collector>', 'exec'), scope)
with self.assertRaises(Stop): scope['_health_collector_loop']()
self.assertEqual(len(manager.events), 1)
payload = manager.events[0]['data']
item = payload['health_degraded']['categories'][0]
self.assertEqual(item['cat_key'], 'cpu')
out = render_fixture('it')('health_degraded', {**payload, 'severity': 'WARNING'})
self.assertIn('Utilizzo e temperatura della CPU', out['title'])
self.assertEqual(out['body'], 'La CPU è superiore al 85% per 300s.')
def test_legacy_key_and_unknown_reason_remain_honest(self):
render = render_fixture('it')
def payload(item):
return {'hostname': 'node.example', 'health_degraded': {'categories': [item]}}
legacy = render('health_degraded', payload({'key': 'cpu', 'status': 'WARNING',
'reason': 'CPU >85% sustained for 300s'}))
self.assertIn('Utilizzo e temperatura della CPU', legacy['title'])
self.assertIn('La CPU', legacy['body'])
raw = render('health_degraded', payload({'cat_key': 'services', 'category': 'PVE Services',
'status': 'CRITICAL', 'reason': 'Services inactive: pvedaemon', 'entity': 'pvedaemon'}))
self.assertIn('Servizi PVE', raw['title'])
self.assertIn('pvedaemon', raw['title'])
self.assertEqual(raw['body'], 'Services inactive: pvedaemon')
unknown = render('health_degraded', payload({'cat_key': 'other', 'category': 'External category',
'reason': 'raw unknown', 'status': 'WARNING'}))
self.assertIn('External category', unknown['title'])
self.assertEqual(unknown['body'], 'raw unknown')
conflicting = render('health_degraded', payload({'key': 'memory', 'cat_key': 'cpu',
'status': 'WARNING', 'reason': 'CPU >85% sustained for 300s'}))
self.assertIn('Memoria e scambio', conflicting['title'])
self.assertEqual(conflicting['body'], 'CPU >85% sustained for 300s')
multi = render('health_degraded', {'hostname': 'node.example',
'health_degraded': {'categories': [
{'cat_key': 'cpu', 'status': 'WARNING', 'reason': 'CPU >85% sustained for 300s'},
{'cat_key': 'services', 'status': 'CRITICAL', 'reason': 'Services inactive: pvedaemon'}]}})
self.assertIn('Utilizzo e temperatura della CPU', multi['body'])
self.assertIn('Servizi PVE', multi['body'])
self.assertIn('Services inactive: pvedaemon', multi['body'])
if __name__ == '__main__':
unittest.main()