Compare commits
2 Commits
14d2422a1f
...
a00155c1f3
| Author | SHA1 | Date | |
|---|---|---|---|
| a00155c1f3 | |||
| b1522cef2f |
@ -1 +1 @@
|
|||||||
v20260112-05-qnap-firmware-update-info-parser
|
v20260112-06-veeam-spc-alarm-summary-parser
|
||||||
|
|||||||
@ -18,9 +18,162 @@ VEEAM_BACKUP_TYPES = [
|
|||||||
"Scale-out Backup Repository",
|
"Scale-out Backup Repository",
|
||||||
"Health Check",
|
"Health Check",
|
||||||
"Cloud Connect Report",
|
"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]:
|
def _parse_cloud_connect_report_from_html(html: str) -> Tuple[List[Dict], str]:
|
||||||
"""Parse Veeam Cloud Connect daily report (provider) HTML.
|
"""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
|
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
|
# Special-case: Veeam Backup for Microsoft 365 mails can come without a
|
||||||
# subject marker. Detect via HTML and extract status from the banner.
|
# subject marker. Detect via HTML and extract status from the banner.
|
||||||
is_m365 = "veeam backup for microsoft 365" in html_lower
|
is_m365 = "veeam backup for microsoft 365" in html_lower
|
||||||
|
|
||||||
# If we cannot detect a status marker and this is not an M365 report,
|
# 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.
|
# 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()
|
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"]):
|
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, {}, []
|
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.
|
# Handle Cloud Connect daily report early: overall status is derived from row colours.
|
||||||
if is_cloud_connect_report:
|
if is_cloud_connect_report:
|
||||||
objects, overall_status = _parse_cloud_connect_report_from_html(html_body)
|
objects, overall_status = _parse_cloud_connect_report_from_html(html_body)
|
||||||
|
|||||||
@ -151,6 +151,14 @@
|
|||||||
- Excluded QNAP firmware update messages from reports and scheduling.
|
- Excluded QNAP firmware update messages from reports and scheduling.
|
||||||
- Ensured affected NAS devices are only shown in Run Checks when the message occurs.
|
- 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
|
## 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.
|
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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user