From 3cb608cb6b6752b152c1b1591c3f2e3bcaca6d55 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Mon, 13 Apr 2026 15:43:47 +0200 Subject: [PATCH] Sync remaining local tracked changes --- .last-branch | 2 +- .../src/backend/app/main/routes_run_checks.py | 132 +++++++++++++++++- docs/changelog-claude.md | 11 ++ docs/technical-notes-codex.md | 15 +- 4 files changed, 157 insertions(+), 3 deletions(-) diff --git a/.last-branch b/.last-branch index 7273c0f..26e0b09 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -25 +v20260402-01 diff --git a/containers/backupchecks/src/backend/app/main/routes_run_checks.py b/containers/backupchecks/src/backend/app/main/routes_run_checks.py index 7238a95..df90961 100644 --- a/containers/backupchecks/src/backend/app/main/routes_run_checks.py +++ b/containers/backupchecks/src/backend/app/main/routes_run_checks.py @@ -9,7 +9,7 @@ from datetime import date, datetime, time, timedelta, timezone from flask import flash, jsonify, redirect, render_template, request, url_for from urllib.parse import urlencode, urljoin from flask_login import current_user, login_required -from sqlalchemy import and_, or_, func, text +from sqlalchemy import and_, bindparam, or_, func, text from .routes_shared import ( _apply_overrides_to_run, @@ -167,6 +167,118 @@ def _is_hidden_3cx_non_backup(backup_software: str | None, backup_type: str | No return bs == "3cx" and bt in {"update", "ssl certificate"} +def _chunked(values: list[int], size: int = 500) -> list[list[int]]: + if not values: + return [] + return [values[i:i + size] for i in range(0, len(values), size)] + + +def _get_cove_complete_success_run_ids(run_ids: list[int]) -> set[int]: + """Return Cove run ids that have at least one object and all object statuses are Success.""" + if not run_ids: + return set() + + complete_success_ids: set[int] = set() + for chunk in _chunked(run_ids, size=500): + rows = db.session.execute( + text( + """ + SELECT + rol.run_id AS run_id, + COUNT(*) AS obj_count, + SUM(CASE WHEN LOWER(COALESCE(rol.status, '')) = 'success' THEN 1 ELSE 0 END) AS success_count + FROM run_object_links rol + WHERE rol.run_id IN :run_ids + GROUP BY rol.run_id + """ + ).bindparams(bindparam("run_ids", expanding=True)), + {"run_ids": chunk}, + ).mappings().all() + + for rr in rows: + run_id = int(rr.get("run_id") or 0) + obj_count = int(rr.get("obj_count") or 0) + success_count = int(rr.get("success_count") or 0) + if run_id > 0 and obj_count > 0 and success_count == obj_count: + complete_success_ids.add(run_id) + + return complete_success_ids + + +def _collect_suppressed_cove_run_ids( + *, + job_ids: list[int] | None = None, + include_reviewed: bool = False, +) -> set[int]: + """Suppress Cove runs that occur later on a day after the first complete success run.""" + q = ( + db.session.query( + JobRun.id.label("run_id"), + JobRun.job_id.label("job_id"), + func.coalesce(JobRun.run_at, JobRun.created_at).label("run_ts"), + JobRun.status.label("status"), + ) + .filter(JobRun.source_type == "cove_api") + ) + + if job_ids: + q = q.filter(JobRun.job_id.in_(job_ids)) + + if not include_reviewed: + q = q.filter(JobRun.reviewed_at.is_(None)) + + run_rows = q.order_by( + JobRun.job_id.asc(), + func.coalesce(JobRun.run_at, JobRun.created_at).asc(), + JobRun.id.asc(), + ).all() + if not run_rows: + return set() + + success_candidate_ids = [ + int(r.run_id) + for r in run_rows + if ((r.status or "").strip().lower() == "success") + ] + complete_success_ids = _get_cove_complete_success_run_ids(success_candidate_ids) + if not complete_success_ids: + return set() + + cutoff_by_job_day: dict[tuple[int, date], datetime] = {} + for r in run_rows: + run_id = int(r.run_id or 0) + run_ts = getattr(r, "run_ts", None) + if run_id <= 0 or run_ts is None or run_id not in complete_success_ids: + continue + local_day = _to_amsterdam_date(run_ts) + if local_day is None: + continue + key = (int(r.job_id), local_day) + prev_cutoff = cutoff_by_job_day.get(key) + if prev_cutoff is None or run_ts < prev_cutoff: + cutoff_by_job_day[key] = run_ts + + if not cutoff_by_job_day: + return set() + + suppressed: set[int] = set() + for r in run_rows: + run_id = int(r.run_id or 0) + run_ts = getattr(r, "run_ts", None) + if run_id <= 0 or run_ts is None: + continue + local_day = _to_amsterdam_date(run_ts) + if local_day is None: + continue + cutoff = cutoff_by_job_day.get((int(r.job_id), local_day)) + if cutoff is None: + continue + if run_ts > cutoff: + suppressed.add(run_id) + + return suppressed + + def _ensure_internal_ticket_for_autotask( *, ticket_number: str, @@ -1337,6 +1449,13 @@ def run_checks_page(): | (func.coalesce(Job.job_name, "").ilike(pat, escape="\\")) ) + # Restrict Cove suppression calculation to currently relevant jobs. + candidate_job_ids = [int(x) for (x,) in base.with_entities(Job.id).limit(4000).all()] + suppressed_cove_run_ids = _collect_suppressed_cove_run_ids( + job_ids=candidate_job_ids, + include_reviewed=include_reviewed, + ) + # Runs to show in the overview: unreviewed (or all if admin toggle enabled) run_filter = [] if not include_reviewed: @@ -1388,6 +1507,8 @@ def run_checks_page(): ) if run_filter: agg = agg.filter(*run_filter) + if suppressed_cove_run_ids: + agg = agg.filter(~JobRun.id.in_(list(suppressed_cove_run_ids))) agg = agg.subquery() @@ -1439,6 +1560,8 @@ def run_checks_page(): ) if run_filter: s_q = s_q.filter(*run_filter) + if suppressed_cove_run_ids: + s_q = s_q.filter(~JobRun.id.in_(list(suppressed_cove_run_ids))) s_q = s_q.group_by(JobRun.job_id, JobRun.status, JobRun.missed, JobRun.override_applied) for jid, status, missed, override_applied, cnt in s_q.all(): @@ -1698,6 +1821,13 @@ def run_checks_details(): if not include_reviewed: q = q.filter(JobRun.reviewed_at.is_(None)) + suppressed_cove_run_ids = _collect_suppressed_cove_run_ids( + job_ids=[int(job.id)], + include_reviewed=include_reviewed, + ) + if suppressed_cove_run_ids: + q = q.filter(~JobRun.id.in_(list(suppressed_cove_run_ids))) + runs = q.order_by(func.coalesce(JobRun.run_at, JobRun.created_at).desc(), JobRun.id.desc()).limit(400).all() # Prefetch internal ticket resolution info for Autotask-linked runs (Phase 2 UI). diff --git a/docs/changelog-claude.md b/docs/changelog-claude.md index fdfeaf7..ed41e22 100644 --- a/docs/changelog-claude.md +++ b/docs/changelog-claude.md @@ -2,6 +2,17 @@ This file documents all changes made to this project via Claude Code. +## [2026-04-13] + +### Fixed +- Run Checks now suppresses repeated Cove runs within the same local day once a **complete success run** has occurred: + - A complete success run is defined as `JobRun.status = Success` with at least one persisted run object and all object statuses equal to `Success`. + - For each Cove job/day, the first complete success run is treated as the cutoff; newer runs on that same day are hidden from Run Checks (both overview aggregation and modal details), regardless of whether they are `Success`, `Warning`, or `Failed/Error`. + - Sorting remains unchanged (`newest -> oldest`). + +### Validation +- Test build executed with `./build-and-push.sh t` on 2026-04-13 and pushed `gitea.oskamp.info/ivooskamp/backupchecks:dev` (digest `sha256:520778f4b72643c1cd1815fa424317ee2dce182ccfcbea687f4ac711b3d00fb0`). + ## [2026-04-02] ### Added diff --git a/docs/technical-notes-codex.md b/docs/technical-notes-codex.md index 46887a2..d639d1d 100644 --- a/docs/technical-notes-codex.md +++ b/docs/technical-notes-codex.md @@ -331,6 +331,11 @@ Cove run rows in the job detail history table are clickable even without a mail - `routes_run_checks.py` returns `cove_summary` in the run payload for `source_type="cove_api"` runs - Includes: account_name, computer_name, customer_name, readable datasource labels, last_run_at, status - `run_checks.html` shows the Cove summary panel and hides the mail section +- Duplicate-day suppression for Cove runs: + - Runs are grouped per job per local day (Europe/Amsterdam date derived from run timestamp). + - A run is considered a "complete success" when `JobRun.status == Success` and persisted run objects exist with all object statuses equal to `Success`. + - Once the first complete success exists on that day, all newer Cove runs for the same day are hidden in Run Checks (overview aggregation + details modal), regardless of status (`Success`, `Warning`, `Failed/Error`). + - Sort order in the modal remains unchanged (`newest -> oldest`). ### Migrations - `migrate_cove_integration()` — adds 8 columns to `system_settings`, `cove_account_id` to `jobs`, `source_type` + `external_id` to `job_runs`, dedup index on `job_runs.external_id` @@ -730,6 +735,15 @@ File: `build-and-push.sh` ## Recent Changes +### 2026-04-13 +- **Run Checks Cove daily suppression** (`main/routes_run_checks.py`): + - Added Cove-specific filtering to suppress repeated same-day runs after the first complete success run. + - Complete success criteria: run status `Success`, object set present, all object statuses `Success`. + - Applied consistently to both Run Checks overview aggregation and details modal query. + - Local-day grouping uses the existing Amsterdam date helper for run timestamps. +- **Validation**: + - Test build executed with `./build-and-push.sh t`; pushed `gitea.oskamp.info/ivooskamp/backupchecks:dev` with digest `sha256:520778f4b72643c1cd1815fa424317ee2dce182ccfcbea687f4ac711b3d00fb0`. + ### 2026-03-23 - **Synology ABB parser fix** (`parsers/synology.py`): ABB completion regex now also matches `has been completed` phrasing. - **Job name parsing corrected for ABB mails**: messages like `backup task dc001 on DS220p has been completed` no longer fall back to generic Synology Active Backup parsing; `job_name` stays `dc001` instead of bracketed subject prefix values. @@ -819,4 +833,3 @@ File: `build-and-push.sh` ### 2026-02-10 - **Added screenshot support to Feedback system**: Multiple file upload, inline display, two-stage delete (soft delete for audit trail, permanent delete for cleanup). - **Completed transition to link-based ticket system**: All pages now use JOIN queries, no date-based logic. Added cross-browser copy ticket functionality with three-tier fallback mechanism to both Run Checks and Job Details pages. -