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: if err is not None:
return err 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() view = (request.args.get("view") or "summary").strip().lower()
if view not in ("summary", "snapshot"): 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), "warning_count": int(r.warning_count or 0),
"failed_count": int(r.failed_count or 0), "failed_count": int(r.failed_count or 0),
"missed_count": int(r.missed_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 for r in rows
], ],
@ -686,12 +696,65 @@ def _normalize_status_row(status: str, missed: bool) -> str:
return "unknown" 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: def _build_report_stats_payload(report_id: int) -> dict:
"""Compute summary and chart datasets for a generated report.""" """Compute summary and chart datasets for a generated report."""
report = ReportDefinition.query.get_or_404(report_id) report = ReportDefinition.query.get_or_404(report_id)
include_keys = _get_success_rate_keys_from_report(report)
with db.engine.connect() as conn: with db.engine.connect() as conn:
status_rows = conn.execute( status_rows = conn.execute(
text( text(
@ -744,16 +807,28 @@ def _build_report_stats_payload(report_id: int) -> dict:
for tr in trend_rows or []: for tr in trend_rows or []:
day_total = int(tr.total_runs or 0) day_total = int(tr.total_runs or 0)
day_success = int(tr.success_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 day_rate = 0.0
if day_total > 0: denom = 0
day_rate = (day_success / float(day_total)) * 100.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( trends.append(
{ {
"day": tr.day.date().isoformat() if tr.day else "", "day": tr.day.date().isoformat() if tr.day else "",
"success_rate": round(day_rate, 2), "success_rate": round(day_rate, 2),
"failed_runs": int(tr.failed_runs or 0), "failed_runs": day_failed,
"warning_runs": int(tr.warning_runs or 0), "warning_runs": day_warning,
"missed_runs": int(tr.missed_runs or 0), "missed_runs": day_missed,
"total_runs": day_total, "total_runs": day_total,
} }
) )
@ -800,8 +875,13 @@ def _build_report_stats_payload(report_id: int) -> dict:
totals["missed_count"] += missed_count totals["missed_count"] += missed_count
rate = 0.0 rate = 0.0
if total_runs > 0: rate = _compute_success_rate(
rate = (success_count / float(total_runs)) * 100.0 success=success_count,
warning=warning_count,
failed=failed_count,
missed=missed_count,
include_keys=include_keys,
)
performance.append( performance.append(
{ {
@ -816,8 +896,13 @@ def _build_report_stats_payload(report_id: int) -> dict:
) )
overall_rate = 0.0 overall_rate = 0.0
if totals["total_runs"] > 0: overall_rate = _compute_success_rate(
overall_rate = (totals["success_count"] / float(totals["total_runs"])) * 100.0 success=totals["success_count"],
warning=totals["warning_count"],
failed=totals["failed_count"],
missed=totals["missed_count"],
include_keys=include_keys,
)
return { return {
"report": { "report": {
@ -862,6 +947,8 @@ def _export_csv_response(report: ReportDefinition, report_id: int, view: str):
if view not in ("summary", "snapshot"): if view not in ("summary", "snapshot"):
view = "summary" view = "summary"
include_keys = _get_success_rate_keys_from_report(report)
with db.engine.connect() as conn: with db.engine.connect() as conn:
if view == "summary": if view == "summary":
rows = conn.execute( rows = conn.execute(
@ -897,16 +984,23 @@ def _export_csv_response(report: ReportDefinition, report_id: int, view: str):
for r in rows or []: for r in rows or []:
total_runs = int(r.total_runs or 0) total_runs = int(r.total_runs or 0)
success_count = int(r.success_count or 0) success_count = int(r.success_count or 0)
rate = 0.0 warning_count = int(r.warning_count or 0)
if total_runs > 0: failed_count = int(r.failed_count or 0)
rate = (success_count / float(total_runs)) * 100.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([ writer.writerow([
r.customer_name or "", r.customer_name or "",
total_runs, total_runs,
success_count, success_count,
int(r.warning_count or 0), warning_count,
int(r.failed_count or 0), failed_count,
int(r.missed_count or 0), missed_count,
round(rate, 2), round(rate, 2),
]) ])
filename = f"report-{report_id}-summary.csv" 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: def _esc(s: str) -> str:
return (s or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;") 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 = "" snapshot_table_html = ""
if (view or "summary").strip().lower() == "snapshot": if want_snapshot_table:
snap_rows = [] snap_rows = []
with db.engine.connect() as conn: with db.engine.connect() as conn:
rows = conn.execute( 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 "" period_e = report.period_end.isoformat() if report.period_end else ""
perf_rows = [] perf_rows = []
for p in perf: if include_customers:
perf_rows.append( for p in perf:
"<tr>" perf_rows.append(
f"<td>{_esc(p.get('customer_name') or '')}</td>" "<tr>"
f"<td class='text-end'>{int(p.get('total_runs') or 0)}</td>" f"<td>{_esc(p.get('customer_name') or '')}</td>"
f"<td class='text-end'>{int(p.get('success_count') or 0)}</td>" f"<td class='text-end'>{int(p.get('total_runs') or 0)}</td>"
f"<td class='text-end'>{int(p.get('warning_count') or 0)}</td>" f"<td class='text-end'>{int(p.get('success_count') or 0)}</td>"
f"<td class='text-end'>{int(p.get('failed_count') or 0)}</td>" f"<td class='text-end'>{int(p.get('warning_count') or 0)}</td>"
f"<td class='text-end'>{int(p.get('missed_count') or 0)}</td>" f"<td class='text-end'>{int(p.get('failed_count') or 0)}</td>"
f"<td class='text-end'>{p.get('success_rate', 0)}%</td>" f"<td class='text-end'>{int(p.get('missed_count') or 0)}</td>"
"</tr>" f"<td class='text-end'>{p.get('success_rate', 0)}%</td>"
) "</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 = f"""<!doctype html>
<html lang="en"> <html lang="en">
@ -1188,36 +1338,7 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
</div> </div>
</div> </div>
<div class="row g-3 mb-3"> {perf_table_html}
<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>
{snapshot_table_html} {snapshot_table_html}

View File

@ -36,6 +36,16 @@
</select> </select>
</div> </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"> <div class="col-12">
<label class="form-label">Description</label> <label class="form-label">Description</label>
<input type="text" class="form-control" id="rep_description" placeholder="Optional description" /> <input type="text" class="form-control" id="rep_description" placeholder="Optional description" />
@ -245,6 +255,21 @@
var initialReport = window.__initialReport || null; var initialReport = window.__initialReport || null;
var editReportId = (initialReport && initialReport.id) ? initialReport.id : 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 --- // --- Report content / column selector ---
var repColsView = 'summary'; var repColsView = 'summary';
@ -681,6 +706,11 @@
var scope = selectedScope(); var scope = selectedScope();
qs('rep_single_wrap').classList.toggle('d-none', scope !== 'single'); qs('rep_single_wrap').classList.toggle('d-none', scope !== 'single');
qs('rep_multiple_wrap').classList.toggle('d-none', scope !== 'multiple'); 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() { function applyCustomerSelection() {
@ -750,6 +780,11 @@
qs('rep_description').value = initialReport.description || ''; qs('rep_description').value = initialReport.description || '';
qs('rep_output_format').value = (initialReport.output_format || 'csv'); 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_start', initialReport.period_start || '');
setDateTimeFromIso('rep_end', initialReport.period_end || ''); setDateTimeFromIso('rep_end', initialReport.period_end || '');
@ -760,6 +795,10 @@
qs('rep_scope_all').checked = (scope === 'all'); qs('rep_scope_all').checked = (scope === 'all');
updateScopeUi(); updateScopeUi();
applyCustomerSelection(); applyCustomerSelection();
if (!repHtmlContent) {
qs('rep_html_content').value = defaultHtmlContentForScope(scope);
}
} }
function validate(payload) { function validate(payload) {
@ -807,7 +846,19 @@
customer_ids: customerIds, customer_ids: customerIds,
period_start: buildIso(qs('rep_start_date').value, qs('rep_start_time').value, '00:00'), 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'), 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; if (!payload.description) delete payload.description;
@ -864,6 +915,8 @@
r.addEventListener('change', updateScopeUi); r.addEventListener('change', updateScopeUi);
}); });
qs('rep_html_content').addEventListener('change', function () { repHtmlContentTouched = true; });
qs('rep_preset_cur_month').addEventListener('click', presetCurrentMonth); qs('rep_preset_cur_month').addEventListener('click', presetCurrentMonth);
qs('rep_preset_last_month').addEventListener('click', presetLastMonth); qs('rep_preset_last_month').addEventListener('click', presetLastMonth);
qs('rep_preset_last_month_full').addEventListener('click', presetLastMonthFull); 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. - 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. - 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 ## v0.1.15