Auto-commit local changes before build (2026-01-12 12:32:03) #93

Merged
ivooskamp merged 1 commits from v20260112-06-veeam-spc-alarm-summary-parser into main 2026-01-13 11:42:00 +01:00
3 changed files with 194 additions and 2 deletions

View File

@ -1 +1 @@
v20260112-05-qnap-firmware-update-info-parser
v20260112-06-veeam-spc-alarm-summary-parser

View File

@ -18,9 +18,162 @@ VEEAM_BACKUP_TYPES = [
"Scale-out Backup Repository",
"Health Check",
"Cloud Connect Report",
"Service Provider Console",
]
def _parse_vspc_summary_daily_alarm_report_from_html(html: str) -> Tuple[List[Dict], str, Optional[str]]:
"""Parse Veeam Service Provider Console (VSPC) *summary daily* alarm notifications.
The VSPC daily summary mail can contain alarms for multiple client companies.
To make this data actionable per customer in Backupchecks, we aggregate alarms
per company and emit one object per company.
Expected content typically includes blocks like:
Company: <Name> (alarms: <N>)
<Alarm details...>
Since templates vary by VSPC version and localization, the parser uses a
tolerant text-first strategy:
1) Convert HTML -> text while preserving meaningful line breaks.
2) Split into company blocks.
3) For each company, extract alarm lines and derive a worst-case status.
Returns: (objects, overall_status, overall_message)
"""
text = _html_to_text_preserve_lines(html)
if not text:
return [], "Success", None
lines = [ln.strip() for ln in text.split("\n") if (ln or "").strip()]
if not lines:
return [], "Success", None
# Identify company block headers.
company_re = re.compile(
r"(?i)^company\s*:\s*(?P<name>.+?)(?:\s*\(\s*alarms?\s*:\s*(?P<count>\d+)\s*\)\s*)?$"
)
blocks: List[Tuple[str, Optional[int], List[str]]] = []
cur_name: Optional[str] = None
cur_count: Optional[int] = None
cur_lines: List[str] = []
def _flush():
nonlocal cur_name, cur_count, cur_lines
if cur_name:
blocks.append((cur_name, cur_count, cur_lines))
cur_name, cur_count, cur_lines = None, None, []
for ln in lines:
m = company_re.match(ln)
if m:
_flush()
cur_name = (m.group("name") or "").strip()
try:
cur_count = int(m.group("count")) if m.group("count") else None
except Exception:
cur_count = None
continue
if cur_name:
cur_lines.append(ln)
_flush()
if not blocks:
return [], "Success", None
objects: List[Dict] = []
saw_failed = False
saw_warning = False
total_alarms = 0
total_companies = 0
# Heuristics for severity classification.
failed_kw = re.compile(r"(?i)\b(critical|error|failed|failure)\b")
warning_kw = re.compile(r"(?i)\b(warn|warning)\b")
for company_name, alarms_count, company_lines in blocks:
total_companies += 1
if alarms_count is not None:
total_alarms += alarms_count
company_failed = False
company_warning = False
# Attempt to detect per-alarm object/message pairs.
current_object: Optional[str] = None
alarm_entries: List[str] = []
for ln in company_lines:
# Skip boilerplate separators.
if ln.strip("-–— ") == "":
continue
m_obj = re.match(r"(?i)^(?:object|host|repository|vm)\s*[:\-]\s*(.+)$", ln)
if m_obj:
current_object = (m_obj.group(1) or "").strip() or None
continue
# Some templates format as "<Object> - <Message>".
if " - " in ln and not ln.lower().startswith("http"):
left, right = ln.split(" - ", 1)
left = left.strip()
right = right.strip()
if left and right and (current_object is None):
current_object = left
alarm_entries.append(f"{left}: {right}")
current_object = None
continue
if current_object:
alarm_entries.append(f"{current_object}: {ln}")
current_object = None
else:
alarm_entries.append(ln)
# Severity inference.
if failed_kw.search(ln):
company_failed = True
elif warning_kw.search(ln):
company_warning = True
# If we did not find explicit severity, fall back to alarm count > 0.
if not company_failed and not company_warning:
if (alarms_count or 0) > 0:
company_warning = True
status = "Success"
if company_failed:
status = "Failed"
saw_failed = True
elif company_warning:
status = "Warning"
saw_warning = True
objects.append(
{
"name": company_name,
"type": "Company",
"status": status,
"error_message": "\n".join([e for e in alarm_entries if e]).strip() or None,
}
)
overall_status = "Success"
if saw_failed:
overall_status = "Failed"
elif saw_warning:
overall_status = "Warning"
overall_message = None
if total_companies and total_alarms:
overall_message = f"Companies with alarms: {total_companies}, Total alarms: {total_alarms}".strip()
return objects, overall_status, overall_message
def _parse_cloud_connect_report_from_html(html: str) -> Tuple[List[Dict], str]:
"""Parse Veeam Cloud Connect daily report (provider) HTML.
@ -873,17 +1026,48 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]:
and "infrastructure status" in html_lower
)
# Veeam Service Provider Console (VSPC) summary daily alarm notification.
# These mails can contain multiple client companies.
# Detection is intentionally tolerant: company blocks are typically formatted
# as "Company: <name> (alarms: N)".
text_body = _html_to_text_preserve_lines(html_body)
text_lower = (text_body or "").lower()
is_vspc_daily_alarm_summary = (
("company:" in text_lower and "alarms" in text_lower)
and (
"service provider" in text_lower
or "availability console" in text_lower
or "vac" in text_lower
or "veeam" in subject.lower()
)
)
# Special-case: Veeam Backup for Microsoft 365 mails can come without a
# subject marker. Detect via HTML and extract status from the banner.
is_m365 = "veeam backup for microsoft 365" in html_lower
# If we cannot detect a status marker and this is not an M365 report,
# we still try to parse when the subject strongly indicates a Veeam report.
if not m_status and not m_finished and not is_m365 and not is_cloud_connect_report:
if not m_status and not m_finished and not is_m365 and not is_cloud_connect_report and not is_vspc_daily_alarm_summary:
lowered = subject.lower()
if not any(k in lowered for k in ["veeam", "cloud connect", "backup job", "backup copy job", "replica job", "configuration backup", "health check"]):
return False, {}, []
# Handle VSPC daily alarm summary early.
if is_vspc_daily_alarm_summary:
objects, overall_status, overall_message = _parse_vspc_summary_daily_alarm_report_from_html(html_body)
result = {
"backup_software": "Veeam",
"backup_type": "Service Provider Console",
"job_name": "Daily alarms",
"overall_status": overall_status,
}
if overall_message:
result["overall_message"] = overall_message
return True, result, objects
# Handle Cloud Connect daily report early: overall status is derived from row colours.
if is_cloud_connect_report:
objects, overall_status = _parse_cloud_connect_report_from_html(html_body)

View File

@ -151,6 +151,14 @@
- Excluded QNAP firmware update messages from reports and scheduling.
- Ensured affected NAS devices are only shown in Run Checks when the message occurs.
---
## v20260112-06-veeam-spc-alarm-summary-parser
- Added support for Veeam Service Provider Console (VSPC) summary daily alarm notification emails.
- Implemented parsing that aggregates alarms per Company and stores one object per company, with the alarm details in the object error message.
- Derived overall status from the worst status found across all companies (Failed > Warning > Success).
- Registered "Service Provider Console" as a supported Veeam backup type for consistent reporting.
================================================================================================================================================
## v0.1.19
This release delivers a broad set of improvements focused on reliability, transparency, and operational control across mail processing, administrative auditing, and Run Checks workflows. The changes aim to make message handling more robust, provide better insight for administrators, and give operators clearer and more flexible control when reviewing backup runs.