mirror of
https://github.com/MacRimi/ProxMenux.git
synced 2026-10-09 15:06:39 +00:00
fix(notifications): retain diagnostic context across final consumers
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user