v20260104-14-reports-stats-total-runs-success-rate-fix #28
@ -1 +1 @@
|
||||
v20260104-01-reports-output-format-save-fix
|
||||
v20260104-02-reports-html-content-and-success-rate-basis
|
||||
|
||||
@ -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(
|
||||
"<tr>"
|
||||
f"<td>{_esc(p.get('customer_name') or '')}</td>"
|
||||
f"<td class='text-end'>{int(p.get('total_runs') or 0)}</td>"
|
||||
f"<td class='text-end'>{int(p.get('success_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('failed_count') or 0)}</td>"
|
||||
f"<td class='text-end'>{int(p.get('missed_count') or 0)}</td>"
|
||||
f"<td class='text-end'>{p.get('success_rate', 0)}%</td>"
|
||||
"</tr>"
|
||||
)
|
||||
if include_customers:
|
||||
for p in perf:
|
||||
perf_rows.append(
|
||||
"<tr>"
|
||||
f"<td>{_esc(p.get('customer_name') or '')}</td>"
|
||||
f"<td class='text-end'>{int(p.get('total_runs') or 0)}</td>"
|
||||
f"<td class='text-end'>{int(p.get('success_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('failed_count') or 0)}</td>"
|
||||
f"<td class='text-end'>{int(p.get('missed_count') or 0)}</td>"
|
||||
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 lang="en">
|
||||
@ -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}
|
||||
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user