Auto-commit local changes before build (2026-01-04 11:57:39)
This commit is contained in:
parent
609364ef2f
commit
cea1df3e38
@ -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:
|
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("&", "&").replace("<", "<").replace(">", ">")
|
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 = ""
|
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,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 "—"
|
period_e = report.period_end.isoformat() if report.period_end else "—"
|
||||||
|
|
||||||
perf_rows = []
|
perf_rows = []
|
||||||
|
if include_customers:
|
||||||
for p in perf:
|
for p in perf:
|
||||||
perf_rows.append(
|
perf_rows.append(
|
||||||
"<tr>"
|
"<tr>"
|
||||||
@ -1108,6 +1223,41 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
|
|||||||
"</tr>"
|
"</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">
|
||||||
<head>
|
<head>
|
||||||
@ -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}
|
||||||
|
|
||||||
|
|||||||
@ -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);
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user