Merge pull request 'Auto-commit local changes before build (2026-01-09 15:01:37)' (#84) from v20260109-10-veeam-cloud-connect-report-parser into main

Reviewed-on: #84
This commit is contained in:
Ivo Oskamp 2026-01-13 11:36:12 +01:00
commit ffb81e8e3d
3 changed files with 137 additions and 3 deletions

View File

@ -1 +1 @@
v20260109-09-ellipsis-reset-on-popup-close v20260109-10-veeam-cloud-connect-report-parser

View File

@ -17,9 +17,105 @@ VEEAM_BACKUP_TYPES = [
"Veeam Backup for Microsoft 365", "Veeam Backup for Microsoft 365",
"Scale-out Backup Repository", "Scale-out Backup Repository",
"Health Check", "Health Check",
"Cloud Connect Report",
] ]
def _parse_cloud_connect_report_from_html(html: str) -> Tuple[List[Dict], str]:
"""Parse Veeam Cloud Connect daily report (provider) HTML.
The report contains a "Backup" table with columns including:
User | Repository Name | ...
Objects in our system are a combination of the "User" and "Repository Name"
columns, separated by " | ".
Row background colour indicates status:
- Red/pink rows: Failed/Error
- Yellow/orange rows: Warning
- White rows: Success
The row where the first cell is "TOTAL" is a summary row and is not an object.
Returns: (objects, overall_status)
"""
html = _normalize_html(html)
if not html:
return [], "Success"
# Find the Backup table block.
m_table = re.search(r"(?is)<p[^>]*>\s*Backup\s*</p>\s*<table.*?</table>", html)
if not m_table:
return [], "Success"
table_html = m_table.group(0)
# Extract rows.
row_pattern = re.compile(r"(?is)<tr([^>]*)>(.*?)</tr>")
cell_pattern = re.compile(r"(?is)<t[dh][^>]*>(.*?)</t[dh]>")
objects: List[Dict] = []
saw_failed = False
saw_warning = False
for row_attr, row_inner in row_pattern.findall(table_html):
cells = cell_pattern.findall(row_inner)
if len(cells) < 3:
continue
# Convert cells to plain text.
plain = [_strip_html_tags(c).strip() for c in cells]
if not plain:
continue
# Skip header row.
if plain[0].strip().lower() == "user":
continue
user = (plain[0] or "").strip()
repo_name = (plain[2] or "").strip()
# Skip summary row.
if user.upper() == "TOTAL":
continue
if not user and not repo_name:
continue
# Determine status based on background colour.
# Veeam uses inline styles like: background-color: #fb9895 (error)
# and background-color: #ffd96c (warning).
row_style = (row_attr or "")
m_bg = re.search(r"(?i)background-color\s*:\s*([^;\"\s]+)", row_style)
bg = (m_bg.group(1).strip().lower() if m_bg else "")
status = "Success"
if bg in {"#fb9895", "#ff9999", "#f4cccc", "#ffb3b3"}:
status = "Failed"
saw_failed = True
elif bg in {"#ffd96c", "#fff2cc", "#ffe599", "#f9cb9c"}:
status = "Warning"
saw_warning = True
name = f"{user} | {repo_name}".strip(" |")
objects.append(
{
"name": name,
"type": "Repository",
"status": status,
"error_message": None,
}
)
overall_status = "Success"
if saw_failed:
overall_status = "Failed"
elif saw_warning:
overall_status = "Warning"
return objects, overall_status
def _strip_html_tags(value: str) -> str: def _strip_html_tags(value: str) -> str:
"""Very small helper to strip HTML tags from a string.""" """Very small helper to strip HTML tags from a string."""
if not value: if not value:
@ -769,17 +865,46 @@ def try_parse_veeam(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]:
html_body = _normalize_html(getattr(msg, "html_body", None) or "") html_body = _normalize_html(getattr(msg, "html_body", None) or "")
html_lower = html_body.lower() html_lower = html_body.lower()
# Veeam Cloud Connect provider daily report (no [Success]/[Warning] marker).
is_cloud_connect_report = (
"veeam cloud connect" in subject.lower()
and "daily report" in subject.lower()
and "repository name" in html_lower
and "infrastructure status" in html_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: if not m_status and not m_finished and not is_m365 and not is_cloud_connect_report:
lowered = subject.lower() lowered = subject.lower()
if not any(k in lowered for k in ["veeam", "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 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)
overall_message = None
# Use the short subject summary when present, e.g. ": 2 Errors, 1 Warnings, 49 Successes".
m_sum = re.search(r"(?i)daily\s+report\s*:\s*(.+)$", subject)
if m_sum:
overall_message = (m_sum.group(1) or "").strip() or None
result = {
"backup_software": "Veeam",
"backup_type": "Cloud Connect Report",
"job_name": "Daily report",
"overall_status": overall_status,
}
if overall_message:
result["overall_message"] = overall_message
return True, result, objects
if m_status: if m_status:
status_word = m_status.group(1) status_word = m_status.group(1)
rest = m_status.group(2) rest = m_status.group(2)

View File

@ -71,6 +71,15 @@
- Reset expanded ellipsis fields when a Bootstrap modal is shown or hidden, so expanded state does not persist between openings. - Reset expanded ellipsis fields when a Bootstrap modal is shown or hidden, so expanded state does not persist between openings.
- Added the same reset behavior for Bootstrap offcanvas components to keep behavior consistent across popups. - Added the same reset behavior for Bootstrap offcanvas components to keep behavior consistent across popups.
---
## v20260109-10-veeam-cloud-connect-report-parser
- Updated the Veeam Cloud Connect Report parser to correctly handle object naming by combining User and Repository Name with a "|" separator.
- Excluded the row containing "TOTAL" from being processed as an object.
- Adjusted status detection so red rows are interpreted as Errors and yellow/orange rows as Warnings.
- Ensured the overall status is set to Error when one or more objects are detected as errors.
- Improved parsing logic to correctly classify mixed-status reports within a single mail.
================================================================================================================================================ ================================================================================================================================================
## 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.