mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-08 22:46:41 +00:00
Merge pull request #408 from f3rs3n/fix/active-notification-payloads
Fix active health and temperature notification payloads
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user