Auto-commit local changes before build (2026-01-04 11:57:39)

This commit is contained in:
Ivo Oskamp 2026-01-04 11:57:39 +01:00
parent 609364ef2f
commit cea1df3e38
4 changed files with 246 additions and 62 deletions

View File

@ -1 +1 @@
v20260104-01-reports-output-format-save-fix
v20260104-02-reports-html-content-and-success-rate-basis

View File

@ -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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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,6 +1209,7 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
period_e = report.period_end.isoformat() if report.period_end else ""
perf_rows = []
if include_customers:
for p in perf:
perf_rows.append(
"<tr>"
@ -1108,6 +1223,41 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
"</tr>"
)
perf_table_html = ""
if include_customers:
perf_table_html = f"""
<div class=\"row g-3 mb-3\">
<div class=\"col-12\">
<div class=\"card shadow-sm\">
<div class=\"card-header bg-white\">
<div class=\"fw-semibold\">Performance by customer</div>
<div class=\"small-muted\">Aggregated from generated snapshot data</div>
</div>
<div class=\"card-body\">
<div class=\"table-responsive\">
<table class=\"table table-sm table-hover\">
<thead>
<tr>
<th>Customer</th>
<th class=\"text-end\">Total</th>
<th class=\"text-end\">Success</th>
<th class=\"text-end\">Warning</th>
<th class=\"text-end\">Failed</th>
<th class=\"text-end\">Missed</th>
<th class=\"text-end\">Success %</th>
</tr>
</thead>
<tbody>
{''.join(perf_rows)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
"""
html = f"""<!doctype html>
<html lang="en">
<head>
@ -1188,36 +1338,7 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-white">
<div class="fw-semibold">Performance by customer</div>
<div class="small-muted">Aggregated from generated snapshot data</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead>
<tr>
<th>Customer</th>
<th class="text-end">Total</th>
<th class="text-end">Success</th>
<th class="text-end">Warning</th>
<th class="text-end">Failed</th>
<th class="text-end">Missed</th>
<th class="text-end">Success %</th>
</tr>
</thead>
<tbody>
{''.join(perf_rows)}
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{perf_table_html}
{snapshot_table_html}

View File

@ -36,6 +36,16 @@
</select>
</div>
<div class="col-12 col-md-6">
<label class="form-label">HTML/PDF content</label>
<select class="form-select" id="rep_html_content">
<option value="customers">Customers</option>
<option value="jobs">Jobs</option>
<option value="both">Customers + Jobs</option>
</select>
<div class="form-text">Controls whether the HTML/PDF output shows a customer list, a job list, or both.</div>
</div>
<div class="col-12">
<label class="form-label">Description</label>
<input type="text" class="form-control" id="rep_description" placeholder="Optional description" />
@ -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);

View File

@ -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