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