From 2710a140adea5bc0ec22a0ed06fa0ac59c6a4cbe Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sat, 3 Jan 2026 18:22:48 +0100 Subject: [PATCH 01/22] Auto-commit local changes before build (2026-01-03 18:22:48) --- .last-branch | 2 +- .../src/backend/app/main/routes_reports.py | 46 +++++++- .../src/templates/main/reports.html | 28 ++++- .../src/templates/main/reports_new.html | 106 +++++++++++++++--- docs/changelog.md | 9 ++ 5 files changed, 173 insertions(+), 18 deletions(-) diff --git a/.last-branch b/.last-branch index 92ab6e6..7f55fc8 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260103-12-reports-columns-selector-init-fix +v20260103-13-reports-edit-and-view-raw-fix diff --git a/containers/backupchecks/src/backend/app/main/routes_reports.py b/containers/backupchecks/src/backend/app/main/routes_reports.py index 5b29dc6..f05d0ca 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reports.py +++ b/containers/backupchecks/src/backend/app/main/routes_reports.py @@ -21,6 +21,17 @@ def _safe_json_list(value): return [] +def _safe_json_dict(value): + if not value: + return {} + if isinstance(value, dict): + return value + try: + return json.loads(value) + except Exception: + return {} + + def _build_report_item(r): return { "id": int(r.id), @@ -33,6 +44,7 @@ def _build_report_item(r): "period_start": r.period_start.isoformat() if getattr(r, "period_start", None) else "", "period_end": r.period_end.isoformat() if getattr(r, "period_end", None) else "", "schedule": r.schedule or "", + "report_config": _safe_json_dict(getattr(r, "report_config", None)), "created_at": r.created_at.isoformat() if getattr(r, "created_at", None) else "", } @@ -72,4 +84,36 @@ def reports_new(): ) customer_items = [{"id": int(c.id), "name": c.name or ""} for c in customers] - return render_template("main/reports_new.html", initial_customers=customer_items, columns_meta=build_report_columns_meta()) + return render_template( + "main/reports_new.html", + initial_customers=customer_items, + columns_meta=build_report_columns_meta(), + is_edit=False, + initial_report=None, + ) + + +@main_bp.route("/reports//edit") +@login_required +def reports_edit(report_id: int): + # Editing reports is limited to the same roles that can create them. + if get_active_role() not in ("admin", "operator", "reporter"): + return abort(403) + + r = ReportDefinition.query.get_or_404(report_id) + + customers = ( + db.session.query(Customer) + .filter(Customer.active.is_(True)) + .order_by(Customer.name.asc()) + .all() + ) + customer_items = [{"id": int(c.id), "name": c.name or ""} for c in customers] + + return render_template( + "main/reports_new.html", + initial_customers=customer_items, + columns_meta=build_report_columns_meta(), + is_edit=True, + initial_report=_build_report_item(r), + ) diff --git a/containers/backupchecks/src/templates/main/reports.html b/containers/backupchecks/src/templates/main/reports.html index ddc0fc0..95f42fe 100644 --- a/containers/backupchecks/src/templates/main/reports.html +++ b/containers/backupchecks/src/templates/main/reports.html @@ -40,7 +40,7 @@ {% for item in initial_reports %} {{ item.name }}
{{ item.description }}
- {{ item.created_at.replace('T',' ') if item.created_at else '' }} + {{ item.report_type }} {% if item.period_start or item.period_end %} {{ item.period_start.replace('T',' ') if item.period_start else '' }} → {{ item.period_end.replace('T',' ') if item.period_end else '' }} @@ -50,6 +50,7 @@ {{ item.output_format }} + Edit Download @@ -64,7 +65,7 @@ No reports found. {% endif %} - > + @@ -271,6 +272,28 @@ } } + function setRawDownloadLink() { + var btn = qs('rep_raw_download_btn'); + if (!btn) return; + if (!rawReportId) { + btn.setAttribute('href', '#'); + btn.classList.add('disabled'); + return; + } + btn.classList.remove('disabled'); + btn.setAttribute('href', '/api/reports/' + rawReportId + '/export.csv?view=' + rawView); + } + + function updateRawMeta(total) { + var t = parseInt(total || 0, 10) || 0; + var start = t ? (rawOffset + 1) : 0; + var end = t ? Math.min(rawOffset + rawLimit, t) : 0; + var label = (rawView === 'snapshot') ? 'Snapshot' : 'Summary'; + qs('rep_raw_meta').textContent = label + ' · Rows ' + start + '-' + end + ' of ' + t; + qs('rep_raw_prev_btn').disabled = (rawOffset <= 0); + qs('rep_raw_next_btn').disabled = ((rawOffset + rawLimit) >= t); + } + function renderRawTable(view, items) { var thead = qs('rep_raw_thead'); var tbody = qs('rep_raw_tbody'); @@ -368,6 +391,7 @@ function loadRawData() { '' + period + '' + '' + fmt + '' + '' + + 'Edit' + '' + '' + 'Download' + diff --git a/containers/backupchecks/src/templates/main/reports_new.html b/containers/backupchecks/src/templates/main/reports_new.html index 66486db..c622191 100644 --- a/containers/backupchecks/src/templates/main/reports_new.html +++ b/containers/backupchecks/src/templates/main/reports_new.html @@ -3,8 +3,8 @@
-

New report

-
Create a one-time report definition. Generate output from the Reports overview.
+

{{ 'Edit report' if is_edit else 'New report' }}

+
{{ 'Update this report definition. Generate output from the Reports overview.' if is_edit else 'Create a one-time report definition. Generate output from the Reports overview.' }}
Back @@ -157,7 +157,7 @@
- + Cancel
@@ -199,6 +199,8 @@ + + + +
+
+
+

{title}

+
Report ID {report_id} · Period {period_s} → {period_e}
+
+
+
Generated HTML preview
+
+
+ +
+
+
+
Total runs
+
{summary.get('total_runs', 0)}
+
+
+
+
+
Success
+
{summary.get('success_count', 0)}
+
+
+
+
+
Failed
+
{summary.get('failed_count', 0)}
+
+
+
+
+
Success rate
+
{summary.get('success_rate', 0)}%
+
+
+
+ +
+
+
+
+
Success rate trend
+
Daily success rate based on generated snapshots
+
+
+ +
+
+
+
+
+
+
Status distribution
+
All snapshots grouped by status
+
+
+ +
+
+
+
+ +
+
+
+
+
Performance by customer
+
Aggregated from generated snapshot data
+
+
+
+ + + + + + + + + + + + + + {''.join(perf_rows)} + +
CustomerTotalSuccessWarningFailedMissedSuccess %
+
+
+
+
+
+ + {snapshot_table_html} + +
+ + + + +""" + + resp = Response(html, mimetype="text/html") + resp.headers["Content-Disposition"] = f'inline; filename="report-{report_id}.html"' + return resp + + +def _export_pdf_response(report: ReportDefinition, report_id: int, view: str): + stats = _build_report_stats_payload(report_id) + summary = stats.get("summary") or {} + trends = (stats.get("charts") or {}).get("trends") or [] + + from reportlab.pdfgen import canvas + from reportlab.lib.pagesizes import A4 + from reportlab.lib.units import mm + + buf = io.BytesIO() + c = canvas.Canvas(buf, pagesize=A4) + w, h = A4 + + margin = 18 * mm + y = h - margin + + c.setTitle(report.name or f"Report {report_id}") + + c.setFont("Helvetica-Bold", 16) + c.drawString(margin, y, report.name or "Report") + y -= 8 * mm + + c.setFont("Helvetica", 9) + period_s = report.period_start.isoformat() if report.period_start else "—" + period_e = report.period_end.isoformat() if report.period_end else "—" + c.drawString(margin, y, f"Report ID {report_id} Period {period_s} → {period_e}") + y -= 10 * mm + + c.setFont("Helvetica-Bold", 11) + c.drawString(margin, y, "Summary") + y -= 6 * mm + + c.setFont("Helvetica", 10) + summary_lines = [ + f"Total runs: {summary.get('total_runs', 0)}", + f"Success: {summary.get('success_count', 0)}", + f"Warning: {summary.get('warning_count', 0)}", + f"Failed: {summary.get('failed_count', 0)}", + f"Missed: {summary.get('missed_count', 0)}", + f"Success rate: {summary.get('success_rate', 0)}%", + ] + for ln in summary_lines: + c.drawString(margin, y, ln) + y -= 5 * mm + + y -= 4 * mm + + c.setFont("Helvetica-Bold", 11) + c.drawString(margin, y, "Success rate trend") + y -= 6 * mm + + chart_w = w - (2 * margin) + chart_h = 55 * mm + chart_x = margin + chart_y = y - chart_h + + c.setLineWidth(1) + c.rect(chart_x, chart_y, chart_w, chart_h) + + if trends: + points = [] + n = len(trends) + for i, t in enumerate(trends): + try: + rate = float(t.get("success_rate") or 0.0) + except Exception: + rate = 0.0 + x = chart_x + (i * (chart_w / max(1, n - 1))) + yv = chart_y + (rate / 100.0) * chart_h + points.append((x, yv)) + + c.setLineWidth(1.5) + for i in range(1, len(points)): + c.line(points[i - 1][0], points[i - 1][1], points[i][0], points[i][1]) + + c.setFont("Helvetica", 8) + first_day = (trends[0].get("day") or "")[:10] + last_day = (trends[-1].get("day") or "")[:10] + c.drawString(chart_x, chart_y - 4 * mm, first_day) + c.drawRightString(chart_x + chart_w, chart_y - 4 * mm, last_day) + c.drawRightString(chart_x - 2, chart_y, "0%") + c.drawRightString(chart_x - 2, chart_y + chart_h, "100%") + else: + c.setFont("Helvetica", 9) + c.drawString(chart_x + 4 * mm, chart_y + chart_h / 2, "No trend data available (generate the report first).") + + c.showPage() + c.save() + + buf.seek(0) + return send_file(buf, mimetype="application/pdf", as_attachment=True, download_name=f"report-{report_id}.pdf") + + +@main_bp.route("/api/reports//export", methods=["GET"]) +@login_required +def api_reports_export(report_id: int): err = _require_reporting_role() if err is not None: return err report = ReportDefinition.query.get_or_404(report_id) view = (request.args.get("view") or "summary").strip().lower() + fmt = (request.args.get("format") or report.output_format or "csv").strip().lower() - if view not in ("summary", "snapshot"): - view = "summary" + if fmt not in ("csv", "html", "pdf"): + fmt = "csv" - output = io.StringIO() - writer = csv.writer(output) + if fmt == "csv": + return _export_csv_response(report, report_id, view) - if view == "summary": - writer.writerow([ - "object_name", - "customer_id", - "customer_name", - "total_runs", - "success_count", - "success_override_count", - "warning_count", - "failed_count", - "missed_count", - "success_rate", - ]) - rows = ( - db.session.query(ReportObjectSummary) - .filter(ReportObjectSummary.report_id == report_id) - .order_by(db.func.coalesce(ReportObjectSummary.customer_name, '').asc(), ReportObjectSummary.object_name.asc()) - .all() - ) - for r in rows: - writer.writerow([ - r.object_name or "", - r.customer_id or "", - r.customer_name or "", - int(r.total_runs or 0), - int(r.success_count or 0), - int(r.success_override_count or 0), - int(r.warning_count or 0), - int(r.failed_count or 0), - int(r.missed_count or 0), - round(float(r.success_rate or 0.0), 2), - ]) - filename = f"report-{report_id}-summary.csv" - else: - writer.writerow([ - "object_name", - "customer_id", - "customer_name", - "job_id", - "job_name", - "backup_software", - "backup_type", - "run_id", - "run_at", - "status", - "missed", - "override_applied", - "reviewed_at", - "ticket_number", - "remark", - ]) - rows = ( - db.session.query(ReportObjectSnapshot) - .filter(ReportObjectSnapshot.report_id == report_id) - .order_by(ReportObjectSnapshot.object_name.asc(), ReportObjectSnapshot.run_at.asc()) - .all() - ) - for r in rows: - writer.writerow([ - r.object_name or "", - r.customer_id or "", - r.customer_name or "", - r.job_id or "", - r.job_name or "", - r.backup_software or "", - r.backup_type or "", - r.run_id or "", - r.run_at.isoformat() if r.run_at else "", - r.status or "", - "1" if r.missed else "0", - "1" if r.override_applied else "0", - r.reviewed_at.isoformat() if r.reviewed_at else "", - r.ticket_number or "", - (r.remark or "").replace("\r", " ").replace("\n", " ").strip(), - ]) - filename = f"report-{report_id}-snapshot.csv" + if fmt == "html": + return _export_html_response(report, report_id, view) - csv_bytes = output.getvalue().encode("utf-8") - mem = io.BytesIO(csv_bytes) - mem.seek(0) - return send_file(mem, mimetype="text/csv", as_attachment=True, download_name=filename) + return _export_pdf_response(report, report_id, view) + + +@main_bp.route("/api/reports//export.csv", methods=["GET"]) +@login_required +def api_reports_export_csv(report_id: int): + # Backward compatible route: always returns CSV. + err = _require_reporting_role() + if err is not None: + return err + + report = ReportDefinition.query.get_or_404(report_id) + view = (request.args.get("view") or "summary").strip().lower() + return _export_csv_response(report, report_id, view) diff --git a/containers/backupchecks/src/templates/main/reports.html b/containers/backupchecks/src/templates/main/reports.html index 95f42fe..5c23126 100644 --- a/containers/backupchecks/src/templates/main/reports.html +++ b/containers/backupchecks/src/templates/main/reports.html @@ -53,7 +53,7 @@ Edit - Download + Download {% if active_role in ('admin','operator','reporter') %} {% endif %} @@ -281,7 +281,7 @@ return; } btn.classList.remove('disabled'); - btn.setAttribute('href', '/api/reports/' + rawReportId + '/export.csv?view=' + rawView); + btn.setAttribute('href', '/api/reports/' + rawReportId + '/export?view=' + rawView); } function updateRawMeta(total) { @@ -394,7 +394,7 @@ function loadRawData() { 'Edit' + '' + '' + - 'Download' + + 'Download' + (canDeleteReports ? '' : '') + '';; diff --git a/containers/backupchecks/src/templates/main/reports_new.html b/containers/backupchecks/src/templates/main/reports_new.html index 624d953..b29388d 100644 --- a/containers/backupchecks/src/templates/main/reports_new.html +++ b/containers/backupchecks/src/templates/main/reports_new.html @@ -30,8 +30,9 @@
diff --git a/docs/changelog.md b/docs/changelog.md index 3dd3919..9a9b23a 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -161,6 +161,17 @@ - Updated the report selection UI to make the “Backup software” and “Backup type” select boxes equal in size. - Both select boxes are now displayed with a fixed height of 6 visible rows for consistent layout and improved usability. +--- + +## v20260103-19-reports-output-format-html + +- Added "HTML" as an output format option next to CSV and PDF in the report definition form. +- Enabled generic report export to support CSV, HTML and PDF outputs. +- Implemented an HTML export preview with a graphical layout (summary cards + charts) to validate report styling. +- Implemented a basic PDF export with summary and a simple success-rate trend chart. +- Updated the Reports overview to use the new generic export endpoint (no longer hardcoded to export.csv). +- Added ReportLab dependency for PDF generation. + ================================================================================================================================================ From 8fbf45201836e48c63f2b7a953185e22a211af76 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sun, 4 Jan 2026 01:33:56 +0100 Subject: [PATCH 08/22] Auto-commit local changes before build (2026-01-04 01:33:56) --- .last-branch | 2 +- containers/backupchecks/src/templates/main/reports.html | 4 ++-- docs/changelog.md | 8 ++++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/.last-branch b/.last-branch index 13abcc4..f412f8f 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260103-19-reports-output-format-html +v20260103-20-reports-export-html-open-new-tab diff --git a/containers/backupchecks/src/templates/main/reports.html b/containers/backupchecks/src/templates/main/reports.html index 5c23126..3e0de3a 100644 --- a/containers/backupchecks/src/templates/main/reports.html +++ b/containers/backupchecks/src/templates/main/reports.html @@ -53,7 +53,7 @@ Edit - Download + Download {% if active_role in ('admin','operator','reporter') %} {% endif %} @@ -394,7 +394,7 @@ function loadRawData() { 'Edit' + '' + '' + - 'Download' + + 'Download' + (canDeleteReports ? '' : '') + '';; diff --git a/docs/changelog.md b/docs/changelog.md index 9a9b23a..f98f049 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -172,6 +172,14 @@ - Updated the Reports overview to use the new generic export endpoint (no longer hardcoded to export.csv). - Added ReportLab dependency for PDF generation. +--- + +## v20260103-20-reports-export-html-open-new-tab + +- Fixed report export logic so the selected output format is always respected. +- Ensured the export URL explicitly includes the chosen format parameter (CSV, PDF, HTML). +- Changed HTML report handling to open in a new browser tab instead of triggering a file download. +- Enabled proper inline rendering of HTML reports to support graphical preview and layout validation. ================================================================================================================================================ From 609364ef2fb95c68f33c75c9d877d8b230d8a6ed Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sun, 4 Jan 2026 11:19:59 +0100 Subject: [PATCH 09/22] Auto-commit local changes before build (2026-01-04 11:19:59) --- .last-branch | 2 +- .../src/backend/app/main/routes_reporting_api.py | 13 ++++++++++++- containers/backupchecks/src/backend/app/models.py | 2 +- docs/changelog.md | 9 +++++++++ 4 files changed, 23 insertions(+), 3 deletions(-) diff --git a/.last-branch b/.last-branch index f412f8f..05058de 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260103-20-reports-export-html-open-new-tab +v20260104-01-reports-output-format-save-fix 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 1711e03..25f2792 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -139,9 +139,12 @@ def api_reports_create(): name = (payload.get("name") or "").strip() or "Report" description = (payload.get("description") or "").strip() or None report_type = (payload.get("report_type") or "one-time").strip() or "one-time" - output_format = (payload.get("output_format") or "csv").strip() or "csv" + output_format = (payload.get("output_format") or "csv").strip().lower() or "csv" schedule = (payload.get("schedule") or "").strip() or None + if output_format not in ("csv", "html", "pdf"): + return {"error": "Invalid output_format. Use csv, html, or pdf."}, 400 + report_config = payload.get("report_config") if report_config is not None and not isinstance(report_config, (dict, list, str)): report_config = None @@ -406,6 +409,7 @@ def api_reports_update(report_id: int): - name - description - report_config + - output_format """ err = _require_reporting_role() if err is not None: @@ -420,6 +424,13 @@ def api_reports_update(report_id: int): desc = (payload.get("description") or "").strip() report.description = desc or None + if "output_format" in payload: + fmt = (payload.get("output_format") or "").strip().lower() + if fmt: + if fmt not in ("csv", "html", "pdf"): + return {"error": "Invalid output_format. Use csv, html, or pdf."}, 400 + report.output_format = fmt + if "report_config" in payload: rc = payload.get("report_config") if rc is None: diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index 04a4627..a66644c 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -536,7 +536,7 @@ class ReportDefinition(db.Model): # one-time | scheduled report_type = db.Column(db.String(32), nullable=False, default="one-time") - # csv | pdf (pdf is future) + # csv | html | pdf output_format = db.Column(db.String(16), nullable=False, default="csv") # customer scope for report generation diff --git a/docs/changelog.md b/docs/changelog.md index f98f049..602bda9 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -181,6 +181,15 @@ - Changed HTML report handling to open in a new browser tab instead of triggering a file download. - Enabled proper inline rendering of HTML reports to support graphical preview and layout validation. +--- + +## v20260104-01-reports-output-format-save-fix + +- Fixed an issue where the selected report output format (HTML/PDF) was not persisted when saving or editing a report. +- The report update endpoint now correctly stores the chosen output_format instead of always reverting to CSV. +- Added validation and normalization for output_format values during report creation and update. +- Ensured that selecting HTML as output no longer results in CSV being generated or downloaded. + ================================================================================================================================================ ## v0.1.15 From cea1df3e388f0e5175462186565e7694bd05f23d Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sun, 4 Jan 2026 11:57:39 +0100 Subject: [PATCH 10/22] Auto-commit local changes before build (2026-01-04 11:57:39) --- .last-branch | 2 +- .../backend/app/main/routes_reporting_api.py | 241 +++++++++++++----- .../src/templates/main/reports_new.html | 55 +++- docs/changelog.md | 10 + 4 files changed, 246 insertions(+), 62 deletions(-) diff --git a/.last-branch b/.last-branch index 05058de..225e42e 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260104-01-reports-output-format-save-fix +v20260104-02-reports-html-content-and-success-rate-basis 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 25f2792..bcc7bf9 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -597,7 +597,8 @@ def api_reports_data(report_id: int): if err is not None: return err - ReportDefinition.query.get_or_404(report_id) + report = ReportDefinition.query.get_or_404(report_id) + include_keys = _get_success_rate_keys_from_report(report) view = (request.args.get("view") or "summary").strip().lower() if view not in ("summary", "snapshot"): @@ -631,7 +632,16 @@ def api_reports_data(report_id: int): "warning_count": int(r.warning_count or 0), "failed_count": int(r.failed_count or 0), "missed_count": int(r.missed_count or 0), - "success_rate": round(round(float(r.success_rate or 0.0), 2), 2), + "success_rate": round( + _compute_success_rate( + success=int(r.success_count or 0), + warning=int(r.warning_count or 0), + failed=int(r.failed_count or 0), + missed=int(r.missed_count or 0), + include_keys=include_keys, + ), + 2, + ), } for r in rows ], @@ -686,12 +696,65 @@ def _normalize_status_row(status: str, missed: bool) -> str: return "unknown" +def _get_success_rate_keys_from_report(report: ReportDefinition) -> set[str]: + """Return which status count keys should contribute to success rate. + + The report UI allows choosing summary columns. Success rate should be + calculated based on the selected status columns to let users exclude + noisy counts (e.g. missed runs). + """ + + rc = _safe_json_dict(getattr(report, "report_config", None)) + cols = (rc.get("columns") or {}) if isinstance(rc, dict) else {} + summary_cols = cols.get("summary") if isinstance(cols, dict) else None + + selected = set() + if isinstance(summary_cols, list): + for k in summary_cols: + if not isinstance(k, str): + continue + kk = k.strip() + if kk: + selected.add(kk) + + # Map selected columns to status keys. + status_keys = {"success_count", "warning_count", "failed_count", "missed_count"} + out = set(k for k in selected if k in status_keys) + + # Always include success_count in the denominator to prevent odd rates. + out.add("success_count") + + # If nothing was selected (or config missing), fall back to all status keys. + if len(out) == 1 and "success_count" in out and not selected: + out = set(status_keys) + out.add("success_count") + + return out + + +def _compute_success_rate(success: int, warning: int, failed: int, missed: int, include_keys: set[str]) -> float: + denom = 0 + if "success_count" in include_keys: + denom += int(success or 0) + if "warning_count" in include_keys: + denom += int(warning or 0) + if "failed_count" in include_keys: + denom += int(failed or 0) + if "missed_count" in include_keys: + denom += int(missed or 0) + if denom <= 0: + return 0.0 + return (int(success or 0) / float(denom)) * 100.0 + + def _build_report_stats_payload(report_id: int) -> dict: """Compute summary and chart datasets for a generated report.""" report = ReportDefinition.query.get_or_404(report_id) + include_keys = _get_success_rate_keys_from_report(report) + with db.engine.connect() as conn: status_rows = conn.execute( text( @@ -744,16 +807,28 @@ def _build_report_stats_payload(report_id: int) -> dict: for tr in trend_rows or []: day_total = int(tr.total_runs or 0) day_success = int(tr.success_runs or 0) + day_warning = int(tr.warning_runs or 0) + day_failed = int(tr.failed_runs or 0) + day_missed = int(tr.missed_runs or 0) day_rate = 0.0 - if day_total > 0: - day_rate = (day_success / float(day_total)) * 100.0 + denom = 0 + if "success_count" in include_keys: + denom += day_success + if "warning_count" in include_keys: + denom += day_warning + if "failed_count" in include_keys: + denom += day_failed + if "missed_count" in include_keys: + denom += day_missed + if denom > 0: + day_rate = (day_success / float(denom)) * 100.0 trends.append( { "day": tr.day.date().isoformat() if tr.day else "", "success_rate": round(day_rate, 2), - "failed_runs": int(tr.failed_runs or 0), - "warning_runs": int(tr.warning_runs or 0), - "missed_runs": int(tr.missed_runs or 0), + "failed_runs": day_failed, + "warning_runs": day_warning, + "missed_runs": day_missed, "total_runs": day_total, } ) @@ -800,8 +875,13 @@ def _build_report_stats_payload(report_id: int) -> dict: totals["missed_count"] += missed_count rate = 0.0 - if total_runs > 0: - rate = (success_count / float(total_runs)) * 100.0 + rate = _compute_success_rate( + success=success_count, + warning=warning_count, + failed=failed_count, + missed=missed_count, + include_keys=include_keys, + ) performance.append( { @@ -816,8 +896,13 @@ def _build_report_stats_payload(report_id: int) -> dict: ) overall_rate = 0.0 - if totals["total_runs"] > 0: - overall_rate = (totals["success_count"] / float(totals["total_runs"])) * 100.0 + overall_rate = _compute_success_rate( + success=totals["success_count"], + warning=totals["warning_count"], + failed=totals["failed_count"], + missed=totals["missed_count"], + include_keys=include_keys, + ) return { "report": { @@ -862,6 +947,8 @@ def _export_csv_response(report: ReportDefinition, report_id: int, view: str): if view not in ("summary", "snapshot"): view = "summary" + include_keys = _get_success_rate_keys_from_report(report) + with db.engine.connect() as conn: if view == "summary": rows = conn.execute( @@ -897,16 +984,23 @@ def _export_csv_response(report: ReportDefinition, report_id: int, view: str): for r in rows or []: total_runs = int(r.total_runs or 0) success_count = int(r.success_count or 0) - rate = 0.0 - if total_runs > 0: - rate = (success_count / float(total_runs)) * 100.0 + warning_count = int(r.warning_count or 0) + failed_count = int(r.failed_count or 0) + missed_count = int(r.missed_count or 0) + rate = _compute_success_rate( + success=success_count, + warning=warning_count, + failed=failed_count, + missed=missed_count, + include_keys=include_keys, + ) writer.writerow([ r.customer_name or "", total_runs, success_count, - int(r.warning_count or 0), - int(r.failed_count or 0), - int(r.missed_count or 0), + warning_count, + failed_count, + missed_count, round(rate, 2), ]) filename = f"report-{report_id}-summary.csv" @@ -991,8 +1085,28 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): def _esc(s: str) -> str: return (s or "").replace("&", "&").replace("<", "<").replace(">", ">") + rc = _safe_json_dict(getattr(report, "report_config", None)) + pres = rc.get("presentation") if isinstance(rc, dict) else None + html_content = None + if isinstance(pres, dict): + html_content = (pres.get("html_content") or "").strip().lower() or None + + # Default behavior: + # - single customer: jobs (snapshot preview) is more useful + # - multiple/all: customers table is useful + scope = (getattr(report, "customer_scope", None) or "all").strip().lower() + if not html_content: + html_content = "jobs" if scope == "single" else "customers" + + include_customers = html_content in ("customers", "both") + include_jobs = html_content in ("jobs", "both") + + # Snapshot preview table can be requested either explicitly via view=snapshot + # or via the report config (include_jobs). + want_snapshot_table = (view or "summary").strip().lower() == "snapshot" or include_jobs + snapshot_table_html = "" - if (view or "summary").strip().lower() == "snapshot": + if want_snapshot_table: snap_rows = [] with db.engine.connect() as conn: rows = conn.execute( @@ -1095,18 +1209,54 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): period_e = report.period_end.isoformat() if report.period_end else "—" perf_rows = [] - for p in perf: - perf_rows.append( - "" - f"{_esc(p.get('customer_name') or '')}" - f"{int(p.get('total_runs') or 0)}" - f"{int(p.get('success_count') or 0)}" - f"{int(p.get('warning_count') or 0)}" - f"{int(p.get('failed_count') or 0)}" - f"{int(p.get('missed_count') or 0)}" - f"{p.get('success_rate', 0)}%" - "" - ) + if include_customers: + for p in perf: + perf_rows.append( + "" + f"{_esc(p.get('customer_name') or '')}" + f"{int(p.get('total_runs') or 0)}" + f"{int(p.get('success_count') or 0)}" + f"{int(p.get('warning_count') or 0)}" + f"{int(p.get('failed_count') or 0)}" + f"{int(p.get('missed_count') or 0)}" + f"{p.get('success_rate', 0)}%" + "" + ) + + perf_table_html = "" + if include_customers: + perf_table_html = f""" +
+
+
+
+
Performance by customer
+
Aggregated from generated snapshot data
+
+
+
+ + + + + + + + + + + + + + {''.join(perf_rows)} + +
CustomerTotalSuccessWarningFailedMissedSuccess %
+
+
+
+
+
+""" html = f""" @@ -1188,36 +1338,7 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
-
-
-
-
-
Performance by customer
-
Aggregated from generated snapshot data
-
-
-
- - - - - - - - - - - - - - {''.join(perf_rows)} - -
CustomerTotalSuccessWarningFailedMissedSuccess %
-
-
-
-
-
+ {perf_table_html} {snapshot_table_html} diff --git a/containers/backupchecks/src/templates/main/reports_new.html b/containers/backupchecks/src/templates/main/reports_new.html index b29388d..16846f9 100644 --- a/containers/backupchecks/src/templates/main/reports_new.html +++ b/containers/backupchecks/src/templates/main/reports_new.html @@ -36,6 +36,16 @@ +
+ + +
Controls whether the HTML/PDF output shows a customer list, a job list, or both.
+
+
@@ -245,6 +255,21 @@ var initialReport = window.__initialReport || null; var editReportId = (initialReport && initialReport.id) ? initialReport.id : null; + // --- HTML/PDF content selector --- + var repHtmlContent = null; + var repHtmlContentTouched = false; + if (isEdit && initialReport && initialReport.report_config && initialReport.report_config.presentation) { + var p = initialReport.report_config.presentation; + if (p && typeof p === 'object') { + repHtmlContent = (p.html_content || '').trim().toLowerCase() || null; + } + } + + function defaultHtmlContentForScope(scope) { + if ((scope || '').toLowerCase() === 'single') return 'jobs'; + return 'customers'; + } + // --- Report content / column selector --- var repColsView = 'summary'; @@ -681,6 +706,11 @@ var scope = selectedScope(); qs('rep_single_wrap').classList.toggle('d-none', scope !== 'single'); qs('rep_multiple_wrap').classList.toggle('d-none', scope !== 'multiple'); + + // Set a sensible default for HTML content selection when the user hasn't chosen one yet. + if (!isEdit && !repHtmlContentTouched) { + qs('rep_html_content').value = defaultHtmlContentForScope(scope); + } } function applyCustomerSelection() { @@ -750,6 +780,11 @@ qs('rep_description').value = initialReport.description || ''; qs('rep_output_format').value = (initialReport.output_format || 'csv'); + // HTML/PDF content selection (defaults handled below) + if (repHtmlContent) { + qs('rep_html_content').value = repHtmlContent; + } + setDateTimeFromIso('rep_start', initialReport.period_start || ''); setDateTimeFromIso('rep_end', initialReport.period_end || ''); @@ -760,6 +795,10 @@ qs('rep_scope_all').checked = (scope === 'all'); updateScopeUi(); applyCustomerSelection(); + + if (!repHtmlContent) { + qs('rep_html_content').value = defaultHtmlContentForScope(scope); + } } function validate(payload) { @@ -807,7 +846,19 @@ customer_ids: customerIds, period_start: buildIso(qs('rep_start_date').value, qs('rep_start_time').value, '00:00'), period_end: buildIso(qs('rep_end_date').value, qs('rep_end_time').value, '23:59'), - report_config: { columns: repColsSelected, columns_version: 1, filters: { backup_softwares: getSelectedValues(qs('rep_job_backup_software')), backup_types: getSelectedValues(qs('rep_job_backup_type')), filters_version: 1 } } + report_config: { + columns: repColsSelected, + columns_version: 1, + filters: { + backup_softwares: getSelectedValues(qs('rep_job_backup_software')), + backup_types: getSelectedValues(qs('rep_job_backup_type')), + filters_version: 1 + }, + presentation: { + html_content: (qs('rep_html_content').value || '').trim().toLowerCase(), + presentation_version: 1 + } + } }; if (!payload.description) delete payload.description; @@ -864,6 +915,8 @@ r.addEventListener('change', updateScopeUi); }); + qs('rep_html_content').addEventListener('change', function () { repHtmlContentTouched = true; }); + qs('rep_preset_cur_month').addEventListener('click', presetCurrentMonth); qs('rep_preset_last_month').addEventListener('click', presetLastMonth); qs('rep_preset_last_month_full').addEventListener('click', presetLastMonthFull); diff --git a/docs/changelog.md b/docs/changelog.md index 602bda9..4bca3b4 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -190,6 +190,16 @@ - Added validation and normalization for output_format values during report creation and update. - Ensured that selecting HTML as output no longer results in CSV being generated or downloaded. +--- + +## v20260104-02-reports-html-content-and-success-rate-basis + +- Added a configurable report content option for HTML/PDF output: Customers, Jobs, or Customers + Jobs. +- Improved single-customer HTML reports to display job-level details instead of only a customer summary. +- Updated success rate calculation to be based solely on the selected status columns. +- Excluded non-selected statuses (such as Missed runs) from success rate calculations to avoid distorted results. +- Aligned success rate logic consistently across HTML output, PDF summaries, and report statistics. + ================================================================================================================================================ ## v0.1.15 From 985397afa134d0ee7a1e0883f192aa950533f44c Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sun, 4 Jan 2026 12:11:00 +0100 Subject: [PATCH 11/22] Auto-commit local changes before build (2026-01-04 12:11:00) --- .last-branch | 2 +- .../src/backend/app/main/routes_reporting_api.py | 11 +++++++---- docs/changelog.md | 9 +++++++++ 3 files changed, 17 insertions(+), 5 deletions(-) diff --git a/.last-branch b/.last-branch index 225e42e..7f51ecc 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260104-02-reports-html-content-and-success-rate-basis +v20260104-03-reports-html-view-selection-fix 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 bcc7bf9..8eca7bf 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -1092,18 +1092,21 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): html_content = (pres.get("html_content") or "").strip().lower() or None # Default behavior: - # - single customer: jobs (snapshot preview) is more useful - # - multiple/all: customers table is useful + # - honor selected view: Summary should not show Snapshot rows unless explicitly requested + # - when html_content is not set, pick a sensible default based on scope + view scope = (getattr(report, "customer_scope", None) or "all").strip().lower() if not html_content: - html_content = "jobs" if scope == "single" else "customers" + if scope == "single" and (view or "summary").strip().lower() == "summary": + html_content = "customers" + else: + html_content = "jobs" if scope == "single" else "customers" include_customers = html_content in ("customers", "both") include_jobs = html_content in ("jobs", "both") # Snapshot preview table can be requested either explicitly via view=snapshot # or via the report config (include_jobs). - want_snapshot_table = (view or "summary").strip().lower() == "snapshot" or include_jobs + want_snapshot_table = (view or "summary").strip().lower() == "snapshot" snapshot_table_html = "" if want_snapshot_table: diff --git a/docs/changelog.md b/docs/changelog.md index 4bca3b4..6997087 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -200,6 +200,15 @@ - Excluded non-selected statuses (such as Missed runs) from success rate calculations to avoid distorted results. - Aligned success rate logic consistently across HTML output, PDF summaries, and report statistics. +--- + +## v20260104-03-reports-html-view-selection-fix + +- Fixed HTML report generation to correctly respect the selected report view. +- When "Summary" is selected, the HTML output now renders the summary content instead of the snapshot with individual runs. +- Prevented unintended fallback to Snapshot view when generating HTML reports. +- Improved default behavior for single-customer summary reports to ensure a meaningful summary is displayed. + ================================================================================================================================================ ## v0.1.15 From 843e01e1e63cb0c3457e9fc4868ed5e4c7906912 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sun, 4 Jan 2026 12:17:21 +0100 Subject: [PATCH 12/22] Auto-commit local changes before build (2026-01-04 12:17:21) --- .last-branch | 2 +- .../backend/app/main/routes_reporting_api.py | 113 +++++++++++++++++- docs/changelog.md | 8 ++ 3 files changed, 116 insertions(+), 7 deletions(-) diff --git a/.last-branch b/.last-branch index 7f51ecc..563c3b1 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260104-03-reports-html-view-selection-fix +v20260104-04-reports-html-jobs-table-fix 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 8eca7bf..208fcc1 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -1096,18 +1096,117 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): # - when html_content is not set, pick a sensible default based on scope + view scope = (getattr(report, "customer_scope", None) or "all").strip().lower() if not html_content: - if scope == "single" and (view or "summary").strip().lower() == "summary": - html_content = "customers" - else: - html_content = "jobs" if scope == "single" else "customers" + # For a single-customer report, the most useful summary is a job list. + # For all-customer reports, the summary defaults to performance by customer. + html_content = "jobs" if scope == "single" else "customers" include_customers = html_content in ("customers", "both") include_jobs = html_content in ("jobs", "both") - # Snapshot preview table can be requested either explicitly via view=snapshot - # or via the report config (include_jobs). + # Snapshot preview table can be requested explicitly via view=snapshot. want_snapshot_table = (view or "summary").strip().lower() == "snapshot" + jobs_table_html = "" + if include_jobs and not want_snapshot_table: + # Job table (latest snapshot per job/object) for a single-customer report. + jobs_rows = [] + with db.engine.connect() as conn: + rows = conn.execute( + text( + """ + SELECT DISTINCT ON (COALESCE(object_name,''), COALESCE(job_name,''), COALESCE(backup_software,''), COALESCE(backup_type,'')) + COALESCE(object_name,'') AS object_name, + COALESCE(customer_name,'') AS customer_name, + COALESCE(job_name,'') AS job_name, + COALESCE(backup_software,'') AS backup_software, + COALESCE(backup_type,'') AS backup_type, + run_at, + COALESCE(status,'') AS status, + missed, + override_applied, + COALESCE(ticket_number,'') AS ticket_number, + COALESCE(remark,'') AS remark + FROM report_object_snapshots + WHERE report_id = :rid + ORDER BY + COALESCE(object_name,''), + COALESCE(job_name,''), + COALESCE(backup_software,''), + COALESCE(backup_type,''), + run_at DESC NULLS LAST + LIMIT 1000 + """ + ), + {"rid": report_id}, + ).fetchall() + + for r in rows or []: + jobs_rows.append( + { + "object_name": r.object_name or "", + "customer_name": r.customer_name or "", + "job_name": r.job_name or "", + "backup_software": r.backup_software or "", + "backup_type": r.backup_type or "", + "run_at": r.run_at.isoformat() if r.run_at else "", + "status": r.status or "", + "missed": bool(r.missed), + "override_applied": bool(r.override_applied), + "ticket_number": r.ticket_number or "", + "remark": (r.remark or "").replace("\r", " ").replace("\n", " ").strip(), + } + ) + + job_row_html = [] + for j in jobs_rows: + job_row_html.append( + "" + f"{_esc(j['object_name'])}" + f"{_esc(j['job_name'])}" + f"{_esc(j['backup_software'])}" + f"{_esc(j['backup_type'])}" + f"{_esc(j['run_at'])}" + f"{_esc(j['status'])}" + f"{'1' if j['missed'] else '0'}" + f"{'1' if j['override_applied'] else '0'}" + f"{_esc(j['ticket_number'])}" + f"{_esc(j['remark'])}" + "" + ) + + jobs_table_html = """
+
+
+
+
Jobs
+
Latest snapshot per job
+
+
+
+ + + + + + + + + + + + + + + + +""" + "\n".join(job_row_html) + """ +
ObjectJobSoftwareTypeLast runStatusMissedOverrideTicketRemark
+
+
+
+
+
""" + snapshot_table_html = "" if want_snapshot_table: snap_rows = [] @@ -1343,6 +1442,8 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): {perf_table_html} + {jobs_table_html} + {snapshot_table_html}
diff --git a/docs/changelog.md b/docs/changelog.md index 6997087..3841936 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -209,6 +209,14 @@ - Prevented unintended fallback to Snapshot view when generating HTML reports. - Improved default behavior for single-customer summary reports to ensure a meaningful summary is displayed. +--- + +## v20260104-04-reports-html-jobs-table-fix + +- Fixed HTML report rendering where only charts were shown and no data rows appeared. +- Restored the jobs table below the charts in HTML reports for single-customer selections. +- Ensured the latest snapshot per job is displayed correctly in the HTML output. + ================================================================================================================================================ ## v0.1.15 From a1b8dfe5cfb12e663ebd9a5aa2616cf840b23290 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sun, 4 Jan 2026 12:34:15 +0100 Subject: [PATCH 13/22] Auto-commit local changes before build (2026-01-04 12:34:15) --- .last-branch | 2 +- .../backend/app/main/routes_reporting_api.py | 163 ++++++++++-------- .../src/templates/main/reports_new.html | 27 +-- docs/changelog.md | 9 + 4 files changed, 116 insertions(+), 85 deletions(-) diff --git a/.last-branch b/.last-branch index 563c3b1..de76ca9 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260104-04-reports-html-jobs-table-fix +v20260104-05-reports-html-jobs-columns-selection 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 208fcc1..992ad86 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -192,6 +192,7 @@ def build_report_columns_meta(): "views": [ {"key": "summary", "label": "Summary"}, {"key": "snapshot", "label": "Snapshot"}, + {"key": "jobs", "label": "Jobs"}, ], "defaults": { "summary": [ @@ -220,6 +221,18 @@ def build_report_columns_meta(): "remark", "reviewed_at", ], + "jobs": [ + "object_name", + "job_name", + "backup_software", + "backup_type", + "run_at", + "status", + "missed", + "override_applied", + "ticket_number", + "remark", + ], }, "groups": [ { @@ -238,35 +251,35 @@ def build_report_columns_meta(): { "name": "Job Information", "items": [ - {"key": "job_name", "label": "Job name", "views": ["snapshot"]}, + {"key": "job_name", "label": "Job name", "views": ["snapshot", "jobs"]}, {"key": "job_id", "label": "Job ID", "views": ["snapshot"]}, - {"key": "backup_software", "label": "Job type", "views": ["snapshot"]}, - {"key": "backup_type", "label": "Repository / Target", "views": ["snapshot"]}, - {"key": "customer_name", "label": "Customer", "views": ["snapshot", "summary"]}, + {"key": "backup_software", "label": "Software", "views": ["snapshot", "jobs"]}, + {"key": "backup_type", "label": "Type", "views": ["snapshot", "jobs"]}, + {"key": "customer_name", "label": "Customer", "views": ["snapshot", "summary", "jobs"]}, ], }, { "name": "Status", "items": [ - {"key": "status", "label": "Last run status", "views": ["snapshot"]}, - {"key": "missed", "label": "Missed", "views": ["snapshot"]}, - {"key": "override_applied", "label": "Override applied", "views": ["snapshot"]}, + {"key": "status", "label": "Status", "views": ["snapshot", "jobs"]}, + {"key": "missed", "label": "Missed", "views": ["snapshot", "jobs"]}, + {"key": "override_applied", "label": "Override", "views": ["snapshot", "jobs"]}, {"key": "run_id", "label": "Run ID", "views": ["snapshot"]}, - {"key": "ticket_number", "label": "Ticket number", "views": ["snapshot"]}, - {"key": "remark", "label": "Remark", "views": ["snapshot"]}, + {"key": "ticket_number", "label": "Ticket", "views": ["snapshot", "jobs"]}, + {"key": "remark", "label": "Remark", "views": ["snapshot", "jobs"]}, ], }, { "name": "Time", "items": [ - {"key": "run_at", "label": "Start time", "views": ["snapshot"]}, + {"key": "run_at", "label": "Last run", "views": ["snapshot", "jobs"]}, {"key": "reviewed_at", "label": "Reviewed at", "views": ["snapshot"]}, ], }, { "name": "Object", "items": [ - {"key": "object_name", "label": "Object name", "views": ["snapshot", "summary"]}, + {"key": "object_name", "label": "Object", "views": ["snapshot", "summary", "jobs"]}, ], }, { @@ -1103,6 +1116,68 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): include_customers = html_content in ("customers", "both") include_jobs = html_content in ("jobs", "both") + cols_meta = build_report_columns_meta() + label_map: dict[str, str] = {} + allowed_by_view: dict[str, set[str]] = {} + for g in (cols_meta.get("groups") or []): + for it in (g.get("items") or []): + k = (it.get("key") or "").strip() + if not k: + continue + label_map[k] = (it.get("label") or k) + for v in (it.get("views") or []): + allowed_by_view.setdefault(v, set()).add(k) + + def _selected_cols(view_key: str) -> list[str]: + cols = [] + rc_cols = rc.get("columns") if isinstance(rc, dict) else None + if isinstance(rc_cols, dict): + v = rc_cols.get(view_key) + if isinstance(v, list): + cols = [str(x).strip() for x in v if str(x).strip()] + + if not cols: + d = (cols_meta.get("defaults") or {}).get(view_key) + if isinstance(d, list): + cols = [str(x).strip() for x in d if str(x).strip()] + + allowed = allowed_by_view.get(view_key) or set() + cols = [c for c in cols if c in allowed] + if not cols: + cols = [c for c in (cols_meta.get("defaults") or {}).get(view_key, []) if c in allowed] + return cols + + def _td(value: str, cls: str = "") -> str: + cl = f" class='{cls}'" if cls else "" + return f"{value}" + + def _th(label: str, cls: str = "") -> str: + cl = f" class='{cls}'" if cls else "" + return f"{label}" + + def _render_table(view_key: str, rows: list[dict]) -> tuple[str, str]: + cols = _selected_cols(view_key) + + th = [] + for k in cols: + cls = "text-end" if k in ("missed", "override_applied") else "" + th.append(_th(_esc(label_map.get(k) or k), cls)) + + tr = [] + for r in rows: + tds = [] + for k in cols: + if k in ("missed", "override_applied"): + v = "1" if bool(r.get(k)) else "0" + tds.append(_td(_esc(v), "text-end")) + elif k == "run_at": + tds.append(_td(_esc(r.get(k) or ""), "text-muted small")) + else: + tds.append(_td(_esc(r.get(k) or ""))) + tr.append("" + "".join(tds) + "") + + return "".join(th), "\n".join(tr) + # Snapshot preview table can be requested explicitly via view=snapshot. want_snapshot_table = (view or "summary").strip().lower() == "snapshot" @@ -1157,22 +1232,7 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): } ) - job_row_html = [] - for j in jobs_rows: - job_row_html.append( - "" - f"{_esc(j['object_name'])}" - f"{_esc(j['job_name'])}" - f"{_esc(j['backup_software'])}" - f"{_esc(j['backup_type'])}" - f"{_esc(j['run_at'])}" - f"{_esc(j['status'])}" - f"{'1' if j['missed'] else '0'}" - f"{'1' if j['override_applied'] else '0'}" - f"{_esc(j['ticket_number'])}" - f"{_esc(j['remark'])}" - "" - ) + jobs_th, jobs_tr = _render_table("jobs", jobs_rows) jobs_table_html = """
@@ -1185,21 +1245,10 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
- - - - - - - - - - - - + {jobs_th} -""" + "\n".join(job_row_html) + """ +""" + jobs_tr + """
ObjectJobSoftwareTypeLast runStatusMissedOverrideTicketRemark
@@ -1252,23 +1301,7 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): } ) - row_html = [] - for s in snap_rows: - row_html.append( - "" - f"{_esc(s['object_name'])}" - f"{_esc(s['customer_name'])}" - f"{_esc(s['job_name'])}" - f"{_esc(s['backup_software'])}" - f"{_esc(s['backup_type'])}" - f"{_esc(s['run_at'])}" - f"{_esc(s['status'])}" - f"{'1' if s['missed'] else '0'}" - f"{'1' if s['override_applied'] else '0'}" - f"{_esc(s['ticket_number'])}" - f"{_esc(s['remark'])}" - "" - ) + snap_th, snap_tr = _render_table("snapshot", snap_rows) snapshot_table_html = """
@@ -1281,22 +1314,10 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
- - - - - - - - - - - - - + {snap_th} -""" + "\n".join(row_html) + """ +""" + snap_tr + """
ObjectCustomerJobSoftwareTypeRun atStatusMissedOverrideTicketRemark
diff --git a/containers/backupchecks/src/templates/main/reports_new.html b/containers/backupchecks/src/templates/main/reports_new.html index 16846f9..ed0e37a 100644 --- a/containers/backupchecks/src/templates/main/reports_new.html +++ b/containers/backupchecks/src/templates/main/reports_new.html @@ -166,6 +166,7 @@
+
Select columns for the summary view.
@@ -274,13 +275,14 @@ // --- Report content / column selector --- var repColsView = 'summary'; var repColsMeta = window.__reportColumnsMeta || null; - var repColsSelected = { summary: [], snapshot: [] }; + var repColsSelected = { summary: [], snapshot: [], jobs: [] }; if (isEdit && initialReport && initialReport.report_config && initialReport.report_config.columns) { var cols = initialReport.report_config.columns; repColsSelected = { summary: Array.isArray(cols.summary) ? cols.summary.slice() : [], snapshot: Array.isArray(cols.snapshot) ? cols.snapshot.slice() : [], + jobs: Array.isArray(cols.jobs) ? cols.jobs.slice() : [], }; } @@ -392,23 +394,20 @@ })(); function colsHintText(viewKey) { - return viewKey === 'snapshot' - ? 'Select columns for the snapshot view.' - : 'Select columns for the summary view.'; + if (viewKey === 'snapshot') return 'Select columns for the snapshot view.'; + if (viewKey === 'jobs') return 'Select columns for the jobs view (HTML/PDF).'; + return 'Select columns for the summary view.'; } function setColsView(viewKey) { - repColsView = (viewKey === 'snapshot') ? 'snapshot' : 'summary'; + repColsView = (viewKey === 'snapshot' || viewKey === 'jobs') ? viewKey : 'summary'; var a = qs('rep_cols_tab_summary'); var b = qs('rep_cols_tab_snapshot'); - if (repColsView === 'summary') { - a.classList.add('active'); - b.classList.remove('active'); - } else { - b.classList.add('active'); - a.classList.remove('active'); - } + var c = qs('rep_cols_tab_jobs'); + a.classList.toggle('active', repColsView === 'summary'); + b.classList.toggle('active', repColsView === 'snapshot'); + c.classList.toggle('active', repColsView === 'jobs'); qs('rep_cols_hint').textContent = colsHintText(repColsView); renderColsAvailable(); @@ -435,7 +434,7 @@ function ensureDefaultsFromMeta() { if (!repColsMeta || !repColsMeta.defaults) return; - ['summary', 'snapshot'].forEach(function (v) { + ['summary', 'snapshot', 'jobs'].forEach(function (v) { if (!repColsSelected[v] || !repColsSelected[v].length) { repColsSelected[v] = (repColsMeta.defaults[v] || []).slice(); } @@ -612,6 +611,7 @@ ensureDefaultsFromMeta(); qs('rep_cols_tab_summary').addEventListener('click', function () { setColsView('summary'); }); qs('rep_cols_tab_snapshot').addEventListener('click', function () { setColsView('snapshot'); }); + qs('rep_cols_tab_jobs').addEventListener('click', function () { setColsView('jobs'); }); setColsView('summary'); return; } @@ -635,6 +635,7 @@ // bind tabs once metadata is ready qs('rep_cols_tab_summary').addEventListener('click', function () { setColsView('summary'); }); qs('rep_cols_tab_snapshot').addEventListener('click', function () { setColsView('snapshot'); }); + qs('rep_cols_tab_jobs').addEventListener('click', function () { setColsView('jobs'); }); setColsView('summary'); }) diff --git a/docs/changelog.md b/docs/changelog.md index 3841936..c4eaf46 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -217,6 +217,15 @@ - Restored the jobs table below the charts in HTML reports for single-customer selections. - Ensured the latest snapshot per job is displayed correctly in the HTML output. +--- + +## v20260104-05-reports-html-jobs-columns-selection + +- Added configurable column selection for the Jobs table in report create/edit views. +- Exposed all Jobs table columns as selectable options instead of using fixed/hardcoded columns. +- Ensured the HTML report Jobs table only renders columns explicitly selected in the report configuration. +- Aligned Jobs table rendering logic with Snapshot and Summary column selection behavior. + ================================================================================================================================================ ## v0.1.15 From c880121cd389ff28b14edc3d09c4b6cb3334d55a Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sun, 4 Jan 2026 13:29:28 +0100 Subject: [PATCH 14/22] Auto-commit local changes before build (2026-01-04 13:29:28) --- .last-branch | 2 +- .../backend/app/main/routes_reporting_api.py | 240 +++++++++++++++--- docs/changelog.md | 12 + 3 files changed, 217 insertions(+), 37 deletions(-) diff --git a/.last-branch b/.last-branch index de76ca9..35b4fcd 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260104-05-reports-html-jobs-columns-selection +v20260104-06-reports-html-jobs-summary-success-rate 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 992ad86..0bd8f21 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -223,22 +223,23 @@ def build_report_columns_meta(): ], "jobs": [ "object_name", + "customer_name", "job_name", "backup_software", "backup_type", - "run_at", - "status", - "missed", - "override_applied", - "ticket_number", - "remark", + "total_runs", + "success_count", + "warning_count", + "failed_count", + "missed_count", + "success_rate", ], }, "groups": [ { "name": "Summary metrics", "items": [ - {"key": "object_name", "label": "Object", "views": ["summary"]}, + {"key": "object_name", "label": "Text", "views": ["summary"]}, {"key": "total_runs", "label": "Total runs", "views": ["summary"]}, {"key": "success_count", "label": "Success", "views": ["summary"]}, {"key": "success_override_count", "label": "Success (override)", "views": ["summary"]}, @@ -248,6 +249,18 @@ def build_report_columns_meta(): {"key": "success_rate", "label": "Success rate", "views": ["summary"]}, ], }, + { + "name": "Job summary", + "items": [ + {"key": "total_runs", "label": "Total runs", "views": ["jobs"]}, + {"key": "success_count", "label": "Success", "views": ["jobs"]}, + {"key": "success_override_count", "label": "Success (override)", "views": ["jobs"]}, + {"key": "warning_count", "label": "Warning", "views": ["jobs"]}, + {"key": "failed_count", "label": "Failed", "views": ["jobs"]}, + {"key": "missed_count", "label": "Missed", "views": ["jobs"]}, + {"key": "success_rate", "label": "Success rate", "views": ["jobs"]}, + ], + }, { "name": "Job Information", "items": [ @@ -277,9 +290,9 @@ def build_report_columns_meta(): ], }, { - "name": "Object", + "name": "Text", "items": [ - {"key": "object_name", "label": "Object", "views": ["snapshot", "summary", "jobs"]}, + {"key": "object_name", "label": "Text", "views": ["snapshot", "summary", "jobs"]}, ], }, { @@ -614,7 +627,7 @@ def api_reports_data(report_id: int): include_keys = _get_success_rate_keys_from_report(report) view = (request.args.get("view") or "summary").strip().lower() - if view not in ("summary", "snapshot"): + if view not in ("summary", "snapshot", "jobs"): view = "summary" limit = _clamp_int(request.args.get("limit"), default=100, min_v=1, max_v=500) @@ -660,6 +673,86 @@ def api_reports_data(report_id: int): ], } + if view == "jobs": + # Aggregated per-job summary over the reporting period (not latest snapshot). + include_keys_jobs = _get_success_rate_keys_from_report(report, view_key="jobs") + + with db.engine.connect() as conn: + rows = conn.execute( + text( + """ + SELECT + COALESCE(object_name, '') AS object_name, + COALESCE(customer_name, '') AS customer_name, + COALESCE(job_name, '') AS job_name, + COALESCE(backup_software, '') AS backup_software, + COALESCE(backup_type, '') AS backup_type, + COUNT(*)::INTEGER AS total_runs, + SUM(CASE WHEN ((COALESCE(status,'') ILIKE 'Success%%' OR override_applied = TRUE) AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_count, + SUM(CASE WHEN (override_applied = TRUE AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_override_count, + SUM(CASE WHEN (COALESCE(status,'') ILIKE 'Warning%%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS warning_count, + SUM(CASE WHEN (COALESCE(status,'') ILIKE 'Fail%%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS failed_count, + SUM(CASE WHEN missed = TRUE THEN 1 ELSE 0 END)::INTEGER AS missed_count + FROM report_object_snapshots + WHERE report_id = :rid + GROUP BY 1,2,3,4,5 + ORDER BY customer_name ASC, object_name ASC, job_name ASC + LIMIT :limit + OFFSET :offset + """ + ), + {"rid": report_id, "limit": limit, "offset": offset}, + ).fetchall() + + total_rows = conn.execute( + text( + """ + SELECT COUNT(*)::INTEGER AS cnt FROM ( + SELECT 1 + FROM report_object_snapshots + WHERE report_id = :rid + GROUP BY COALESCE(object_name,''), COALESCE(customer_name,''), COALESCE(job_name,''), COALESCE(backup_software,''), COALESCE(backup_type,'') + ) x + """ + ), + {"rid": report_id}, + ).scalar() or 0 + + items = [] + for r in rows or []: + rate = _compute_success_rate( + success=int(r.success_count or 0), + warning=int(r.warning_count or 0), + failed=int(r.failed_count or 0), + missed=int(r.missed_count or 0), + include_keys=include_keys_jobs, + ) + items.append( + { + "object_name": r.object_name or "", + "customer_name": r.customer_name or "", + "job_name": r.job_name or "", + "backup_software": r.backup_software or "", + "backup_type": r.backup_type or "", + "total_runs": int(r.total_runs or 0), + "success_count": int(r.success_count or 0), + "success_override_count": int(r.success_override_count or 0), + "warning_count": int(r.warning_count or 0), + "failed_count": int(r.failed_count or 0), + "missed_count": int(r.missed_count or 0), + "success_rate": round(rate, 2), + } + ) + + return { + "view": "jobs", + "total": int(total_rows), + "limit": int(limit), + "offset": int(offset), + "items": items, + } + + q = db.session.query(ReportObjectSnapshot).filter(ReportObjectSnapshot.report_id == report_id) total = q.count() rows = ( @@ -709,7 +802,7 @@ def _normalize_status_row(status: str, missed: bool) -> str: return "unknown" -def _get_success_rate_keys_from_report(report: ReportDefinition) -> set[str]: +def _get_success_rate_keys_from_report(report: ReportDefinition, view_key: str = "summary") -> set[str]: """Return which status count keys should contribute to success rate. The report UI allows choosing summary columns. Success rate should be @@ -719,11 +812,11 @@ def _get_success_rate_keys_from_report(report: ReportDefinition) -> set[str]: rc = _safe_json_dict(getattr(report, "report_config", None)) cols = (rc.get("columns") or {}) if isinstance(rc, dict) else {} - summary_cols = cols.get("summary") if isinstance(cols, dict) else None + view_cols = cols.get(view_key) if isinstance(cols, dict) else None selected = set() - if isinstance(summary_cols, list): - for k in summary_cols: + if isinstance(view_cols, list): + for k in view_cols: if not isinstance(k, str): continue kk = k.strip() @@ -957,7 +1050,7 @@ def api_reports_stats(report_id: int): def _export_csv_response(report: ReportDefinition, report_id: int, view: str): view = (view or "summary").strip().lower() - if view not in ("summary", "snapshot"): + if view not in ("summary", "snapshot", "jobs"): view = "summary" include_keys = _get_success_rate_keys_from_report(report) @@ -1182,33 +1275,32 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): want_snapshot_table = (view or "summary").strip().lower() == "snapshot" jobs_table_html = "" + jobs_chart_html = "" if include_jobs and not want_snapshot_table: - # Job table (latest snapshot per job/object) for a single-customer report. + # Job table (aggregated per job) for a single-customer report. + include_keys_jobs = _get_success_rate_keys_from_report(report, view_key="jobs") + jobs_rows = [] with db.engine.connect() as conn: rows = conn.execute( text( """ - SELECT DISTINCT ON (COALESCE(object_name,''), COALESCE(job_name,''), COALESCE(backup_software,''), COALESCE(backup_type,'')) + SELECT COALESCE(object_name,'') AS object_name, COALESCE(customer_name,'') AS customer_name, COALESCE(job_name,'') AS job_name, COALESCE(backup_software,'') AS backup_software, COALESCE(backup_type,'') AS backup_type, - run_at, - COALESCE(status,'') AS status, - missed, - override_applied, - COALESCE(ticket_number,'') AS ticket_number, - COALESCE(remark,'') AS remark + COUNT(*)::INTEGER AS total_runs, + SUM(CASE WHEN ((COALESCE(status,'') ILIKE 'Success%%' OR override_applied = TRUE) AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_count, + SUM(CASE WHEN (override_applied = TRUE AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_override_count, + SUM(CASE WHEN (COALESCE(status,'') ILIKE 'Warning%%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS warning_count, + SUM(CASE WHEN (COALESCE(status,'') ILIKE 'Fail%%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS failed_count, + SUM(CASE WHEN missed = TRUE THEN 1 ELSE 0 END)::INTEGER AS missed_count FROM report_object_snapshots WHERE report_id = :rid - ORDER BY - COALESCE(object_name,''), - COALESCE(job_name,''), - COALESCE(backup_software,''), - COALESCE(backup_type,''), - run_at DESC NULLS LAST + GROUP BY 1,2,3,4,5 + ORDER BY customer_name ASC, object_name ASC, job_name ASC LIMIT 1000 """ ), @@ -1216,6 +1308,13 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): ).fetchall() for r in rows or []: + rate = _compute_success_rate( + success=int(r.success_count or 0), + warning=int(r.warning_count or 0), + failed=int(r.failed_count or 0), + missed=int(r.missed_count or 0), + include_keys=include_keys_jobs, + ) jobs_rows.append( { "object_name": r.object_name or "", @@ -1223,12 +1322,13 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): "job_name": r.job_name or "", "backup_software": r.backup_software or "", "backup_type": r.backup_type or "", - "run_at": r.run_at.isoformat() if r.run_at else "", - "status": r.status or "", - "missed": bool(r.missed), - "override_applied": bool(r.override_applied), - "ticket_number": r.ticket_number or "", - "remark": (r.remark or "").replace("\r", " ").replace("\n", " ").strip(), + "total_runs": str(int(r.total_runs or 0)), + "success_count": str(int(r.success_count or 0)), + "success_override_count": str(int(r.success_override_count or 0)), + "warning_count": str(int(r.warning_count or 0)), + "failed_count": str(int(r.failed_count or 0)), + "missed_count": str(int(r.missed_count or 0)), + "success_rate": f"{round(rate, 2)}%", } ) @@ -1239,7 +1339,7 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
Jobs
-
Latest snapshot per job
+
Aggregated per job over the report period
@@ -1256,6 +1356,72 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
""" + # Per-job success rate chart (top 20 by total runs) + chart_rows = [] + for it in jobs_rows: + try: + tr = int(it.get("total_runs") or 0) + except Exception: + tr = 0 + try: + sr = float(str(it.get("success_rate") or "0").replace("%", "")) + except Exception: + sr = 0.0 + label = (it.get("job_name") or it.get("object_name") or "(job)").strip() + chart_rows.append((tr, sr, label)) + + chart_rows.sort(key=lambda x: (-x[0], x[2])) + chart_rows = chart_rows[:20] + + job_labels = [c[2] for c in chart_rows] + job_rates = [round(float(c[1]), 2) for c in chart_rows] + + jobs_chart_html = f""" +
+
+
+
+
Success rate per job
+
Top {len(job_labels)} jobs by total runs
+
+
+ +
+
+
+
+ +""" + snapshot_table_html = "" if want_snapshot_table: snap_rows = [] @@ -1463,6 +1629,8 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str): {perf_table_html} + {jobs_chart_html} + {jobs_table_html} {snapshot_table_html} diff --git a/docs/changelog.md b/docs/changelog.md index c4eaf46..87047b1 100644 --- a/docs/changelog.md +++ b/docs/changelog.md @@ -226,6 +226,18 @@ - Ensured the HTML report Jobs table only renders columns explicitly selected in the report configuration. - Aligned Jobs table rendering logic with Snapshot and Summary column selection behavior. +--- + +## v20260104-06-reports-html-jobs-summary-success-rate + +- Restored the **Text** column label in the report column selector (previously shown as Object). +- Added job-level metrics to the Jobs view, including total runs, success, warning, failed, missed counts, and success rate. +- Made **success rate per job** selectable as a column in job-based reports. +- Changed the HTML Jobs section from showing the **latest snapshot per job** to an **aggregated summary per job** over the selected reporting period. +- Removed the “Latest snapshot per job” subtitle from HTML reports. +- Added a **per-job success rate chart** to the HTML report to visualize backup performance per job. +- Prevented job status from being presented as a summary value, as it represents a momentary state rather than a stable metric. + ================================================================================================================================================ ## v0.1.15 From b3f3ac90fd86213e6299718c8bbb19437eb770e7 Mon Sep 17 00:00:00 2001 From: Ivo Oskamp Date: Sun, 4 Jan 2026 14:34:57 +0100 Subject: [PATCH 15/22] Auto-commit local changes before build (2026-01-04 14:34:57) --- .last-branch | 2 +- .../src/backend/app/main/routes_reporting_api.py | 8 ++++---- docs/changelog.md | 10 ++++++++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/.last-branch b/.last-branch index 35b4fcd..19347a4 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260104-06-reports-html-jobs-summary-success-rate +v20260104-07-reports-html-export-fix-json 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 0bd8f21..b637ab2 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -1392,8 +1392,8 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):