From efe7bd184e849cb9af7fc7b50e8e06b96cb8dde3 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Mon, 12 Jan 2026 15:04:09 +0100 Subject: [PATCH] Auto-commit local changes before build (2026-01-12 15:04:09) --- .last-branch | 2 +- .../src/backend/app/main/routes_inbox.py | 97 ++++++++++++++----- .../src/backend/app/parsers/veeam.py | 22 +++-- .../src/templates/main/inbox.html | 2 +- docs/changelog.md | 9 ++ 5 files changed, 96 insertions(+), 36 deletions(-) diff --git a/.last-branch b/.last-branch index abb84b7..5c0f3e7 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260112-14-vspc-company-mapping-require-all +v20260112-15-vspc-scroll-partial-approve-objects diff --git a/containers/backupchecks/src/backend/app/main/routes_inbox.py b/containers/backupchecks/src/backend/app/main/routes_inbox.py index e64fa16..7ec40b5 100644 --- a/containers/backupchecks/src/backend/app/main/routes_inbox.py +++ b/containers/backupchecks/src/backend/app/main/routes_inbox.py @@ -488,19 +488,33 @@ def inbox_message_approve_vspc_companies(message_id: int): final_map.update(provided_map) missing_companies = [c for c in companies_present if c not in final_map] - if missing_companies: - # Keep message in Inbox until all companies are mapped. + mapped_companies = [c for c in companies_present if c in final_map] + + if not mapped_companies: + # Nothing to approve yet; user must map at least one company. missing_str = ", ".join(missing_companies[:10]) if len(missing_companies) > 10: missing_str += f" (+{len(missing_companies) - 10} more)" - flash(f"Please map all companies before approving. Missing: {missing_str}", "danger") + flash( + ( + "Please map at least one company before approving." + + (f" Missing: {missing_str}" if missing_str else "") + ), + "danger", + ) return redirect(url_for("main.inbox")) + def _is_error_status(value: str | None) -> bool: + v = (value or "").strip().lower() + return v in {"error", "failed", "critical"} or v.startswith("fail") + created_runs: list[JobRun] = [] + skipped_existing = 0 first_job: Job | None = None - # Create runs for all companies in the message using the resolved mapping. - for company in companies_present: + # Create runs for mapped companies only. If some companies remain unmapped, + # the message stays in the Inbox so the user can map the remainder later. + for company in mapped_companies: customer_id = int(final_map[company]) customer = Customer.query.get(customer_id) if not customer: @@ -539,23 +553,30 @@ def inbox_message_approve_vspc_companies(message_id: int): .filter(MailObject.object_name.like(f"{company} | %")) .all() ) - saw_error = any((o.status or "").lower() == "error" for o in objs) - saw_warning = any((o.status or "").lower() == "warning" for o in objs) + saw_error = any(_is_error_status(o.status) for o in objs) + saw_warning = any((o.status or "").strip().lower() == "warning" for o in objs) status = "Error" if saw_error else ("Warning" if saw_warning else (msg.overall_status or "Success")) - run = JobRun( - job_id=job.id, - mail_message_id=msg.id, - run_at=(msg.received_at or getattr(msg, "parsed_at", None) or datetime.utcnow()), - status=status or None, - missed=False, - ) - if hasattr(run, "remark"): - run.remark = getattr(msg, "overall_message", None) + # De-duplicate: do not create multiple runs for the same (mail_message_id, job_id). + run = JobRun.query.filter(JobRun.job_id == job.id, JobRun.mail_message_id == msg.id).first() + if run: + skipped_existing += 1 + else: + run = JobRun( + job_id=job.id, + mail_message_id=msg.id, + run_at=(msg.received_at or getattr(msg, "parsed_at", None) or datetime.utcnow()), + status=status or None, + missed=False, + ) + if hasattr(run, "remark"): + run.remark = getattr(msg, "overall_message", None) - db.session.add(run) - db.session.flush() + db.session.add(run) + db.session.flush() + created_runs.append(run) + # Persist objects for reporting (idempotent upsert; safe to repeat). try: persist_objects_for_approved_run_filtered( customer.id, @@ -571,13 +592,37 @@ def inbox_message_approve_vspc_companies(message_id: int): f"Filtered object persistence failed for message {msg.id} (company '{company}', job {job.id}, run {run.id}): {exc}", ) - created_runs.append(run) - - if not created_runs: + processed_total = len(created_runs) + skipped_existing + if processed_total <= 0: flash("No runs could be created for this VSPC summary.", "danger") return redirect(url_for("main.inbox")) - # Update mail message to reflect approval + # Commit created runs and any job mapping updates first. + try: + db.session.commit() + except Exception as exc: + db.session.rollback() + flash("Could not approve this job due to a database error.", "danger") + _log_admin_event("inbox_approve_error", f"Failed to approve VSPC message {msg.id}: {exc}") + return redirect(url_for("main.inbox")) + + if missing_companies: + # Keep message in Inbox until all companies are mapped, but keep the already + # created runs for mapped companies. + missing_str = ", ".join(missing_companies[:10]) + if len(missing_companies) > 10: + missing_str += f" (+{len(missing_companies) - 10} more)" + _log_admin_event( + "inbox_approve_vspc_partial", + f"Partially approved VSPC message {msg.id}: {processed_total} run(s) processed, missing={missing_str}", + ) + flash( + f"Approved {processed_total} mapped compan{'y' if processed_total == 1 else 'ies'}. Message stays in the Inbox until all companies are mapped. Missing: {missing_str}", + "warning", + ) + return redirect(url_for("main.inbox")) + + # All companies mapped: mark the message as approved and move it to History. msg.job_id = first_job.id if first_job else None if hasattr(msg, "approved"): msg.approved = True @@ -592,15 +637,15 @@ def inbox_message_approve_vspc_companies(message_id: int): db.session.commit() except Exception as exc: db.session.rollback() - flash("Could not approve this job due to a database error.", "danger") - _log_admin_event("inbox_approve_error", f"Failed to approve VSPC message {msg.id}: {exc}") + flash("Could not finalize approval due to a database error.", "danger") + _log_admin_event("inbox_approve_error", f"Failed to finalize VSPC approval for message {msg.id}: {exc}") return redirect(url_for("main.inbox")) _log_admin_event( "inbox_approve_vspc", - f"Approved VSPC message {msg.id} into {len(created_runs)} runs (job_id={msg.job_id})", + f"Approved VSPC message {msg.id} into {processed_total} run(s) (job_id={msg.job_id})", ) - flash(f"Approved VSPC summary into {len(created_runs)} run(s).", "success") + flash(f"Approved VSPC summary into {processed_total} run(s).", "success") return redirect(url_for("main.inbox")) diff --git a/containers/backupchecks/src/backend/app/parsers/veeam.py b/containers/backupchecks/src/backend/app/parsers/veeam.py index 81df839..92ee4a9 100644 --- a/containers/backupchecks/src/backend/app/parsers/veeam.py +++ b/containers/backupchecks/src/backend/app/parsers/veeam.py @@ -93,6 +93,8 @@ def _parse_vspc_active_alarms_from_html(html: str) -> Tuple[List[Dict], str, Opt colmap["alarm_name"] = i elif c in {"n. of repeats", "n.of repeats", "repeats"}: colmap["repeats"] = i + elif c in {"alarm details", "alarmdetails", "details"}: + colmap["alarm_details"] = i # Basic validation: needs at least object + current state if "object" not in colmap or "current_state" not in colmap: @@ -118,6 +120,7 @@ def _parse_vspc_active_alarms_from_html(html: str) -> Tuple[List[Dict], str, Opt at_time = plain[colmap.get("time", -1)].strip() if colmap.get("time", -1) >= 0 and colmap.get("time", -1) < len(plain) else "" alarm_name = plain[colmap.get("alarm_name", -1)].strip() if colmap.get("alarm_name", -1) >= 0 and colmap.get("alarm_name", -1) < len(plain) else "" repeats = plain[colmap.get("repeats", -1)].strip() if colmap.get("repeats", -1) >= 0 and colmap.get("repeats", -1) < len(plain) else "" + alarm_details = plain[colmap.get("alarm_details", -1)].strip() if colmap.get("alarm_details", -1) >= 0 and colmap.get("alarm_details", -1) < len(plain) else "" state_lower = (current_state or "").lower() status = "Success" @@ -128,16 +131,19 @@ def _parse_vspc_active_alarms_from_html(html: str) -> Tuple[List[Dict], str, Opt status = "Warning" saw_warning = True - # Try to find a more descriptive detail line in the company text. - detail_line = None + # Prefer the explicit "Alarm Details" column if present. + detail_line = alarm_details or None + + # Otherwise try to find a more descriptive detail line in the company text. # Prefer lines that mention the object or alarm name and are long enough to be a real description. needles = [n for n in [obj_name, alarm_name] if n] - for ln in seg_lines: - if len(ln) < 25: - continue - if any(n.lower() in ln.lower() for n in needles): - detail_line = ln - break + if not detail_line: + for ln in seg_lines: + if len(ln) < 25: + continue + if any(n.lower() in ln.lower() for n in needles): + detail_line = ln + break if not detail_line and alarm_name: # fallback: use alarm name with context diff --git a/containers/backupchecks/src/templates/main/inbox.html b/containers/backupchecks/src/templates/main/inbox.html index a0d69a8..578fe20 100644 --- a/containers/backupchecks/src/templates/main/inbox.html +++ b/containers/backupchecks/src/templates/main/inbox.html @@ -227,7 +227,7 @@ {% endfor %} -
+
diff --git a/docs/changelog.md b/docs/changelog.md index ac6dc5a..cc03693 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -220,6 +220,15 @@ - Added default customer prefill for each VSPC company based on existing per-company jobs (previous mappings). - Changed VSPC approval flow to require all companies in the email to be mapped before approval; when mappings are incomplete the message stays in the Inbox while newly provided mappings are still saved. +--- + +## v20260112-15-vspc-scroll-partial-approve-objects +- Added a vertical scrollbar to the VSPC company mapping popup so “Approve mapped companies” stays reachable without browser zoom. +- Changed VSPC approval to approve only mapped companies; if some companies are still unmapped, the email stays in the Inbox while runs for mapped companies are created. +- Added de-duplication for VSPC approvals to prevent creating duplicate runs on repeated approvals. +- Improved VSPC error detection by treating both “Error” and “Failed” states as errors for the run status. +- Enhanced VSPC parsing to use the “Alarm Details” column so object rows (e.g., HV01, USB Disk) persist with their full alarm messages and become visible in the customer job view. + ================================================================================================================================================ ## 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.