From e52f52388ad15f627bdd244091894efa9275d479 Mon Sep 17 00:00:00 2001 From: martino <32328813+f3rs3n@users.noreply.github.com> Date: Tue, 29 Sep 2026 22:20:28 +0200 Subject: [PATCH] fix(monitor): parse PVE 9.2 backup reports and remove Slovak overrides --- .../test_notification_outcome_wording.py | 57 +---- .../scripts/tests/test_notification_pve92.py | 194 ++++++++++++++++++ AppImage/scripts/notification_events.py | 52 ++--- AppImage/scripts/notification_templates.py | 142 ++++++------- 4 files changed, 283 insertions(+), 162 deletions(-) create mode 100644 .github/scripts/tests/test_notification_pve92.py diff --git a/.github/scripts/tests/test_notification_outcome_wording.py b/.github/scripts/tests/test_notification_outcome_wording.py index 824dd016..5b2f8820 100644 --- a/.github/scripts/tests/test_notification_outcome_wording.py +++ b/.github/scripts/tests/test_notification_outcome_wording.py @@ -59,6 +59,7 @@ def renderer(catalog, translated=None): '_catalog_value': lookup, 'runtime_message': message} from typing import Optional ns['Optional'] = Optional + extract(path, '_parse_vzdump_table', namespace=ns) extract(path, '_parse_vzdump_message', namespace=ns) extract(path, '_format_vzdump_body', namespace=ns) return templates, extract(path, 'render_template', namespace=ns) @@ -71,54 +72,6 @@ class OutcomeWording(unittest.TestCase): cls.templates, render = renderer(cls.catalog) cls.render = staticmethod(render) - def test_upstream_slovak_stale_claims_use_english_report_fallback(self): - import importlib.util - spec = importlib.util.spec_from_file_location('isolated_slovak_report', SCRIPTS / 'notification_templates.py') - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - recovered = module.render_template('error_resolved', {'hostname':'node-a', - 'category':'temperature','reason':'old','duration':'3d', - 'original_severity':'WARNING'}, 'sk') - self.assertIn('No longer reported',recovered['title']) - self.assertIn('no longer in active health records',recovered['body']) - restored = module.render_template('system_restore_completed', {'hostname':'node-a', - 'guests':3,'stubs':0,'stale_nodes':0,'components':1,'duration':'2m', - 'warnings_block':'⚠️ Boot check pending'}, 'sk') - self.assertIn('Post-restore tasks completed',restored['body']) - self.assertNotIn('úplne pripravený',restored['body']) - - def test_slovak_fallback_is_per_stale_leaf_not_unrelated_key_presence(self): - import importlib.util - spec = importlib.util.spec_from_file_location('isolated_slovak_future', SCRIPTS / 'notification_templates.py') - assert spec is not None and spec.loader is not None - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - upstream = json.loads((ROOT / 'AppImage/messages/sk/common.json').read_text())['runtime']['notifications'] - english = self.catalog['runtime']['notifications'] - data = {'hostname': 'node-a', 'category': 'temperature', 'reason': 'old', - 'duration': '3d', 'original_severity': 'WARNING', 'guests': 3} - for event, field in (('error_resolved', 'title'), ('error_resolved', 'body'), - ('system_restore_completed', 'body'), - ('backup_complete', 'title'), ('backup_complete', 'body')): - # A new translation must work independently of another family's key. - translated = copy.deepcopy(upstream) - translated['templates'][event][field] = 'REVIEWED TRANSLATION {hostname}' - with self.subTest(event=event, field=field, case='future translation'): - with patch.object(module, '_load_runtime_catalog', side_effect=lambda lang: translated if lang == 'sk' else english): - result = module.render_template(event, data, 'sk') - self.assertEqual(result[field], 'REVIEWED TRANSLATION node-a') - # Adding outcome keys must not re-enable unrelated stale claims. - stale = copy.deepcopy(upstream) - stale.setdefault('backup', {})['unconfirmedBody'] = 'REVIEWED OUTCOME' - with self.subTest(event=event, field=field, case='stale after key addition'): - with patch.object(module, '_load_runtime_catalog', side_effect=lambda lang: stale if lang == 'sk' else english): - result = module.render_template(event, data, 'sk') - self.assertEqual(result[field], module.render_template(event, data, 'en')[field]) - # The unchanged restore title is not an unsafe readiness claim. - self.assertEqual(module.render_template('system_restore_completed', data, 'sk')['title'], - upstream['templates']['system_restore_completed']['title'].format(**data)) - def test_spanish_restore_uses_maintainer_guests_terminology(self): import importlib.util spec = importlib.util.spec_from_file_location('isolated_spanish_restore', SCRIPTS / 'notification_templates.py') @@ -256,6 +209,7 @@ class OutcomeWording(unittest.TestCase): from typing import Dict, Optional ns = {'re': re, 'Dict': Dict, 'Optional': Optional, 'Any': Any, 'runtime_message': lambda key, lang, **kw: key} + extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_table', namespace=ns) parser = extract(SCRIPTS / 'notification_templates.py', '_parse_vzdump_message', namespace=ns) formatter = extract(SCRIPTS / 'notification_templates.py', '_format_vzdump_body', namespace=ns) incomplete = parser('INFO: Starting Backup of VM 104 (qemu)') @@ -392,9 +346,7 @@ class OutcomeWording(unittest.TestCase): self.catalog['runtime']['notifications']['backup'][key]).format(hostname=data['hostname']) self.assertTrue(result['title'].startswith(expected_title), result['title']) else: - source = (catalog if catalog.get('backup', {}).get('unconfirmedBody') - else self.catalog['runtime']['notifications']) - self.assertEqual(result['title'], source['templates']['backup_complete']['title'].format(hostname=data['hostname'])) + self.assertEqual(result['title'], catalog['templates']['backup_complete']['title'].format_map(module._SafeFormatDict(data))) self.assertNotIn('{hostname}', result['title']) if state == 'unconfirmed': source = (catalog if catalog.get('backup', {}).get('unconfirmedBody') @@ -407,8 +359,7 @@ class OutcomeWording(unittest.TestCase): self.assertTrue(enriched.startswith({'confirmed':'💾✅','unconfirmed':'💾❔','failed':'💾❌'}[state])) recovery = module.render_template('error_resolved', {'hostname':'node','category':'temperature', 'reason':'Old observation','duration':'3d','original_severity':'WARNING'}, lang) - recovery_source = (catalog if catalog.get('backup', {}).get('unconfirmedBody') - else self.catalog['runtime']['notifications']) + recovery_source = catalog self.assertEqual(recovery['title'], recovery_source['templates']['error_resolved']['title'].format(hostname='node',category='temperature',entity_suffix='')) self.assertNotIn('resolved', recovery['title'].lower()) if lang == 'en' else None restore = module.render_template('system_restore_completed', {'hostname':'node', 'guests':4, diff --git a/.github/scripts/tests/test_notification_pve92.py b/.github/scripts/tests/test_notification_pve92.py new file mode 100644 index 00000000..16f27487 --- /dev/null +++ b/.github/scripts/tests/test_notification_pve92.py @@ -0,0 +1,194 @@ +"""Frozen PVE 9.2 report through inert actual receiver and renderers. + +No git history, host-management import, notification send or generated fixture. +""" +import ast +import importlib.util +import re +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[3] +SCRIPTS = ROOT / 'AppImage/scripts' +sys.path.insert(0, str(SCRIPTS)) +import notification_templates as templates + +PVE92 = '''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 +''' + + +def receiver(): + tree = ast.parse((SCRIPTS / 'notification_events.py').read_text()) + owner = next(n for n in tree.body if isinstance(n, ast.ClassDef) and n.name == 'ProxmoxHookWatcher') + names = ('_classify_pve', '_map_severity', '_backup_outcome', 'process_webhook') + 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 + for name in names: + node = next(n for n in owner.body if isinstance(n, ast.FunctionDef) and n.name == name) + node.decorator_list = [] + exec(compile(ast.Module(body=[node], type_ignores=[]), '', 'exec'), ns) + class Queue: + def __init__(self): self.items = [] + def put(self, event): self.items.append(event) + class Receiver: + _hostname = 'node-a' + _classify_pve = ns['_classify_pve'] + _map_severity = staticmethod(ns['_map_severity']) + _backup_outcome = staticmethod(ns['_backup_outcome']) + process_webhook = ns['process_webhook'] + def __init__(self): self._queue = Queue() + return Receiver() + + +def event_for(message, severity='info'): + target = receiver() + target.process_webhook({'fields': {'type': 'vzdump'}, 'severity': severity, + 'title': 'Backup', 'message': message}) + return target._queue.items[0] + + +class PVE92Tests(unittest.TestCase): + def test_exact_maintainer_report_is_confirmed(self): + event = event_for(PVE92) + self.assertEqual(event.data['backup_outcome'], 'confirmed') + result = templates.render_template(event.event_type, event.data, 'en') + self.assertIn('Backup complete', result['title']) + self.assertIn('web (100)', result['title']) + self.assertIn('✅', result['body']) + + def test_failed_guest_identity_does_not_come_from_timestamp_or_ok_guest(self): + failed = '101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z' + ok = PVE92.splitlines()[3] + for rows in (ok + '\n' + failed, failed + '\n' + ok): + for severity in ('info', 'error'): + with self.subTest(rows=rows, severity=severity): + message = ('INFO: 100 01:01:06 OK\nINFO: Starting Backup of VM 100 (qemu)\n' + + PVE92.replace(ok, rows)) + event = event_for(message, severity) + self.assertEqual(event.data['backup_outcome'], 'failed') + self.assertEqual(event.data.get('vmname'), 'db') + self.assertEqual(event.data.get('vmid'), '101') + result = templates.render_template(event.event_type, event.data, 'en') + self.assertIn('db (101)', result['title']) + self.assertNotIn('web', result['title']) + self.assertNotIn('01:01:06', result['title']) + self.assertIn('❌ CT db (101)', result['body']) + + def test_pbs_prefix_is_used_in_parsed_type_and_title(self): + for prefix, kind, label in (('vm', 'qemu', 'VM'), ('ct', 'lxc', 'CT'), + ('other', '', 'VM/CT')): + with self.subTest(prefix=prefix): + message = PVE92.replace('vm/100/', prefix + '/100/') + parsed = templates._parse_vzdump_message(message) + self.assertEqual(parsed['vms'][0]['type'], kind) + event = event_for(message) + result = templates.render_template(event.event_type, event.data, 'en') + self.assertIn(label + ' web (100)', result['title']) + + def test_slovak_uses_normal_locale_resolution_without_source_sentence_overrides(self): + from unittest.mock import patch + data = {'hostname': 'node-a', 'category': 'temperature', 'entity_suffix': '', + 'reason': 'old', 'duration': '3d', 'original_severity': 'WARNING', + 'vmname': 'web', 'vmid': '100', 'storage': 'PBS', 'size': '1 GiB', + 'guests': 3, 'stubs': 0, 'stale_nodes': 0, 'components': 1, + 'warnings_block': ''} + slovak = templates._load_runtime_catalog('sk') + english = templates._load_runtime_catalog('en') + for event, field in (('error_resolved', 'title'), ('error_resolved', 'body'), + ('system_restore_completed', 'body'), + ('backup_complete', 'title'), ('backup_complete', 'body')): + with self.subTest(event=event, field=field): + value = slovak['templates'][event][field] + result = templates.render_template(event, data, 'sk') + self.assertEqual(result[field], value.format(**data)) + self.assertNotIn(value, (SCRIPTS / 'notification_templates.py').read_text()) + # Independently updated and absent leaves use the usual provider. + import copy + future = copy.deepcopy(slovak) + future['templates'][event][field] = 'REVIEWED {hostname}' + with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english): + self.assertEqual(templates.render_template(event, data, 'sk')[field], 'REVIEWED node-a') + future['templates'][event].pop(field) + with patch.object(templates, '_load_runtime_catalog', side_effect=lambda lang: future if lang == 'sk' else english): + self.assertEqual(templates.render_template(event, data, 'sk')[field], + templates.render_template(event, data, 'en')[field]) + + def test_complete_table_beats_only_truncated_supplemental_log(self): + message = ('INFO: Starting Backup of VM 100 (qemu)\n' + PVE92 + + '\nLogs\n====\nINFO: Log output was too long to be displayed. Please see task log for details.') + self.assertEqual(event_for(message).data['backup_outcome'], 'confirmed') + for message, severity, expected in ( + (PVE92.replace('ok ', 'OK '), 'info', 'confirmed'), + (message, 'warning', 'unconfirmed'), + (message, 'error', 'failed'), + (message + '\nERROR: archive write failed', 'info', 'failed'), + (message + '\nWARNING: skipped file', 'info', 'unconfirmed'), + (PVE92.replace('ok ', 'WARNINGS '), 'info', 'unconfirmed'), + ): + with self.subTest(message=message, severity=severity): + self.assertEqual(event_for(message, severity).data['backup_outcome'], expected) + + def test_incomplete_and_unrelated_sections_cannot_certify_table(self): + for message in ( + PVE92.split('Total running time:')[0], + PVE92.replace('vm/100/2026-09-29T17:00:00Z', ''), + PVE92.replace('\n\nTotal', '\n\nLogs\n======\nTotal'), + PVE92.replace('\n\nTotal', '\n\nUnrelated section\n100 web ok\nTotal'), + PVE92.replace('1m 1s 1 GiB', 'nonsense 1 GiB'), + PVE92.replace('1 GiB vm/', 'garbage vm/'), + PVE92.replace('1m 1s 1 GiB vm/', '1m 1s'), + ): + with self.subTest(message=message): + # Complete guest logs cannot rescue a genuinely incomplete table. + message += '\nINFO: Starting Backup of VM 100 (qemu)\nINFO: Finished Backup of VM 100 (00:01:01)' + self.assertEqual(event_for(message).data['backup_outcome'], 'unconfirmed') + + def test_blank_line_between_rows_does_not_hide_a_failure(self): + message = PVE92.replace('\n\nTotal', + '\n\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal') + event = event_for(message) + self.assertEqual(event.data['backup_outcome'], 'failed') + result = templates.render_template(event.event_type, event.data, 'en') + self.assertIn('db (101)', result['title']) + self.assertIn('❌ CT db (101)', result['body']) + + def test_changed_titles_and_rows_reach_actual_html_email_in_all_locales(self): + import html + from notification_channels import EmailChannel + email = object.__new__(EmailChannel) + email.subject_prefix = '[ProxMenux]' + failed = PVE92.replace('\n\nTotal', + '\n101 db err 1m 1s 0 B ct/101/2026-09-29T17:00:00Z\n\nTotal') + for lang in ('en', 'de', 'es', 'fr', 'it', 'pt', 'sk', 'sv'): + for message, severity in ((PVE92, 'info'), (failed, 'info'), (failed, 'error')): + with self.subTest(lang=lang, severity=severity, message=message): + event = event_for(message, severity) + result = templates.render_template(event.event_type, event.data, lang) + context = {**event.data, '_event_type': event.event_type, + '_notification_language': lang, '_group': result['group']} + markup = html.unescape(email._format_html(result['title'], result['body'], event.severity, context)) + self.assertIn(result['title'], markup) + if event.data['backup_outcome'] == 'failed': + self.assertIn('db (101)', result['title']) + self.assertNotIn('web', result['title']) + self.assertIn('❌ CT db (101)', markup) + status = templates.runtime_message('channels.email.status.failed', lang) + else: + self.assertIn('VM web (100)', result['title']) + self.assertIn('✅ VM web (100)', markup) + status = templates.runtime_message('channels.email.status.completed', lang) + badge = (templates.runtime_message('channels.email.severity.critical', lang) + if event.event_type == 'backup_fail' else status) + self.assertIn('>' + badge.upper() + '', markup) + + +if __name__ == '__main__': unittest.main() diff --git a/AppImage/scripts/notification_events.py b/AppImage/scripts/notification_events.py index 9e949f60..6d9ac520 100644 --- a/AppImage/scripts/notification_events.py +++ b/AppImage/scripts/notification_events.py @@ -4298,41 +4298,20 @@ class ProxmoxHookWatcher: 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) - lines = text.splitlines() - table_outcome = None - for index, header in enumerate(lines): - if not re.match(r'\s*VMID\s+Name\s+Status\b', header, re.IGNORECASE): - continue - status_start = header.find('Status') - status_end = header.find('Time', status_start) - if status_start < 0 or status_end < 0: - break - rows = [] - for line in lines[index + 1:]: - if re.match(r'\s*Total\b', line, re.IGNORECASE): - table_outcome = ('confirmed' if rows and all(status == 'OK' for status in rows) - else 'unconfirmed') - break - if not line.strip(): - break - if not re.match(r'\s*\d+\s+', line): - break - status = line[status_start:status_end].strip().upper() - if status in ('ERROR', 'ERR'): - return 'failed' - rows.append(status) - break + 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' if severity not in ('info', 'ok', 'success') or re.search( r'(?im)(?:^\s*WARNING:|\bWARNINGS\s*:\s*\d+)', text): return 'unconfirmed' # 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_outcome is not None: - return table_outcome - if any(re.match(r'\s*VMID\s+Name\s+Status\b', line, re.IGNORECASE) - for line in lines): - return 'unconfirmed' + if table is not None: + return ('confirmed' if table['complete'] and + all(guest['status'].lower() == 'ok' for guest in table['vms']) + else 'unconfirmed') if starts: return 'confirmed' if sorted(starts) == sorted(finished) else 'unconfirmed' if re.search( @@ -4498,10 +4477,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_templates.py b/AppImage/scripts/notification_templates.py index eba823a9..d0a20dd5 100644 --- a/AppImage/scripts/notification_templates.py +++ b/AppImage/scripts/notification_templates.py @@ -206,6 +206,51 @@ 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+[dhms]\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 '') + 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,55 +270,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() - if status.lower() in ('err', 'error'): - status = 'error' - 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. @@ -1856,19 +1856,6 @@ def render_template(event_type: str, data: Dict[str, Any], _catalog_value(requested_catalog, key) or _catalog_value(english_catalog, key) ) - # Keep the Slovak catalog with its maintainer. Suppress only the - # exact stale report leaves, not future translations or safe titles. - # An unrelated outcome key cannot version recovery/restore wording. - stale_slovak_reports = { - 'templates.backup_complete.title': '{hostname} → {storage}: Záloha dokončená — {vmname} ({vmid})', - 'templates.backup_complete.body': 'Záloha {vmname} (ID: {vmid}) na úložisku {storage} bola úspešne dokončená.\nVeľkosť: {size}', - 'templates.error_resolved.title': '{hostname}: Vyriešené - {category}{entity_suffix}', - 'templates.error_resolved.body': 'Problém v kategórii {category} bol vyriešený.\n{reason}\n🚦 Predchádzajúca závažnosť: {original_severity}\n⏱️ Trvanie: {duration}', - 'templates.system_restore_completed.body': 'Úlohy po obnove boli dokončené na pozadí.\n\nPoužité VM a LXC: {guests}\nZástupné priečinky bind mountov: {stubs}\nOdstránené zastarané priečinky uzlov: {stale_nodes}\nPreinštalované súčasti: {components}\nTrvanie: {duration}\n{warnings_block}\nUzol je teraz úplne pripravený na použitie.', - } - if (requested_language == 'sk' and key in stale_slovak_reports - and localized == stale_slovak_reports[key]): - localized = _catalog_value(english_catalog, key) if localized: template[field] = localized backup_title_target = '' @@ -1877,25 +1864,28 @@ def render_template(event_type: str, data: Dict[str, Any], if outcome == 'confirmed': template['title'] = runtime_message('backup.confirmedTitle', language, hostname=data.get('hostname') or _get_hostname()) - 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 [] - 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) template['body'] = runtime_message('backup.confirmedBody', 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_complete' and data.get('backup_outcome') in ('confirmed', '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 [] + if 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 = {