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"""
+
+
+
+
+
+
+
+
+
+ | Customer |
+ Total |
+ Success |
+ Warning |
+ Failed |
+ Missed |
+ Success % |
+
+
+
+ {''.join(perf_rows)}
+
+
+
+
+
+
+
+"""
html = f"""
@@ -1188,36 +1338,7 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
-
-
-
-
-
-
-
-
-
- | Customer |
- Total |
- Success |
- Warning |
- Failed |
- Missed |
- Success % |
-
-
-
- {''.join(perf_rows)}
-
-
-
-
-
-
-
+ {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