fix(notifications): retain diagnostic context across final consumers

This commit is contained in:
martino
2026-10-01 08:38:32 +02:00
parent e24308c017
commit 147c401864
6 changed files with 287 additions and 16 deletions
+15 -6
View File
@@ -1053,11 +1053,13 @@ class EmailChannel(NotificationChannel):
status = 'unconfirmed'
sev['label'] = _runtime_text(f'email.status.{status}', data)
group = data.get('_group', 'other')
# Scoped inline mail-compatible wrapping: temperature measurements
# and backup identities/raw diagnostics. Other events retain layout.
# Scoped inline mail-compatible wrapping for authoritative raw-context
# bodies, including restore bodies released from quiet hours.
backup_email = event_type in {'backup_complete', 'backup_fail'}
temp_cell_wrap = 'word-wrap:break-word;overflow-wrap:break-word;word-break:break-word;' if event_type == 'temp_high' or backup_email else ''
temp_table_layout = 'table-layout:fixed;' if event_type == 'temp_high' or backup_email else ''
wrap_body = (event_type in {'temp_high', 'system_restore_completed', 'error_resolved'}
or backup_email or data.get('_restore_summary'))
temp_cell_wrap = 'word-wrap:break-word;overflow-wrap:break-word;word-break:break-word;' if wrap_body else ''
temp_table_layout = 'table-layout:fixed;' if wrap_body else ''
backup_title_wrap = temp_cell_wrap if backup_email else ''
backup_metadata_layout = 'table-layout:fixed;' if backup_email else ''
section_label = _runtime_text(f'email.groups.{group}', data)
@@ -1090,8 +1092,14 @@ class EmailChannel(NotificationChannel):
('', html_mod.escape(line.strip()))
for line in body.split('\n') if line.strip()
)
# A metadata-only/manual body may be generic. Keep actionable raw
# context once, without restoring duplicated inventory metadata.
reason = data.get('reason', '')
if reason and len(reason) <= 80 and reason not in body:
detail_rows.append((html_mod.escape(_runtime_text('email.fields.reason', data)),
html_mod.escape(reason)))
if event_type in {'system_restore_completed', 'error_resolved'}:
if event_type in {'system_restore_completed', 'error_resolved'} or data.get('_restore_summary'):
# Observation age/disappearance must not become a green OK row.
# The endpoint's warnings_block and task counts live in the
# localized body, not the generic services Event row.
@@ -1129,7 +1137,8 @@ class EmailChannel(NotificationChannel):
# ── Reason / details block (long text, displayed separately) ──
reason = data.get('reason', '')
reason_html = ''
if reason and len(reason) > 80 and not (event_type == 'temp_high' and reason in body):
if reason and len(reason) > 80 and not (
(event_type in {'temp_high', 'error_resolved'} or backup_email) 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>
+22 -8
View File
@@ -1821,7 +1821,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,16 +1831,23 @@ 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')
short_title = title.split(': ', 1)[-1] if ': ' in title else title
event_icon = (
@@ -1847,10 +1855,15 @@ class NotificationManager:
) 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 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 +1998,14 @@ 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)
summary_body = self._compose_digest_body(rows, use_icons=use_icons, quiet_release=True)
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),
'_notification_language': language},
) or result
except Exception as e:
+12 -1
View File
@@ -2090,8 +2090,19 @@ def render_template(event_type: str, data: Dict[str, Any],
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
if diagnostic_lines:
body_text += '\n' + '\n'.join(dict.fromkeys(diagnostic_lines))
body_text += '\n' + '\n'.join(
line for line in dict.fromkeys(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()
@@ -341,7 +341,10 @@ class RuntimeCatalogTests(unittest.TestCase):
},
language="sk",
)
self.assertIn("Backup complete", 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"])