diff --git a/.github/scripts/tests/notification_final_fixture.py b/.github/scripts/tests/notification_final_fixture.py new file mode 100644 index 00000000..a6e9b17d --- /dev/null +++ b/.github/scripts/tests/notification_final_fixture.py @@ -0,0 +1,88 @@ +"""Inert infrastructure for actual endpoint/manual/queued/SQLite release seams.""" +import datetime +import sqlite3 +import tempfile +import threading +import time +import types +import typing +from html.parser import HTMLParser +from pathlib import Path +from notification_fixture import templates, SCRIPTS, EmailChannel, extract + + +def visible(markup): + class Text(HTMLParser): + def __init__(self): super().__init__(); self.parts = []; self.tags = [] + def handle_data(self, data): self.parts.append(data) + def handle_starttag(self, tag, attrs): self.tags.append(tag) + parser = Text(); parser.feed(markup) + return '\n'.join(p.strip() for p in parser.parts if p.strip()), parser.tags + + +def restore_event(warnings): + events = [] + ns = {'request': types.SimpleNamespace(remote_addr='127.0.0.1', get_json=lambda **kw: { + 'hostname':'node-a', 'guests':'3', 'stubs':'1', 'stale_nodes':'0', + 'components':'2', 'duration':'2m', 'warnings':warnings}), + 'notification_manager':types.SimpleNamespace(emit_event=lambda **kw:events.append(kw)), + 'jsonify':lambda value:value} + handler = extract(SCRIPTS/'flask_notification_routes.py', 'internal_restore_event', None, ns) + response, status = handler() + assert status == 200 and len(events) == 1 + return events[0] + + +def deliver(event_type, data, severity='INFO', language='en', manual=False, quiet=False, quiet_before=()): + """Execute real dispatch/manual and optional real SQLite buffer+flush.""" + captured = [] + channel = object.__new__(EmailChannel); channel.subject_prefix = '[ProxMenux]' + def sink(title, body, severity, data): + markup = channel._format_html(title, body, severity, data) + text, tags = visible(markup) + captured.append(dict(title=title, body=body, severity=severity, data=dict(data), html=markup, text=text, tags=tags)) + return {'success':True} + manager = types.SimpleNamespace(_config={'email.rich_format':'true'}, _lock=threading.RLock(), + _channels={'email':types.SimpleNamespace(send=sink)}, + _group_limiter=types.SimpleNamespace(allow=lambda group:True), + _claim_delivery=lambda event:'inert', _finish_delivery_claim=lambda *a,**kw:None, + _notification_language=lambda:language, _build_ai_config=lambda:{'ai_enabled':'false'}, + _in_quiet_hours=lambda channel:quiet, _should_buffer_for_digest=lambda *a:False, + _record_history=lambda *a:None, _stats={'total_sent':0,'total_errors':0}, + is_event_enabled=lambda event:True) + ns = dict(vars(typing), NotificationEvent=types.SimpleNamespace, TEMPLATES=templates.TEMPLATES, render_template=templates.render_template, + resolve_notification_hostname=lambda host, config:host or 'node-a', + enrich_with_emojis=templates.enrich_with_emojis, datetime=datetime.datetime, + _should_bypass_ai=lambda event:True, _AI_BYPASS_EVENTS=frozenset({'backup_complete','backup_fail'})) + for name in ('_dispatch_to_channels', '_dispatch_event', 'send_notification'): + setattr(manager, name, types.MethodType(extract(SCRIPTS/'notification_manager.py', name, 'NotificationManager', ns), manager)) + with tempfile.TemporaryDirectory(prefix='notification-final-') as scratch: + db = Path(scratch)/'pending.sqlite' + rows = [] + if quiet: + conn = sqlite3.connect(db) + conn.execute('CREATE TABLE quiet_pending (id INTEGER PRIMARY KEY, channel TEXT, event_type TEXT, event_group TEXT, severity TEXT, ts INTEGER, title TEXT, body TEXT)') + conn.commit(); conn.close() + qns = dict(vars(typing), sqlite3=sqlite3, DB_PATH=db, time=time, datetime=datetime.datetime, + _resolve_display_hostname=lambda config:'node-a', runtime_message=templates.runtime_message, + EVENT_EMOJI=templates.EVENT_EMOJI, CATEGORY_EMOJI=templates.CATEGORY_EMOJI) + for name in ('_buffer_quiet_event', '_flush_quiet_for_channel', '_compose_digest_body'): + setattr(manager,name,types.MethodType(extract(SCRIPTS/'notification_manager.py',name,'NotificationManager',qns),manager)) + for earlier in quiet_before: + manager._dispatch_event(types.SimpleNamespace(**earlier,source='inert',entity_type='node',entity_id='',event_id='inert',fingerprint='inert')) + if manual: + assert not quiet + result = manager.send_notification(event_type, severity, '', '', dict(data)) + assert result['success'] + else: + event = types.SimpleNamespace(event_type=event_type, severity=severity, data=dict(data), source='inert', entity_type='node', entity_id='', event_id='inert', fingerprint='inert') + manager._dispatch_event(event) + if quiet: + conn = sqlite3.connect(db); rows = conn.execute('SELECT event_type,severity,title,body FROM quiet_pending').fetchall(); conn.close() + assert len(rows)==1+len(quiet_before) and not captured + manager._flush_quiet_for_channel('email', manager._channels['email']) + conn=sqlite3.connect(db); remaining=conn.execute('SELECT count(*) FROM quiet_pending').fetchone()[0]; conn.close() + assert remaining==0 + assert len(captured)==1 + if quiet: captured[0]['buffered']=rows + return captured[0] diff --git a/.github/scripts/tests/notification_fixture.py b/.github/scripts/tests/notification_fixture.py new file mode 100644 index 00000000..90ba64e4 --- /dev/null +++ b/.github/scripts/tests/notification_fixture.py @@ -0,0 +1,51 @@ +"""Assertion-free inert actual consumers; no operational host imports.""" +import ast +import re +import sys +from pathlib import Path +ROOT = Path(__file__).resolve().parents[3] +SCRIPTS = ROOT / 'AppImage/scripts' +if str(SCRIPTS) not in sys.path: + sys.path.insert(0, str(SCRIPTS)) +import notification_templates as templates +# Display-name resolution is an infrastructure boundary, never load manager. +templates._get_hostname = lambda: 'node-a' +from notification_channels import EmailChannel +LANGUAGES = ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv') + +def extract(path, name, owner, ns): + tree = ast.parse(path.read_text()) + nodes = tree.body if owner is None else next(n.body for n in tree.body if isinstance(n, ast.ClassDef) and n.name == owner) + node = next(n for n in nodes if isinstance(n, ast.FunctionDef) and n.name == name) + node.decorator_list = [] + exec(compile(ast.Module(body=[node], type_ignores=[]), str(path), 'exec'), ns) + return ns[name] + +def receive(message, severity='info', title='Backup', kind='vzdump'): + ns = {'re': re, 'capture_journal_context': lambda **kw: ''} + class Event: + def __init__(self, **kw): self.__dict__.update(kw); self.event_id = 'inert' + ns['NotificationEvent'] = Event + methods = {name: extract(SCRIPTS / 'notification_events.py', name, 'ProxmoxHookWatcher', ns) + for name in ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook')} + class Queue: + def __init__(self): self.items = [] + def put(self, event): self.items.append(event) + class Receiver: + _hostname = 'node-a' + _classify_pve = methods['_classify_pve'] + _map_severity = staticmethod(methods['_map_severity']) + _backup_outcome = staticmethod(methods['_backup_outcome']) + process_webhook = methods['process_webhook'] + def __init__(self): self._queue = Queue() + target = Receiver() + result = target.process_webhook({'fields': {'type': kind}, 'severity': severity, 'title': title, 'message': message}) + assert result['accepted'] and len(target._queue.items) == 1 + return target._queue.items[0] + +def email(event_type, data, severity='INFO', language='en'): + result = templates.render_template(event_type, data, language) + channel = object.__new__(EmailChannel) + channel.subject_prefix = '[ProxMenux]' + context = {**data, 'severity': severity, '_event_type': event_type, '_group': result['group'], '_notification_language': language} + return result, channel._format_html(result['title'], result['body'], severity, context) diff --git a/.github/scripts/tests/test_command_descriptions.py b/.github/scripts/tests/test_command_descriptions.py index c1e3fd9d..c8fb6431 100644 --- a/.github/scripts/tests/test_command_descriptions.py +++ b/.github/scripts/tests/test_command_descriptions.py @@ -130,6 +130,18 @@ class CommandDescriptionsTests(unittest.TestCase): for key in ("temperatureAlertTitle", "temperatureAlertBody", "recordedReason", "recordedDetails"): fallback.setdefault(key, source_fallback[key]) + # Slovak remains the exact upstream catalog; model the + # pending outcome-key generator additions in disposable + # copies rather than modifying its curated values. + if lang == 'sk': + local = temporary['runtime']['notifications'] + source = catalog('en')['runtime']['notifications'] + for key, value in source['backup'].items(): + local.setdefault('backup', {}).setdefault(key, value) + local['channels']['email']['status'].setdefault( + 'unconfirmed', source['channels']['email']['status']['unconfirmed']) + local['channels']['email']['status'].setdefault( + 'completed_with_warnings', source['channels']['email']['status']['completed_with_warnings']) 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. diff --git a/.github/scripts/tests/test_notification_backup_split.py b/.github/scripts/tests/test_notification_backup_split.py new file mode 100644 index 00000000..d63b4e6d --- /dev/null +++ b/.github/scripts/tests/test_notification_backup_split.py @@ -0,0 +1,116 @@ +"""Backup-only maintainer contract at actual render/dispatch/email seams. + +Scope-approved seams: receiver, template lookup, rich enrichment, inert queued +and manual delivery with an email capture sink. No host operations or sends. +""" +import unittest +from notification_fixture import templates, LANGUAGES +from notification_final_fixture import deliver + + +class BackupSplitTests(unittest.TestCase): + def test_recovery_default_is_upstream_resolved_without_proof(self): + for language in LANGUAGES: + data = {'hostname': 'alias', 'category': 'temperature', + 'reason': 'Temperature high (recovered)', 'duration': '2m', + 'original_severity': 'WARNING'} + rendered = templates.render_template('error_resolved', data, language) + expected = templates.runtime_message('templates.error_resolved.title', language, + hostname='alias', category='temperature', entity_suffix='') + self.assertEqual(rendered['title'], expected) + rich, _ = templates.enrich_with_emojis('error_resolved', rendered['title'], rendered['body'], data) + self.assertTrue(rich.startswith('✅ ')) + result = deliver('error_resolved', data, 'OK', language) + self.assertIn('background:#f0fdf4;', result['html']) + + def test_pre_guest_native_subject_keeps_only_cause_once_with_display_alias(self): + from notification_fixture import receive + raw_host = 'pve-production.internal.example' + message = ('Details\n=======\nVMID Name Status Time Size Filename\n' + '\nTotal running time: 0s\nTotal size: 0 B') + for raw in (message, 'ERROR: unable to activate storage PBS\n' + message): + event = receive(raw, 'error', 'vzdump backup status (' + raw_host + + '): backup failed: unable to activate storage PBS') + event.data['hostname'] = 'display-alias {rack.location}' + for language in LANGUAGES: + for manual in (False, True): + result = deliver(event.event_type, event.data, event.severity, language, manual=manual) + self.assertNotIn(raw_host, result['text']) + self.assertNotIn('vzdump backup status', result['text']) + self.assertEqual(result['text'].count('unable to activate storage PBS'), 1) + self.assertIn('display-alias {rack.location}', result['title']) + self.assertEqual(result['data']['pve_title'], event.data['pve_title']) + self.assertEqual(result['data']['pve_message'], raw) + # No global hostname or guest-name substitution: actual diagnostics are + # authoritative, even when they happen to contain the original hostname. + event = receive(message, 'error', 'vzdump backup status (' + raw_host + + '): backup failed: cannot connect to ' + raw_host) + result = deliver(event.event_type, {**event.data, 'hostname': 'alias'}, event.severity) + self.assertEqual(result['body'].count('cannot connect to ' + raw_host), 1) + self.assertNotIn('vzdump backup status', result['body']) + + def test_backup_legacy_keys_keep_meaning_and_unknown_uses_new_keys(self): + # Frozen upstream contract; no Git history required in shipped tests. + legacy = {'en': {'title': '{hostname} → {storage}: Backup complete — {vmname} ({vmid})', 'body': 'Backup of {vmname} (ID: {vmid}) completed successfully on {storage}.\nSize: {size}', 'label': 'Backup complete'}, 'de': {'title': '{hostname} → {storage}: Sicherung abgeschlossen – {vmname} ({vmid})', 'body': 'Die Sicherung von {vmname} (ID: {vmid}) wurde am {storage} erfolgreich abgeschlossen.\nGröße: {size}', 'label': 'Sicherung abgeschlossen'}, 'es': {'title': '{hostname} → {storage}: Backup completado — {vmname} ({vmid})', 'body': 'El backup de {vmname} (ID: {vmid}) se ha completado correctamente en {storage}.\nTamaño: {size}', 'label': 'Backup completado'}, 'fr': {'title': '{hostname} → {storage}\xa0: Sauvegarde terminée — {vmname} ({vmid})', 'body': "La sauvegarde de {vmname} (ID\xa0: {vmid}) s'est terminée avec succès le {storage}.\nTaille\xa0: {size}", 'label': 'Sauvegarde terminée'}, 'it': {'title': '{hostname} → {storage}: Backup completato — {vmname} ({vmid})', 'body': 'Backup di {vmname} (ID: {vmid}) completato con successo su {storage}.\nTaglia: {size}', 'label': 'Backup completato'}, 'pt': {'title': '{hostname} → {storage}: Backup concluído — {vmname} ({vmid})', 'body': 'Backup de {vmname} (ID: {vmid}) concluído com sucesso em {storage}.\nTamanho: {size}', 'label': 'Backup concluído'}, 'sk': {'title': '{hostname} → {storage}: Záloha dokončená — {vmname} ({vmid})', 'body': 'Záloha {vmname} (ID: {vmid}) na úložisku {storage} bola úspešne dokončená.\nVeľkosť: {size}', 'label': 'Záloha bola dokončená'}, 'sv': {'title': '{hostname} → {storage}: Säkerhetskopiering klar — {vmname} ({vmid})', 'body': 'Säkerhetskopiering av {vmname} (ID: {vmid}) slutfördes framgångsrikt på {storage}.\nStorlek: {size}', 'label': 'Säkerhetskopieringen är klar'}} + for language, expected in legacy.items(): + self.assertEqual(templates._load_runtime_catalog(language)['templates']['backup_complete'], expected) + result = templates.render_template('backup_complete', {'hostname': 'alias {rack.location}'}, language) + self.assertIn('alias {rack.location}', result['title']) + self.assertNotIn('()', result['title']) + new_title = templates.runtime_message('backup.unconfirmedTitle', language, hostname='alias {rack.location}') + self.assertTrue(new_title) + self.assertEqual(result['title'], new_title) + self.assertIn(templates.runtime_message('backup.unconfirmedBody', language), result['body']) + # Absent/blank/non-string translation uses English per-key fallback. + from unittest.mock import patch + english = templates._load_runtime_catalog('en') + for value in (None, '', {}, []): + missing = {'backup': {'unconfirmedTitle': value, 'unconfirmedBody': value}} + with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: english if lang == 'en' else missing): + result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it') + self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed') + self.assertEqual(result['body'], 'The backup outcome is not confirmed.') + # Both catalogs missing: new outcome text still has explicit EN defaults. + with patch.object(templates, '_load_runtime_catalog', return_value={}): + result = templates.render_template('backup_complete', {'hostname': 'alias'}, 'it') + self.assertEqual(result['title'], 'alias: Backup outcome unconfirmed') + self.assertEqual(result['body'], 'The backup outcome is not confirmed.') + + def test_restore_keeps_original_ready_line_and_success_icon_with_warnings(self): + from notification_final_fixture import restore_event + ready = {'en': 'The node is now fully ready to use.', 'de': 'Der Knoten ist nun vollständig einsatzbereit.', 'es': 'El nodo está listo para usarse.', 'fr': 'Le nœud est maintenant entièrement prêt à être utilisé.', 'it': "Il nodo è ora completamente pronto per l'uso.", 'pt': 'O nó agora está totalmente pronto para uso.', 'sk': 'Uzol je teraz úplne pripravený na použitie.', 'sv': 'Noden är nu helt redo att användas.'} + for warning in ('', 'missing module zfs'): + event = restore_event(warning) + for language in LANGUAGES: + result = deliver(event['event_type'], event['data'], event['severity'], language) + self.assertTrue(result['title'].startswith('✅ ')) + self.assertIn(ready[language], result['body']) + self.assertIn(ready[language], result['text']) + self.assertIn('2m', result['text']) + if warning: + self.assertIn(warning, result['text']) + quiet = deliver(event['event_type'], event['data'], event['severity'], language, quiet=True) + self.assertIn('✅', quiet['body']) + self.assertIn(' ' + ready[language], quiet['body']) + self.assertIn('white-space:pre-wrap;', quiet['html']) + + def test_spanish_outcome_and_restore_titles_are_capitalized_and_failure_is_exact(self): + expected = {'confirmed': 'Backup completado', 'completed_with_warnings': 'Backup completado con advertencias', + 'unconfirmed': 'Resultado del backup sin confirmar', 'failed': 'Backup fallido'} + for outcome, title in expected.items(): + result = templates.render_template('backup_complete', {'hostname': 'alias', 'backup_outcome': outcome}, 'es') + self.assertEqual(result['title'], 'alias: ' + title) + failure = templates.render_template('backup_fail', {'hostname': 'alias'}, 'es') + self.assertEqual(failure['title'], 'alias: Backup fallido') + restore = templates.render_template('system_restore_completed', {'hostname': 'alias'}, 'es') + self.assertEqual(restore['title'], 'alias: Restauración del host finalizada') + + def test_recovery_only_quiet_release_keeps_upstream_summary_contract(self): + result = deliver('error_resolved', {'hostname': 'alias', 'category': 'temperature', + 'reason': 'old (recovered)', 'duration': '2m'}, 'OK', quiet=True) + self.assertIn(templates.runtime_message('digest.lead', 'en', count=1).strip(), result['body']) + self.assertIn(templates.runtime_message('digest.footer', 'en'), result['body']) + self.assertIn('✅', result['body']) + + +if __name__ == '__main__': unittest.main() diff --git a/.github/scripts/tests/test_notification_corrections.py b/.github/scripts/tests/test_notification_corrections.py new file mode 100644 index 00000000..40b2ff58 --- /dev/null +++ b/.github/scripts/tests/test_notification_corrections.py @@ -0,0 +1,204 @@ +"""Whole-PR outcome corrections, inert producer/actual email consumers.""" +import html +import unittest +from notification_fixture import templates, receive, email, LANGUAGES + +REPORT = """Details +======= +VMID Name Status Time Size Filename +100 web ok 1m 1s 1 GiB vm/100/2026-09-29T17:00:00Z + +Total running time: 1m 1s +Total size: 1 GiB +""" + +class CorrectionTests(unittest.TestCase): + def test_reversed_finish_is_not_completion_evidence(self): + event = receive('INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Starting Backup of VM 100 (qemu)') + self.assertEqual(event.data['backup_outcome'], 'unconfirmed') + + + def test_interleaved_complete_logs_keep_both_finished_guests(self): + message = ('INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: web\n' + 'INFO: Starting Backup of VM 101 (lxc)\nINFO: CT Name: db\n' + 'INFO: Finished Backup of VM 100 (00:01:01)\nINFO: Finished Backup of VM 101 (00:01:02)') + event = receive(message) + self.assertEqual(event.data['backup_outcome'], 'confirmed') + result, markup = email(event.event_type, event.data, event.severity) + self.assertIn('✅ VM web (100)', result['body']) + self.assertIn('✅ CT db (101)', result['body']) + self.assertNotIn('❔', result['body']) + self.assertIn('00:01:01', result['body']) + + + def test_backup_identity_has_event_scoped_mail_compatible_wrapping(self): + result, markup = email('backup_complete', {'hostname': 'n' * 64, + 'backup_outcome': 'confirmed', 'pve_message': 'INFO: Starting Backup of VM 100 (qemu)\nINFO: VM Name: customerproductionpostgresqlreplicaeuropewestdatacenter01\nINFO: Finished Backup of VM 100 (00:01:01)'}) + self.assertIn('table-layout:fixed;', markup) + title_tag = markup.split('
{_runtime_text('email.details', data)}
@@ -1140,15 +1175,15 @@ class EmailChannel(NotificationChannel):| + | {html_mod.escape(host_label)}: {html_mod.escape(data.get('hostname', ''))} | @@ -1221,7 +1256,9 @@ class EmailChannel(NotificationChannel): v = str(value).strip() if value else '' if not v or v == '0' and original_label not in ('Failures',): return - if fmt == 'severity': + if fmt == 'backup_error': + rows.append((esc(label), f'{esc(v)}')) + elif fmt == 'severity': sev_colors = { 'CRITICAL': '#dc2626', 'WARNING': '#d97706', 'INFO': '#2563eb', 'OK': '#16a34a', @@ -1254,9 +1291,14 @@ class EmailChannel(NotificationChannel): # tell which target the backup ran against. Reported gap: emails # showed no way to distinguish which PBS failed with 2+ configured. _add('Storage', data.get('storage') or data.get('storage_name'), 'code') - status_key = 'failed' if 'fail' in event_type else 'completed' if 'complete' in event_type else 'started' + if event_type == 'backup_complete' and data.get('backup_outcome') != 'confirmed': + status_key = ('failed' if data.get('backup_outcome') == 'failed' + else 'completed_with_warnings' if data.get('backup_outcome') == 'completed_with_warnings' + else 'unconfirmed') + else: + status_key = 'failed' if 'fail' in event_type else 'completed' if 'complete' in event_type else 'started' _add('Status', _runtime_text(f'email.status.{status_key}', language_data), - 'severity' if 'fail' in event_type else '') + 'backup_error' if status_key == 'failed' else '') _add('Size', data.get('size')) _add('Duration', data.get('duration')) _add('Snapshot', data.get('snapshot_name'), 'code') diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index 6269f6ad..9272ff69 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -4289,6 +4289,47 @@ class ProxmoxHookWatcher: def _hostname(self) -> str: return _hostname() + @staticmethod + def _backup_outcome(severity: str, message: str) -> str: + """Distinguish explicit failure, complete guest logs and unknown results.""" + text = str(message or '') + if severity in ('error', 'err', 'critical') or re.search( + r'(?im)^\s*(?:\d+:\s*)?(?:\d{4}-\d{2}-\d{2}\s+\S+\s+)?(?:ERROR:|TASK ERROR:|.*\bStatus\s+ERROR\b)', text): + return 'failed' + starts = re.findall(r'(?im)\bStarting Backup of VM (\d+)\s*\(', text) + finished = re.findall(r'(?im)\bFinished Backup of VM (\d+)\s*\(', text) + from notification_templates import _parse_vzdump_table + table = _parse_vzdump_table(text) + if table is not None and any(guest['status'].lower() == 'error' for guest in table['vms']): + return 'failed' + warnings = severity in ('warning', 'warn') or bool(re.search( + r'(?im)(?:^\s*(?:\d+:\s*)?(?:\d{4}-\d{2}-\d{2}\s+\S+\s+)?WARN(?:ING)?:|\bWARNINGS\s*:\s*[1-9]\d*)', text)) + if severity not in ('info', 'ok', 'success', 'warning', 'warn'): + return 'unconfirmed' + completed = 'completed_with_warnings' if warnings else 'confirmed' + # A present table is authoritative: do not certify an incomplete table + # from a finished guest log, or reject a complete OK table merely + # because the extra diagnostic log was truncated before its finishes. + if table is not None: + return (completed if table['complete'] and + all(guest['status'].lower() == 'ok' for guest in table['vms']) + else 'unconfirmed') + if starts: + pending = {} + for match in re.finditer(r'(?im)\b(Starting|Finished) Backup of VM (\d+)\s*\(', text): + action, vmid = match.groups() + if action.lower() == 'starting': + pending[vmid] = pending.get(vmid, 0) + 1 + elif not pending.get(vmid): + return 'unconfirmed' # A finish before its start is not evidence. + else: + pending[vmid] -= 1 + return completed if not any(pending.values()) else 'unconfirmed' + if re.search( + r'(?im)^\s*(?:INFO:\s*)?TASK OK\s*$', text): + return completed + return 'unconfirmed' + def process_webhook(self, payload: dict) -> dict: """Process an incoming Proxmox webhook payload. @@ -4347,6 +4388,13 @@ class ProxmoxHookWatcher: 'title': title or event_type, 'job_id': pve_job_id, } + if event_type in ('backup_complete', 'backup_fail'): + # This is presentation metadata, not a new event/toggle/delivery path. + outcome = self._backup_outcome(severity_raw, message) + data['backup_outcome'] = ( + 'failed' if event_type == 'backup_fail' or outcome == 'failed' else + outcome if pve_type == 'vzdump' else 'unconfirmed' + ) if pve_type == 'replication': replication = self._extract_replication_context( @@ -4440,10 +4488,17 @@ class ProxmoxHookWatcher: if vmids: data['vmid'] = vmids[0] entity_id = vmids[0] - # Try to extract VM name from the table line - name_m = re.search(r'(\d+)\s+(\S+)\s+(?:OK|ERROR|WARNINGS)', message) - if name_m: - data['vmname'] = name_m.group(2) + from notification_templates import _parse_vzdump_message + parsed = _parse_vzdump_message(message) or {} + guests = parsed.get('vms', []) + if data.get('backup_outcome') == 'failed': + guests = [guest for guest in guests if guest.get('status', '').lower() == 'error'] + if len(guests) == 1: + data['vmid'] = guests[0]['vmid'] + data['vmname'] = guests[0]['name'] + else: + # Do not make one successful guest the subject of a batch failure. + data.pop('vmid', None) # Extract size from "Total size: X" size_m = re.search(r'Total size:\s*(.+?)(?:\n|$)', message) if size_m: diff --git a/AppImage/scripts/notification_manager.py b/AppImage/scripts/notification_manager.py index 2b68d298..178bd087 100644 --- a/AppImage/scripts/notification_manager.py +++ b/AppImage/scripts/notification_manager.py @@ -1364,6 +1364,12 @@ class NotificationManager: # Get journal context if available (will be enriched per-channel based on detail_level) raw_journal_context = data.get('_journal_context', '') + # Persist a presentation token in the existing title column: buffers + # otherwise discard outcome metadata before composition. Old rows + # without a token remain neutral; routing and schema are unchanged. + buffer_title = title + if event_type in ('backup_complete', 'backup_fail'): + buffer_title, _ = enrich_with_emojis(event_type, title, '', data) for ch_name, channel in channels.items(): # ── Per-channel category check ── @@ -1393,7 +1399,7 @@ class NotificationManager: # delivered after Quiet Hours + Daily Digest were merged. if severity != 'CRITICAL' and self._in_quiet_hours(ch_name): self._buffer_quiet_event(ch_name, event_type, event_group, - severity, title, body) + severity, buffer_title, body) continue # ── Per-channel daily digest ── @@ -1406,7 +1412,7 @@ class NotificationManager: # excluded from the digest by `_DIGEST_EXEMPT_EVENTS`. if self._should_buffer_for_digest(ch_name, severity, event_type): self._buffer_digest_event(ch_name, event_type, event_group, - severity, title, body) + severity, buffer_title, body) continue try: @@ -1821,7 +1827,8 @@ class NotificationManager: print(f"[NotificationManager] digest cleanup failed for " f"{ch_name}: {e}") - def _compose_digest_body(self, rows: list, use_icons: bool = False) -> str: + def _compose_digest_body(self, rows: list, use_icons: bool = False, + quiet_release: bool = False) -> str: """Render a grouped summary body. rows is a list of (id, event_type, event_group, ts, title, body) tuples ordered by timestamp ASC. @@ -1830,27 +1837,47 @@ class NotificationManager: groups: OrderedDict[str, list] = OrderedDict() for _id, ev_type, group, ts, title, body in rows: label = group or 'other' - groups.setdefault(label, []).append((ts, ev_type, title)) + groups.setdefault(label, []).append((ts, ev_type, title, body)) language = self._notification_language() - lines = [runtime_message('digest.lead', language, count=len(rows))] + # The quiet summary title already carries the total; the daily lead + # incorrectly calls every buffered WARNING an INFO event. + lines = [] if quiet_release else [runtime_message('digest.lead', language, count=len(rows))] for group, items in groups.items(): group_label = runtime_message(f'digest.groups.{group}', language) or group.title() group_icon = CATEGORY_EMOJI.get(group, '') if use_icons else '' group_prefix = f'{group_icon} ' if group_icon else '' lines.append(f"{group_prefix}{group_label}: {len(items)}") - for ts, ev_type, title in items[:8]: + # Quiet hours can buffer restore warnings, unlike the daily INFO + # digest. Keep their complete recorded body, even past the usual + # title preview limit, without changing either delivery policy. + visible_items = [item for index, item in enumerate(items) + if index < 8 or (quiet_release and item[1] == 'system_restore_completed')] + for ts, ev_type, title, body in visible_items: hhmm = datetime.fromtimestamp(ts).strftime('%H:%M') + backup_icon = '' + if ev_type in ('backup_complete', 'backup_fail'): + for token in ('💾✅', '💾⚠️', '💾❌', '💾❔', '💾'): + if title.startswith(token + ' '): + backup_icon = token + title = title[len(token) + 1:] + break + backup_icon = backup_icon or ('💾❌' if ev_type == 'backup_fail' else '💾❔') short_title = title.split(': ', 1)[-1] if ': ' in title else title event_icon = ( - EVENT_EMOJI.get(ev_type) or CATEGORY_EMOJI.get(group, '') + backup_icon or EVENT_EMOJI.get(ev_type) or CATEGORY_EMOJI.get(group, '') ) if use_icons else '' event_prefix = f'{event_icon} ' if event_icon else '' lines.append(f" • {event_prefix}{hhmm} {short_title}") - if len(items) > 8: - lines.append(runtime_message('digest.more', language, count=len(items) - 8)) + if quiet_release and ev_type == 'system_restore_completed' and body: + lines.extend(' ' + line.strip() for line in body.splitlines() if line.strip()) + if len(items) > len(visible_items): + lines.append(runtime_message('digest.more', language, count=len(items) - len(visible_items))) lines.append('') - lines.append(runtime_message('digest.footer', language)) + # The daily footer describes live warning delivery, which is not true + # for warnings buffered during quiet hours. Do not repeat that claim. + if not quiet_release: + lines.append(runtime_message('digest.footer', language)) return '\n'.join(lines).rstrip() + '\n' # ─── Quiet Hours buffer + flush ──────────────────────────── @@ -1985,13 +2012,17 @@ class NotificationManager: 'digest.quietTitle', language, hostname=host, count=len(rows), ) use_icons = self._config.get(f'{ch_name}.rich_format', 'false') == 'true' - summary_body = self._compose_digest_body(rows, use_icons=use_icons) + quiet_details = any(row[1] in ('backup_complete', 'backup_fail', 'system_restore_completed') + for row in rows) + summary_body = self._compose_digest_body(rows, use_icons=use_icons, quiet_release=quiet_details) result: dict = {'success': False, 'error': ''} try: result = channel.send( summary_title, summary_body, severity='INFO', data={'_quiet_hours_summary': True, '_count': len(rows), + '_restore_summary': any(row[1] == 'system_restore_completed' for row in rows), + '_backup_summary': any(row[1] in ('backup_complete', 'backup_fail') for row in rows), '_notification_language': language}, ) or result except Exception as e: @@ -2467,6 +2498,11 @@ class NotificationManager: runtime_data.get('hostname'), self._config, ) runtime_data.setdefault('_notification_language', self._notification_language()) + # Match queued dispatch's presentation context for these outcome + # notices; this does not alter event/severity or direct-send policy. + if event_type in ('backup_complete', 'backup_fail', 'system_restore_completed'): + runtime_data['_event_type'] = event_type + runtime_data['_group'] = TEMPLATES[event_type].get('group', 'other') # Render template if available if event_type in TEMPLATES and not message: diff --git a/AppImage/scripts/notification_templates.py b/AppImage/scripts/notification_templates.py index b8b20c91..2192c50f 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -206,6 +206,58 @@ def _format_lxc_update_details(data: Dict[str, Any], language: str) -> str: # ─── vzdump message parser ─────────────────────────────────────── +def _parse_vzdump_table(message: str) -> Optional[Dict[str, Any]]: + """Read the bounded fixed-column summary for both outcomes and guest details.""" + lines = message.splitlines() + for index, header in enumerate(lines): + if not re.match(r'\s*VMID\s+Name\s+Status\b', header, re.IGNORECASE): + continue + columns = [re.search(r'\b' + name + r'\b', header, re.IGNORECASE) + for name in ('VMID', 'Name', 'Status', 'Time', 'Size', 'Filename')] + if not all(columns): + return {'vms': [], 'complete': False} + starts = [column.start() for column in columns if column is not None] + if starts != sorted(starts): + return {'vms': [], 'complete': False} + rows = [] + valid = True + complete = False + for line in lines[index + 1:]: + if not line.strip(): + continue + if re.match(r'\s*Total running time:\s*\S', line, re.IGNORECASE): + complete = valid and bool(rows) + break + # Blanks are allowed, but no unrelated section can extend the table. + if not re.match(r'\s*\d+\s+', line): + break + values = [line[a:b].strip() for a, b in + zip(starts, starts[1:] + [len(line)])] + vmid, name, status, duration, size, filename = values + if not vmid.isdigit(): + valid = False + break + valid = bool(valid and all(values) + and re.fullmatch(r'(?:\d+:\d{2}:\d{2}|(?:\d+[yMwdhms]\s*)+)', duration) + and re.fullmatch(r'\d+(?:\.\d+)?\s*(?:[KMGTPE]i?B?|B)', size, re.IGNORECASE)) + if status.lower() in ('err', 'error'): + status = 'error' + kind = ('lxc' if 'lxc' in filename or filename.startswith('ct/') else + 'qemu' if 'qemu' in filename or filename.startswith('vm/') else '') + if filename.lower() == 'null': + # Native failed rows lack archives. Match only this row's VMID; + # multiple inconsistent starts are not authoritative identity. + kinds = set(re.findall( + r'(?im)\bStarting Backup of VM ' + re.escape(vmid) + r'\s+\((lxc|qemu)\)', + message)) + kind = kinds.pop() if len(kinds) == 1 else '' + rows.append({'vmid': vmid, 'name': name, 'status': status, + 'time': duration, 'size': size, 'filename': filename, + 'type': kind}) + return {'vms': rows, 'complete': bool(complete)} + return None + + def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]: """Parse a PVE vzdump notification message into structured data. @@ -225,53 +277,10 @@ def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]: lines = message.split('\n') - # ── Strategy 1: classic table (local/NFS/CIFS storage) ── - header_idx = -1 - for i, line in enumerate(lines): - if re.match(r'\s*VMID\s+Name\s+Status', line, re.IGNORECASE): - header_idx = i - break - - if header_idx >= 0: - # Use column positions from the header to slice each row. - # Header: "VMID Name Status Time Size Filename" - header = lines[header_idx] - col_starts = [] - for col_name in ['VMID', 'Name', 'Status', 'Time', 'Size', 'Filename']: - idx = header.find(col_name) - if idx >= 0: - col_starts.append(idx) - - if len(col_starts) == 6: - for line in lines[header_idx + 1:]: - stripped = line.strip() - if not stripped or stripped.startswith('Total') or stripped.startswith('Logs') or stripped.startswith('='): - break - # Pad line to avoid index errors - padded = line.ljust(col_starts[-1] + 50) - vmid = padded[col_starts[0]:col_starts[1]].strip() - name = padded[col_starts[1]:col_starts[2]].strip() - status = padded[col_starts[2]:col_starts[3]].strip() - time_val = padded[col_starts[3]:col_starts[4]].strip() - size = padded[col_starts[4]:col_starts[5]].strip() - filename = padded[col_starts[5]:].strip() - - if vmid and vmid.isdigit(): - # Infer type from filename (vzdump-lxc-NNN or vzdump-qemu-NNN) - vm_type = '' - if 'lxc' in filename: - vm_type = 'lxc' - elif 'qemu' in filename: - vm_type = 'qemu' - vms.append({ - 'vmid': vmid, - 'name': name, - 'status': status, - 'time': time_val, - 'size': size, - 'filename': filename, - 'type': vm_type, - }) + # The same summary rows drive classification, rich bodies and identities. + table = _parse_vzdump_table(message) + if table is not None: + vms = table['vms'] # ── Strategy 2: log-style (PBS / Proxmox Backup Server) ── # Parse from the full vzdump log lines. @@ -299,7 +308,7 @@ def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]: current_vm = { 'vmid': m_start.group(1), 'name': '', - 'status': 'ok', + 'status': 'unknown', 'time': '', 'size': '', 'filename': '', @@ -307,6 +316,18 @@ def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]: } continue + # A finish can belong to a guest already stored when another + # start arrived. Preserve that guest's actual completion too. + prior_finish = re.match(r'Finished Backup of VM (\d+)\s+\(([^)]+)\)', clean) + if prior_finish: + prior = next((vm for vm in reversed(vms) + if vm['vmid'] == prior_finish.group(1)), None) + if prior is not None: + prior['time'] = prior_finish.group(2) + if prior['status'] != 'error': + prior['status'] = 'ok' + continue + if current_vm: # Guest name m_name = re.match(r'(?:CT|VM) Name:\s*(.+)', clean) @@ -338,15 +359,16 @@ def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]: # Finished -> duration m_finish = re.match( r'Finished Backup of VM (\d+)\s+\(([^)]+)\)', clean) - if m_finish: + if m_finish and m_finish.group(1) == current_vm['vmid']: current_vm['time'] = m_finish.group(2) - current_vm['status'] = 'ok' + if current_vm['status'] != 'error': + current_vm['status'] = 'ok' vms.append(current_vm) current_vm = None continue # Error - if clean.startswith('ERROR:') or clean.startswith('TASK ERROR'): + if re.match(r'^\s*(?:ERROR:|TASK ERROR)', line, re.IGNORECASE): if current_vm: current_vm['status'] = 'error' @@ -354,6 +376,21 @@ def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]: if current_vm: vms.append(current_vm) + # Explicit guest-linked failures outrank a contradictory summary OK row. + # Job-level/prune errors do not invalidate unrelated successfully saved guests. + for line in lines: + error = re.match(r'^\s*(?:\d+:\s*)?(?:\d{4}-\d{2}-\d{2}\s+\S+\s+)?(?:ERROR:|TASK ERROR:)\s*(.*)', + line, re.IGNORECASE) + if not error: + continue + failed_guest = re.search(r'\bBackup of (?:VM|CT) (\d+) failed\b|\bbackup failed for (?:VM|CT) (\d+)\b', + error.group(1), re.IGNORECASE) + if failed_guest: + vmid = failed_guest.group(1) or failed_guest.group(2) + for vm in vms: + if vm['vmid'] == vmid: + vm['status'] = 'error' + # ── Extract totals ── for line in lines: m_time = re.search(r'Total running time:\s*(.+)', line) @@ -369,7 +406,7 @@ def _parse_vzdump_message(message: str) -> Optional[Dict[str, Any]]: sizes_gib = 0.0 for vm in vms: s = vm.get('size', '') - m = re.match(r'([\d.]+)\s+(.*)', s) + m = re.fullmatch(r'(\d+(?:\.\d+)?)\s+([KMGTPE]i?B|B)', s, re.IGNORECASE) if m: val = float(m.group(1)) unit = m.group(2).strip().upper() @@ -439,7 +476,7 @@ def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool, for vm in parsed.get('vms', []): status = vm.get('status', '').lower() - icon = '\u2705' if status == 'ok' else '\u274C' + icon = '\u2705' if status == 'ok' else '\u274C' if status == 'error' else '\u2754' # Determine VM/CT type prefix vm_type = vm.get('type', '') @@ -501,7 +538,8 @@ def _format_vzdump_body(parsed: Dict[str, Any], is_success: bool, if vm_count > 0 or parsed.get('total_size'): ok_count = sum(1 for v in parsed.get('vms', []) if v.get('status', '').lower() == 'ok') - fail_count = vm_count - ok_count + fail_count = sum(1 for v in parsed.get('vms', []) + if v.get('status', '').lower() == 'error') summary_parts = [] if vm_count: @@ -1855,6 +1893,50 @@ def render_template(event_type: str, data: Dict[str, Any], ) if localized: template[field] = localized + backup_title_target = '' + if event_type == 'backup_complete': + template['title'] = runtime_message('backup.unconfirmedTitle', language, + hostname=data.get('hostname') or _get_hostname()) or ( + str(data.get('hostname') or _get_hostname()) + ': Backup outcome unconfirmed') + template['body'] = runtime_message('backup.unconfirmedBody', language) or 'The backup outcome is not confirmed.' + outcome = data.get('backup_outcome') + if outcome == 'confirmed': + template['title'] = runtime_message('backup.confirmedTitle', language, + hostname=data.get('hostname') or _get_hostname()) + template['body'] = runtime_message('backup.confirmedBody', language) + elif outcome == 'completed_with_warnings': + template['title'] = runtime_message('backup.warningTitle', language, + hostname=data.get('hostname') or _get_hostname()) + template['body'] = runtime_message('backup.warningBody', language) + elif outcome == 'failed': + template['title'] = runtime_message('backup.errorTitle', language, + hostname=data.get('hostname') or _get_hostname()) + template['body'] = runtime_message('backup.errorBody', language) + if event_type == 'backup_fail': + template['title'] = runtime_message('backup.errorTitle', language, + hostname=data.get('hostname') or _get_hostname()) + if event_type == 'backup_fail' or (event_type == 'backup_complete' and data.get('backup_outcome') in ('confirmed', 'completed_with_warnings', 'failed')): + parsed_backup = _parse_vzdump_message(str(data.get('pve_message') or '')) + storage = str((parsed_backup or {}).get('storage_name') or data.get('storage') or '').strip() + guests = (parsed_backup or {}).get('vms') or [] + # Explicit confirmed manual metadata is useful context, not evidence + # about an unparsed batch. Only use it when there is no raw report. + if not data.get('pve_message') and data.get('backup_outcome') == 'confirmed' and data.get('vmid'): + guests = [{'vmid': str(data['vmid']), 'name': str(data.get('vmname') or ''), + 'type': str(data.get('vm_type') or ''), 'status': 'ok'}] + if event_type == 'backup_fail' or data.get('backup_outcome') == 'failed': + guests = [guest for guest in guests if guest.get('status', '').lower() == 'error'] + target = [] + if storage: + target.append(storage) + if len(guests) == 1: + guest = guests[0] + kind = 'VM' if guest.get('type') == 'qemu' else 'CT' if guest.get('type') == 'lxc' else 'VM/CT' + name = guest.get('name') or kind + target.append(f"{kind} {name} ({guest['vmid']})" if name != kind + else f"{kind} {guest['vmid']}") + if target: + backup_title_target = ' — ' + ' · '.join(target) # Ensure hostname is always available variables = { @@ -1884,6 +1966,11 @@ def render_template(event_type: str, data: Dict[str, Any], 'log_file': '', } variables.update(data) + if event_type in ('backup_fail', 'backup_complete'): + # The provider has already substituted raw Display Names. Insert the + # resolved title as a value, never reinterpret its literal braces. + variables['_backup_title'] = template['title'] + template['title'] = '{_backup_title}' # Old persisted errors and manual events may lack a complete reading. # Accept plain numeric strings, but never interpret booleans or objects as @@ -1992,12 +2079,37 @@ def render_template(event_type: str, data: Dict[str, Any], title = template['title'].format_map(safe_vars) except (ValueError, IndexError): title = template['title'] + title += backup_title_target # ── PVE vzdump special formatting ── # When the event came from PVE webhook with a full vzdump message, # parse the table/logs and format a rich body instead of the sparse template. pve_message = data.get('pve_message', '') - pve_title = data.get('pve_title', '') + backup_diagnostics = [] + principal_cause = None + + def bounded_backup_diagnostics(lines, principal_cause=None): + # 1024 chars matches the repository's small-channel message convention; + # 8 lines keeps repeated producer warnings readable. Inventory/title + # size is separate: this is not a one-Telegram-message guarantee. + unique = list(dict.fromkeys(line for line in lines if line.strip())) + principal = principal_cause or next((line for line in unique if re.search(r'\b(?:ERROR:|TASK ERROR:)', line, re.IGNORECASE)), None) + if principal: + unique.remove(principal) + unique.insert(0, principal) + shown, budget, omitted = [], 1024, 0 + for line in unique: + if len(shown) >= 8 or budget < 2: + omitted += 1 + continue + line_budget = min(budget, 512) + rendered = line if len(line) <= line_budget else line[:line_budget - 1] + '…' + omitted += int(rendered != line) + shown.append(rendered) + budget -= len(rendered) + 1 + if omitted: + shown.append(runtime_message('backup.diagnosticsOmitted', language, count=omitted)) + return '\n'.join(shown) # Check for custom formatter function formatter_name = template.get('formatter') @@ -2016,13 +2128,29 @@ def render_template(event_type: str, data: Dict[str, Any], if parsed: is_success = (event_type == 'backup_complete') body_text = _format_vzdump_body(parsed, is_success, language=language) - # Preserve PVE's source title for English, but never leak it into a - # deterministic localized notification. - if pve_title and requested_language == 'en': - title = pve_title + diagnostic_lines = [line.strip() for line in pve_message.splitlines() + if re.match(r'^\s*(?:\d+:\s*)?(?:\d{4}-\d{2}-\d{2}\s+\S+\s+)?(?:WARN(?:ING)?:|ERROR:|TASK ERROR)', + line, re.IGNORECASE)] + if event_type == 'backup_fail' or data.get('backup_outcome') == 'failed': + # Native send_notification puts multiline job/setup errors + # before Details, while its subject says only "multiple problems". + # Keep that raw block when inventory replaces the producer body; + # it is job context, not evidence that every guest failed. + error_block = re.match(r'\A(.*?)^Details\r?\n=+\s*$', + pve_message, re.MULTILINE | re.DOTALL) + if error_block: + diagnostic_lines = error_block.group(1).rstrip('\r\n').splitlines() + diagnostic_lines + backup_diagnostics = [line for line in diagnostic_lines + if line.strip() and line not in body_text.splitlines()] else: - # Couldn't parse -- use PVE raw message as body - body_text = pve_message.strip() + # Unparsed diagnostic-only reports remain visible but bounded. + body_text = '' + backup_diagnostics = pve_message.strip().splitlines() + if event_type == 'backup_complete' and data.get('backup_outcome') != 'confirmed': + key = ('backup.errorBody' if data.get('backup_outcome') == 'failed' + else 'backup.warningBody' if data.get('backup_outcome') == 'completed_with_warnings' + else 'backup.unconfirmedBody') + body_text = (runtime_message(key, language) or template['body']) + '\n' + body_text elif event_type == 'system_mail' and pve_message: # System mail -- use PVE message directly (mail bounce, cron, smartd) body_text = pve_message.strip()[:1000] @@ -2032,6 +2160,51 @@ def render_template(event_type: str, data: Dict[str, Any], except (ValueError, IndexError): body_text = template['body'] + if event_type == 'backup_complete' and data.get('backup_outcome') == 'confirmed' and not pve_message: + context = [] + if data.get('vmid'): + name = str(data.get('vmname') or '') + context.append(f"{name} ({data['vmid']})" if name else str(data['vmid'])) + if data.get('storage'): + context.append(str(data['storage'])) + if data.get('size'): + context.append(runtime_message('vzdump.size', language, value=data['size'])) + if data.get('duration'): + context.append(runtime_message('vzdump.duration', language, value=data['duration'])) + if context: + body_text += '\n' + '\n'.join(context) + + # PVE can move a one-line setup/abort reason exclusively into its subject. + # Preserve that raw failure context, without using it as a localized title. + if event_type in ('backup_complete', 'backup_fail') and ( + event_type == 'backup_fail' or data.get('backup_outcome') == 'failed'): + source_subject = str(data.get('pve_title') or '').strip() + native_failure = re.fullmatch( + r'vzdump backup status \([^\r\n]*\): backup failed(?::\s*(.*))?', + source_subject, re.IGNORECASE) + guest_context = (_parse_vzdump_message(str(pve_message or '')) or {}).get('vms') + if native_failure: + # Before the first guest, too, only the native cause is diagnostic; + # the original host/job envelope is not display-name context. + source_subject = (native_failure.group(1) or '').strip() + elif guest_context: + cause = re.search(r'\bbackup failed:\s*(.+)', source_subject, re.IGNORECASE) + source_subject = cause.group(1).strip() if cause else '' + if source_subject.lower() == 'multiple problems': + source_subject = '' + if source_subject and source_subject not in {line.strip() for line in body_text.splitlines()}: + # Reserve the subject-equivalent diagnostic BEFORE the cap. Finding + # it in uncapped logs is not enough: that late line could be omitted. + principal_cause = next((line for line in backup_diagnostics + if line.strip() == source_subject or + re.split(r'\b(?:TASK ERROR:|ERROR:)\s*', line, maxsplit=1, flags=re.IGNORECASE)[-1].strip() == source_subject), None) + if not principal_cause: + principal_cause = source_subject + backup_diagnostics.insert(0, source_subject) + + if backup_diagnostics: + body_text += '\n' + bounded_backup_diagnostics(backup_diagnostics, principal_cause) + # Clean up: collapse runs of 3+ blank lines into 1, remove trailing whitespace import re as _re body_text = _re.sub(r'\n{3,}', '\n\n', body_text.strip()) @@ -2166,7 +2339,7 @@ EVENT_EMOJI = { 'host_backup_start': '\U0001F5C4️\U0001F680', # 🗄️🚀 cabinet + rocket 'host_backup_complete': '\U0001F5C4️✅', # 🗄️✅ cabinet + check 'host_backup_fail': '\U0001F5C4️❌', # 🗄️❌ cabinet + cross - 'backup_complete': '\U0001F4BE\u2705', # 💾✅ floppy + check + 'backup_complete': '\U0001F4BE', # 💾 neutral for digests without outcome metadata 'backup_warning': '\U0001F4BE\u26A0\uFE0F', # 💾⚠️ floppy + warning 'backup_fail': '\U0001F4BE\u274C', # 💾❌ floppy + cross 'snapshot_complete': '\U0001F4F8', # camera with flash @@ -2363,6 +2536,10 @@ def enrich_with_emojis(event_type: str, title: str, body: str, severity = data.get('severity', 'INFO') icon = EVENT_EMOJI.get(event_type) or CATEGORY_EMOJI.get(group) or SEVERITY_ICONS.get(severity, '') + if event_type == 'backup_complete': + icon = { + 'confirmed': '💾✅', 'completed_with_warnings': '💾⚠️', 'failed': '💾❌', + }.get(str(data.get('backup_outcome') or ''), '💾❔') # Build enriched title: replace severity circle with event-specific icon # Current format: "hostname: Something" -> "ICON hostname: Something" diff --git a/AppImage/scripts/tests/test_notification_runtime_i18n.py b/AppImage/scripts/tests/test_notification_runtime_i18n.py index 14cacb30..58961d09 100644 --- a/AppImage/scripts/tests/test_notification_runtime_i18n.py +++ b/AppImage/scripts/tests/test_notification_runtime_i18n.py @@ -68,10 +68,23 @@ class RuntimeCatalogTests(unittest.TestCase): return result en = flatten(self.catalogs["en"]) + pending_slovak = {"backup.confirmedTitle", "backup.confirmedBody", + "backup.errorTitle", "backup.errorBody", "backup.unconfirmedBody", + "backup.unconfirmedTitle", "channels.email.status.unconfirmed", + "backup.warningTitle", "backup.warningBody", "backup.diagnosticsOmitted", + "channels.email.status.completed_with_warnings"} for language, catalog in self.catalogs.items(): translated = flatten(catalog) - self.assertEqual(set(translated), set(en), language) - for key in en: + if language == 'sk': + # Missing maintainer-owned leaves may be generated later. + # Accept only this bounded gap, and validate every present leaf. + self.assertTrue(set(en) - pending_slovak <= set(translated), language) + self.assertTrue(set(translated) <= set(en), language) + else: + self.assertEqual(set(translated), set(en), language) + for key in translated: + self.assertIsInstance(translated[key], str, f"{language}:{key}") + self.assertTrue(translated[key].strip(), f"{language}:{key}") self.assertEqual(_placeholders(translated[key]), _placeholders(en[key]), f"{language}:{key}") def test_notification_language_ui_keys_exist_in_both_catalogs(self): @@ -111,6 +124,11 @@ class RuntimeCatalogTests(unittest.TestCase): with mock.patch.object(notification_templates, "_get_hostname", return_value="HOST-ŽILINA"): for event_type in notification_templates.TEMPLATES: event_values = dict(values) + if event_type == "backup_complete": + # Legacy catalog fields keep their completion meaning; + # the new unknown-outcome keys intentionally omit guest data. + # Exercise retained metadata on the explicit confirmed path. + event_values["backup_outcome"] = "confirmed" if event_type == "temp_high": # Temperature is a measured numeric contract; arbitrary # DYNAMIC_VALUE is correctly rejected by its fallback. @@ -312,6 +330,7 @@ class RuntimeCatalogTests(unittest.TestCase): "backup_complete", { "hostname": "pve01", "storage": "pbs-main", "vmname": "alpha", "vmid": "100", + "backup_outcome": "confirmed", "pve_title": "Backup job finished", "pve_message": ( "INFO: Starting Backup of VM 100 (qemu)\n" @@ -322,7 +341,12 @@ class RuntimeCatalogTests(unittest.TestCase): }, language="sk", ) - self.assertIn("Záloha dokončená", backup["title"]) + expected_title = notification_templates.runtime_message( + "backup.confirmedTitle", "sk", hostname="pve01", + ) + self.assertTrue(backup["title"].startswith(expected_title + " — ")) + self.assertIn("pbs-main", backup["title"]) + self.assertIn("VM alpha (100)", backup["title"]) self.assertNotIn("Backup job finished", backup["title"]) self.assertIn("Veľkosť: 1.5 GiB", backup["body"]) self.assertIn("Trvanie: 00:00:10", backup["body"]) @@ -627,6 +651,7 @@ class RuntimeCatalogTests(unittest.TestCase): "_notification_language": "sk", "_event_type": event_type, "_group": "backup", "hostname": "pve01", "vmid": "100", "vmname": "alpha", "storage": "pbs-main", + "backup_outcome": "confirmed" if event_type == "backup_complete" else "unconfirmed", }, ) self.assertIn(f">{localized_status}<", backup_html) diff --git a/AppImage/scripts/tests/test_vzdump_ai_integrity.py b/AppImage/scripts/tests/test_vzdump_ai_integrity.py index 458606bf..2286842c 100644 --- a/AppImage/scripts/tests/test_vzdump_ai_integrity.py +++ b/AppImage/scripts/tests/test_vzdump_ai_integrity.py @@ -275,8 +275,14 @@ class VzdumpAIIntegrityTests(unittest.TestCase): rendered["body"], "CRITICAL", data, ) + # The failure title now identifies the unique failed guest; inventory + # remains exactly once in the detail table, not suppressed from body. + inventory = html.split('', 1)[1].split(' |