From ccf9af43d5bf6db6c04c829a306dd42b4e3ed4bf Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Mon, 12 Jan 2026 11:13:43 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-01-12 11:13:43) --- .last-branch | 2 +- .../backend/app/main/routes_reporting_api.py | 7 +- .../src/backend/app/main/routes_shared.py | 2 + .../src/backend/app/parsers/__init__.py | 3 + .../src/backend/app/parsers/qnap.py | 100 ++++++++++++++++++ .../src/backend/app/parsers/registry.py | 26 +++++ docs/changelog.md | 18 ++++ 7 files changed, 156 insertions(+), 2 deletions(-) create mode 100644 containers/backupchecks/src/backend/app/parsers/qnap.py diff --git a/.last-branch b/.last-branch index e053942..220c644 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260112-04-remove-runchecks-success-override-button +v20260112-05-qnap-firmware-update-info-parser diff --git a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py index 722cd7b..767b74b 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -360,6 +360,10 @@ def build_report_job_filters_meta(): if (bs_val or "").strip().lower() == "synology" and bt_val.lower() == "updates": continue + # QNAP firmware update notifications are informational and should never appear in reports. + if (bs_val or "").strip().lower() == "qnap" and bt_val.lower() == "firmware update": + continue + if bs_val: backup_softwares_set.add(bs_val) if bt_val: @@ -381,7 +385,7 @@ def build_report_job_filters_meta(): "backup_softwares": backup_softwares, "backup_types": backup_types, "by_backup_software": by_backup_software_out, - "excluded_backup_types": ["License Key", "Updates (Synology)"], + "excluded_backup_types": ["License Key", "Updates (Synology)", "Firmware Update (QNAP)"], } @@ -507,6 +511,7 @@ def api_reports_generate(report_id: int): where_filters = ( " AND COALESCE(j.backup_type,'') NOT ILIKE 'license key' " " AND NOT (LOWER(COALESCE(j.backup_software,'')) = 'synology' AND LOWER(COALESCE(j.backup_type,'')) = 'updates') " + " AND NOT (LOWER(COALESCE(j.backup_software,'')) = 'qnap' AND LOWER(COALESCE(j.backup_type,'')) = 'firmware update') " ) rc = _safe_json_dict(getattr(report, "report_config", None)) filters = rc.get("filters") if isinstance(rc, dict) else None diff --git a/containers/backupchecks/src/backend/app/main/routes_shared.py b/containers/backupchecks/src/backend/app/main/routes_shared.py index a0979e3..5789981 100644 --- a/containers/backupchecks/src/backend/app/main/routes_shared.py +++ b/containers/backupchecks/src/backend/app/main/routes_shared.py @@ -637,6 +637,8 @@ def _infer_schedule_map_from_runs(job_id: int): return schedule if bs == 'synology' and bt == 'updates': return schedule + if bs == 'qnap' and bt == 'firmware update': + return schedule if bs == 'syncovery' and bt == 'syncovery': return schedule except Exception: diff --git a/containers/backupchecks/src/backend/app/parsers/__init__.py b/containers/backupchecks/src/backend/app/parsers/__init__.py index 3e44494..a33be6b 100644 --- a/containers/backupchecks/src/backend/app/parsers/__init__.py +++ b/containers/backupchecks/src/backend/app/parsers/__init__.py @@ -14,6 +14,7 @@ from .veeam import try_parse_veeam from .rdrive import try_parse_rdrive from .syncovery import try_parse_syncovery from .ntfs_auditing import try_parse_ntfs_auditing +from .qnap import try_parse_qnap def _sanitize_text(value: object) -> object: @@ -106,6 +107,8 @@ def parse_mail_message(msg: MailMessage) -> None: try: handled, result, objects = try_parse_3cx(msg) + if not handled: + handled, result, objects = try_parse_qnap(msg) if not handled: handled, result, objects = try_parse_synology(msg) if not handled: diff --git a/containers/backupchecks/src/backend/app/parsers/qnap.py b/containers/backupchecks/src/backend/app/parsers/qnap.py new file mode 100644 index 0000000..3898ef0 --- /dev/null +++ b/containers/backupchecks/src/backend/app/parsers/qnap.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +import html +import re +from typing import Dict, Tuple, List + +from ..models import MailMessage + + +_SUBJECT_RE = re.compile( + r"^\[(?Pinfo|warning|error)\]\s*\[\s*firmware\s+update\s*\]\s*notification\s+from\s+your\s+device\s*:\s*(?P.+)$", + re.I, +) + +_NAS_NAME_RE = re.compile(r"\bNAS\s*Name\s*:\s*(?P[^\n<]+)", re.I) +_APP_NAME_RE = re.compile(r"\bApp\s*Name\s*:\s*(?P[^\n<]+)", re.I) +_CATEGORY_RE = re.compile(r"\bCategory\s*:\s*(?P[^\n<]+)", re.I) +_MESSAGE_RE = re.compile(r"\bMessage\s*:\s*(?P.+)$", re.I | re.M) + +_BR_RE = re.compile(r"<\s*br\s*/?\s*>", re.I) +_TAG_RE = re.compile(r"<[^>]+>") +_WS_RE = re.compile(r"[\t\r\f\v ]+") + + +def _html_to_text(value: str) -> str: + if not value: + return "" + s = value + s = _BR_RE.sub("\n", s) + s = _TAG_RE.sub("", s) + s = html.unescape(s) + s = s.replace("\u00a0", " ") + # keep newlines, but normalize whitespace on each line + lines = [(_WS_RE.sub(" ", ln)).strip() for ln in s.split("\n")] + return "\n".join([ln for ln in lines if ln]).strip() + + +def try_parse_qnap(msg: MailMessage) -> Tuple[bool, Dict, List[Dict]]: + """Parse QNAP Notification Center e-mails. + + Supported (informational): + - Firmware Update notifications + Subject: [Info][Firmware Update] Notification from your device: + + These notifications are informational: they should be visible in Run Checks, + but they must not participate in schedule inference, missed/expected logic, + or reporting. + """ + + subject = (getattr(msg, "subject", None) or "").strip() + if not subject: + return False, {}, [] + + m = _SUBJECT_RE.match(subject) + if not m: + return False, {}, [] + + host = (m.group("host") or "").strip() + + html_body = getattr(msg, "html_body", None) or "" + text_body = getattr(msg, "text_body", None) or getattr(msg, "body", None) or "" + text = _html_to_text(html_body) if html_body else (text_body or "") + + if text: + m_host = _NAS_NAME_RE.search(text) + if m_host: + host = (m_host.group("host") or "").strip() or host + + # Prefer the detailed 'Message:' line from the body. + overall_message = None + if text: + m_msg = _MESSAGE_RE.search(text) + if m_msg: + overall_message = (m_msg.group("msg") or "").strip() or None + + # If the body doesn't contain a dedicated message line, derive one. + if not overall_message and text: + parts: List[str] = [] + m_app = _APP_NAME_RE.search(text) + if m_app: + parts.append((m_app.group("app") or "").strip()) + m_cat = _CATEGORY_RE.search(text) + if m_cat: + parts.append((m_cat.group("cat") or "").strip()) + if parts: + overall_message = " / ".join([p for p in parts if p]) or None + + result: Dict = { + "backup_software": "QNAP", + "backup_type": "Firmware Update", + "job_name": "Firmware Update", + "overall_status": "Warning", + "overall_message": overall_message, + } + + objects: List[Dict] = [] + if host: + objects.append({"name": host, "status": "Warning"}) + + return True, result, objects diff --git a/containers/backupchecks/src/backend/app/parsers/registry.py b/containers/backupchecks/src/backend/app/parsers/registry.py index aa76392..b2fc100 100644 --- a/containers/backupchecks/src/backend/app/parsers/registry.py +++ b/containers/backupchecks/src/backend/app/parsers/registry.py @@ -61,6 +61,32 @@ PARSER_DEFINITIONS = [ }, }, }, + { + "name": "qnap_firmware_update", + "backup_software": "QNAP", + "backup_types": ["Firmware Update"], + "order": 235, + "enabled": True, + "match": { + "from_contains": "notifications@", + "subject_contains": "Firmware Update", + }, + "description": "Parses QNAP Notification Center firmware update notifications (informational; excluded from reporting and missing logic).", + "example": { + "subject": "[Info][Firmware Update] Notification from your device: BETSIES-NAS01", + "from_address": "notifications@customer.tld", + "body_snippet": "NAS Name: BETSIES-NAS01\n...\nMessage: ...", + "parsed_result": { + "backup_software": "QNAP", + "backup_type": "Firmware Update", + "job_name": "Firmware Update", + "overall_status": "Warning", + "objects": [ + {"name": "BETSIES-NAS01", "status": "Warning", "error_message": None} + ], + }, + }, + }, { "name": "veeam_replication_job", "backup_software": "Veeam", diff --git a/docs/changelog.md b/docs/changelog.md index 941af4a..b44b9b0 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -133,6 +133,24 @@ - Set overall status to Warning when the subject indicates any detected changes (↑/↓ counts > 0); otherwise Success. - Ensured job name is consistently generated as " file audits" for all supported senders. +--- + +## v20260112-04-remove-runchecks-success-override-button + +- Removed the “Mark success (override)” button from the Run Checks popup. +- Prevented creation of actual overrides when marking individual runs as success. +- Simplified override overview by ensuring Run Checks actions no longer affect override administration. + +--- + +## v20260112-05-qnap-firmware-update-info-parser + +- Added parser support for QNAP Firmware Update notifications. +- Classified firmware update messages as informational warnings. +- Excluded QNAP firmware update messages from missing-run detection logic. +- Excluded QNAP firmware update messages from reports and scheduling. +- Ensured affected NAS devices are only shown in Run Checks when the message occurs. + ================================================================================================================================================ ## 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.