v20260104-14-reports-stats-total-runs-success-rate-fix #28

Merged
ivooskamp merged 22 commits from v20260104-14-reports-stats-total-runs-success-rate-fix into main 2026-01-13 10:59:39 +01:00
6 changed files with 659 additions and 159 deletions
Showing only changes of commit 22a2e7146b - Show all commits

View File

@ -1 +1 @@
v20260103-18-reports-job-filter-selectbox-size-6-rows v20260103-19-reports-output-format-html

View File

@ -6,3 +6,4 @@ psycopg2-binary==2.9.9
python-dateutil==2.9.0.post0 python-dateutil==2.9.0.post0
gunicorn==23.0.0 gunicorn==23.0.0
requests==2.32.3 requests==2.32.3
reportlab==4.2.5

View File

@ -675,75 +675,48 @@ def _normalize_status_row(status: str, missed: bool) -> str:
return "unknown" return "unknown"
@main_bp.route("/api/reports/<int:report_id>/stats", methods=["GET"])
@login_required
def api_reports_stats(report_id: int):
"""Return aggregated KPI + chart data for a report.
Designed to support the "Overview" tab (KPIs + charts) described in the reporting proposal.
"""
err = _require_reporting_role()
if err is not None:
return err
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) report = ReportDefinition.query.get_or_404(report_id)
# If the report hasn't been generated yet, these tables can be empty. with db.engine.connect() as conn:
# Return empty-but-valid structures so the UI can render deterministically. status_rows = conn.execute(
engine = db.get_engine()
with engine.begin() as conn:
# KPI (runs)
kpi = conn.execute(
text( text(
""" """
SELECT SELECT
COUNT(*)::INTEGER AS total_runs, COALESCE(status,'') AS status,
SUM(CASE WHEN (COALESCE(status,'') ILIKE 'success%' AND override_applied = FALSE AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_runs, missed AS missed,
SUM(CASE WHEN override_applied = TRUE AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS success_override_runs, COUNT(*)::INTEGER AS cnt
SUM(CASE WHEN (COALESCE(status,'') ILIKE 'warning%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS warning_runs,
SUM(CASE WHEN (COALESCE(status,'') ILIKE 'fail%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS failed_runs,
SUM(CASE WHEN missed = TRUE THEN 1 ELSE 0 END)::INTEGER AS missed_runs,
COUNT(DISTINCT job_id)::INTEGER AS total_jobs
FROM report_object_snapshots FROM report_object_snapshots
WHERE report_id = :rid WHERE report_id = :rid
GROUP BY 1, 2
""" """
), ),
{"rid": report_id}, {"rid": report_id},
).fetchone() ).fetchall()
total_runs = int(kpi.total_runs or 0) if kpi else 0 status_distribution = {
success_runs = int(kpi.success_runs or 0) if kpi else 0 "success": 0,
success_override_runs = int(kpi.success_override_runs or 0) if kpi else 0 "warning": 0,
warning_runs = int(kpi.warning_runs or 0) if kpi else 0 "failed": 0,
failed_runs = int(kpi.failed_runs or 0) if kpi else 0 "missed": 0,
missed_runs = int(kpi.missed_runs or 0) if kpi else 0 "unknown": 0,
total_jobs = int(kpi.total_jobs or 0) if kpi else 0 }
for sr in status_rows or []:
key = _normalize_status_row(sr.status, bool(sr.missed))
status_distribution[key] = int(status_distribution.get(key, 0)) + int(sr.cnt or 0)
success_rate = 0.0 # Success rate trend over time (daily)
if total_runs > 0:
success_rate = ((success_runs + success_override_runs) / float(total_runs)) * 100.0
success_rate = round(success_rate, 2)
# Status distribution (for donut/pie)
status_distribution = [
{"key": "success", "value": success_runs + success_override_runs},
{"key": "warning", "value": warning_runs},
{"key": "failed", "value": failed_runs},
{"key": "missed", "value": missed_runs},
]
# Trends over time (day buckets)
# Note: uses report.period_start/end so the UI can render the x-axis consistently.
trend_rows = conn.execute( trend_rows = conn.execute(
text( text(
""" """
SELECT SELECT
DATE_TRUNC('day', run_at) AS day, DATE_TRUNC('day', run_at) AS day,
SUM(CASE WHEN (COALESCE(status,'') ILIKE 'success%' AND override_applied = FALSE AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_runs, SUM(CASE WHEN (COALESCE(status,'') ILIKE 'Success%%' OR override_applied = TRUE) AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS success_runs,
SUM(CASE WHEN (override_applied = TRUE AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_override_runs, SUM(CASE WHEN COALESCE(status,'') ILIKE 'Warning%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS warning_runs,
SUM(CASE WHEN (COALESCE(status,'') ILIKE 'warning%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS warning_runs, SUM(CASE WHEN COALESCE(status,'') ILIKE 'Fail%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS failed_runs,
SUM(CASE WHEN (COALESCE(status,'') ILIKE 'fail%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS failed_runs,
SUM(CASE WHEN missed = TRUE THEN 1 ELSE 0 END)::INTEGER AS missed_runs, SUM(CASE WHEN missed = TRUE THEN 1 ELSE 0 END)::INTEGER AS missed_runs,
COUNT(*)::INTEGER AS total_runs COUNT(*)::INTEGER AS total_runs
FROM report_object_snapshots FROM report_object_snapshots
@ -759,13 +732,13 @@ def api_reports_stats(report_id: int):
trends = [] trends = []
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) + int(tr.success_override_runs or 0) day_success = int(tr.success_runs or 0)
day_rate = 0.0 day_rate = 0.0
if day_total > 0: if day_total > 0:
day_rate = (day_success / float(day_total)) * 100.0 day_rate = (day_success / float(day_total)) * 100.0
trends.append( trends.append(
{ {
"day": tr.day.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": int(tr.failed_runs or 0),
"warning_runs": int(tr.warning_runs or 0), "warning_runs": int(tr.warning_runs or 0),
@ -774,24 +747,84 @@ def api_reports_stats(report_id: int):
} }
) )
# Performance placeholders (requires duration/data extraction work in later phases) # Performance by customer (for summary view)
performance = { perf_rows = conn.execute(
"avg_runtime_seconds": None, text(
"top_jobs_by_runtime": [], """
"top_jobs_by_data": [], SELECT
COALESCE(customer_name, '') AS customer_name,
COUNT(*)::INTEGER AS total_runs,
SUM(CASE WHEN (COALESCE(status,'') ILIKE 'Success%%' OR override_applied = TRUE) AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS success_count,
SUM(CASE WHEN COALESCE(status,'') ILIKE 'Warning%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS warning_count,
SUM(CASE WHEN COALESCE(status,'') ILIKE 'Fail%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS failed_count,
SUM(CASE WHEN missed = TRUE THEN 1 ELSE 0 END)::INTEGER AS missed_count
FROM report_object_snapshots
WHERE report_id = :rid
GROUP BY 1
ORDER BY 1 ASC
"""
),
{"rid": report_id},
).fetchall()
performance = []
totals = {
"total_runs": 0,
"success_count": 0,
"warning_count": 0,
"failed_count": 0,
"missed_count": 0,
} }
for pr in perf_rows or []:
total_runs = int(pr.total_runs or 0)
success_count = int(pr.success_count or 0)
warning_count = int(pr.warning_count or 0)
failed_count = int(pr.failed_count or 0)
missed_count = int(pr.missed_count or 0)
totals["total_runs"] += total_runs
totals["success_count"] += success_count
totals["warning_count"] += warning_count
totals["failed_count"] += failed_count
totals["missed_count"] += missed_count
rate = 0.0
if total_runs > 0:
rate = (success_count / float(total_runs)) * 100.0
performance.append(
{
"customer_name": pr.customer_name or "",
"total_runs": total_runs,
"success_count": success_count,
"warning_count": warning_count,
"failed_count": failed_count,
"missed_count": missed_count,
"success_rate": round(rate, 2),
}
)
overall_rate = 0.0
if totals["total_runs"] > 0:
overall_rate = (totals["success_count"] / float(totals["total_runs"])) * 100.0
return { return {
"period_start": report.period_start.isoformat() if report.period_start else "", "report": {
"period_end": report.period_end.isoformat() if report.period_end else "", "id": report.id,
"kpis": { "name": report.name,
"total_jobs": total_jobs, "report_type": report.report_type,
"total_runs": total_runs, "output_format": report.output_format,
"success_runs": success_runs + success_override_runs, "period_start": report.period_start.isoformat() if report.period_start else "",
"warning_runs": warning_runs, "period_end": report.period_end.isoformat() if report.period_end else "",
"failed_runs": failed_runs, "created_at": report.created_at.isoformat() if report.created_at else "",
"missed_runs": missed_runs, },
"success_rate": success_rate, "summary": {
"total_runs": totals["total_runs"],
"success_count": totals["success_count"],
"warning_count": totals["warning_count"],
"failed_count": totals["failed_count"],
"missed_count": totals["missed_count"],
"success_rate": round(overall_rate, 2),
}, },
"charts": { "charts": {
"status_distribution": status_distribution, "status_distribution": status_distribution,
@ -801,100 +834,554 @@ def api_reports_stats(report_id: int):
} }
@main_bp.route("/api/reports/<int:report_id>/export.csv", methods=["GET"])
@main_bp.route("/api/reports/<int:report_id>/stats", methods=["GET"])
@login_required @login_required
def api_reports_export_csv(report_id: int): def api_reports_stats(report_id: int):
err = _require_reporting_role()
if err is not None:
return err
return _build_report_stats_payload(report_id)
def _export_csv_response(report: ReportDefinition, report_id: int, view: str):
view = (view or "summary").strip().lower()
if view not in ("summary", "snapshot"):
view = "summary"
with db.engine.connect() as conn:
if view == "summary":
rows = conn.execute(
text(
"""
SELECT
COALESCE(customer_name,'') AS customer_name,
COUNT(*)::INTEGER AS total_runs,
SUM(CASE WHEN (COALESCE(status,'') ILIKE 'Success%%' OR override_applied = TRUE) AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS success_count,
SUM(CASE WHEN COALESCE(status,'') ILIKE 'Warning%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS warning_count,
SUM(CASE WHEN COALESCE(status,'') ILIKE 'Fail%%' AND missed = FALSE THEN 1 ELSE 0 END)::INTEGER AS failed_count,
SUM(CASE WHEN missed = TRUE THEN 1 ELSE 0 END)::INTEGER AS missed_count
FROM report_object_snapshots
WHERE report_id = :rid
GROUP BY 1
ORDER BY 1 ASC
"""
),
{"rid": report_id},
).fetchall()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"customer_name",
"total_runs",
"success_count",
"warning_count",
"failed_count",
"missed_count",
"success_rate",
])
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
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),
round(rate, 2),
])
filename = f"report-{report_id}-summary.csv"
else:
rows = conn.execute(
text(
"""
SELECT
COALESCE(object_name,'') AS object_name,
COALESCE(customer_name,'') AS customer_name,
COALESCE(job_id,'') AS job_id,
COALESCE(job_name,'') AS job_name,
COALESCE(backup_software,'') AS backup_software,
COALESCE(backup_type,'') AS backup_type,
COALESCE(run_id,'') AS run_id,
run_at,
COALESCE(status,'') AS status,
missed,
override_applied,
reviewed_at,
COALESCE(ticket_number,'') AS ticket_number,
COALESCE(remark,'') AS remark
FROM report_object_snapshots
WHERE report_id = :rid
ORDER BY customer_name ASC, object_name ASC
"""
),
{"rid": report_id},
).fetchall()
output = io.StringIO()
writer = csv.writer(output)
writer.writerow([
"object_name",
"customer_name",
"job_id",
"job_name",
"backup_software",
"backup_type",
"run_id",
"run_at",
"status",
"missed",
"override_applied",
"reviewed_at",
"ticket_number",
"remark",
])
for r in rows or []:
writer.writerow([
r.object_name or "",
r.customer_name or "",
r.job_id or "",
r.job_name or "",
r.backup_software or "",
r.backup_type or "",
r.run_id or "",
r.run_at.isoformat() if r.run_at else "",
r.status or "",
"1" if r.missed else "0",
"1" if r.override_applied else "0",
r.reviewed_at.isoformat() if r.reviewed_at else "",
r.ticket_number or "",
(r.remark or "").replace("\r", " ").replace("\n", " ").strip(),
])
filename = f"report-{report_id}-snapshot.csv"
csv_bytes = output.getvalue().encode("utf-8")
mem = io.BytesIO(csv_bytes)
mem.seek(0)
return send_file(mem, mimetype="text/csv", as_attachment=True, download_name=filename)
def _export_html_response(report: ReportDefinition, report_id: int, view: str):
stats = _build_report_stats_payload(report_id)
summary = stats.get("summary") or {}
charts = stats.get("charts") or {}
trends = charts.get("trends") or []
status_dist = charts.get("status_distribution") or {}
perf = charts.get("performance") or []
def _esc(s: str) -> str:
return (s or "").replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
snapshot_table_html = ""
if (view or "summary").strip().lower() == "snapshot":
snap_rows = []
with db.engine.connect() as conn:
rows = conn.execute(
text(
"""
SELECT
COALESCE(object_name,'') AS object_name,
COALESCE(customer_name,'') AS customer_name,
COALESCE(job_name,'') AS job_name,
COALESCE(backup_software,'') AS backup_software,
COALESCE(backup_type,'') AS backup_type,
run_at,
COALESCE(status,'') AS status,
missed,
override_applied,
COALESCE(ticket_number,'') AS ticket_number,
COALESCE(remark,'') AS remark
FROM report_object_snapshots
WHERE report_id = :rid
ORDER BY customer_name ASC, object_name ASC
LIMIT 500
"""
),
{"rid": report_id},
).fetchall()
for r in rows or []:
snap_rows.append(
{
"object_name": r.object_name or "",
"customer_name": r.customer_name or "",
"job_name": r.job_name or "",
"backup_software": r.backup_software or "",
"backup_type": r.backup_type or "",
"run_at": r.run_at.isoformat() if r.run_at else "",
"status": r.status or "",
"missed": bool(r.missed),
"override_applied": bool(r.override_applied),
"ticket_number": r.ticket_number or "",
"remark": (r.remark or "").replace("\r", " ").replace("\n", " ").strip(),
}
)
row_html = []
for s in snap_rows:
row_html.append(
"<tr>"
f"<td>{_esc(s['object_name'])}</td>"
f"<td>{_esc(s['customer_name'])}</td>"
f"<td>{_esc(s['job_name'])}</td>"
f"<td>{_esc(s['backup_software'])}</td>"
f"<td>{_esc(s['backup_type'])}</td>"
f"<td class='text-muted small'>{_esc(s['run_at'])}</td>"
f"<td>{_esc(s['status'])}</td>"
f"<td class='text-end'>{'1' if s['missed'] else '0'}</td>"
f"<td class='text-end'>{'1' if s['override_applied'] else '0'}</td>"
f"<td>{_esc(s['ticket_number'])}</td>"
f"<td>{_esc(s['remark'])}</td>"
"</tr>"
)
snapshot_table_html = """<div class="row g-3">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-white">
<div class="fw-semibold">Snapshot preview</div>
<div class="small-muted">First 500 rows</div>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-sm table-striped">
<thead>
<tr>
<th>Object</th>
<th>Customer</th>
<th>Job</th>
<th>Software</th>
<th>Type</th>
<th>Run at</th>
<th>Status</th>
<th class="text-end">Missed</th>
<th class="text-end">Override</th>
<th>Ticket</th>
<th>Remark</th>
</tr>
</thead>
<tbody>
""" + "\n".join(row_html) + """</tbody>
</table>
</div>
</div>
</div>
</div>
</div>"""
import json as _json
title = _esc(report.name or "Report")
period_s = report.period_start.isoformat() if report.period_start else ""
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>"
)
html = f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>{title}</title>
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.1/dist/chart.umd.min.js"></script>
<style>
body {{ background: #f8f9fa; }}
.card {{ border-radius: 14px; }}
.metric {{ font-size: 1.4rem; font-weight: 700; }}
.small-muted {{ color: #6c757d; font-size: .85rem; }}
.table-sm td, .table-sm th {{ vertical-align: middle; }}
</style>
</head>
<body>
<div class="container py-4">
<div class="d-flex flex-wrap align-items-baseline justify-content-between gap-2 mb-3">
<div>
<h2 class="mb-0">{title}</h2>
<div class="small-muted">Report ID {report_id} · Period {period_s} {period_e}</div>
</div>
<div class="text-end">
<div class="small-muted">Generated HTML preview</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-md-3">
<div class="card shadow-sm"><div class="card-body">
<div class="small-muted">Total runs</div>
<div class="metric">{summary.get('total_runs', 0)}</div>
</div></div>
</div>
<div class="col-12 col-md-3">
<div class="card shadow-sm"><div class="card-body">
<div class="small-muted">Success</div>
<div class="metric">{summary.get('success_count', 0)}</div>
</div></div>
</div>
<div class="col-12 col-md-3">
<div class="card shadow-sm"><div class="card-body">
<div class="small-muted">Failed</div>
<div class="metric">{summary.get('failed_count', 0)}</div>
</div></div>
</div>
<div class="col-12 col-md-3">
<div class="card shadow-sm"><div class="card-body">
<div class="small-muted">Success rate</div>
<div class="metric">{summary.get('success_rate', 0)}%</div>
</div></div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-12 col-xl-8">
<div class="card shadow-sm">
<div class="card-header bg-white">
<div class="fw-semibold">Success rate trend</div>
<div class="small-muted">Daily success rate based on generated snapshots</div>
</div>
<div class="card-body">
<canvas id="chartTrend" height="110"></canvas>
</div>
</div>
</div>
<div class="col-12 col-xl-4">
<div class="card shadow-sm">
<div class="card-header bg-white">
<div class="fw-semibold">Status distribution</div>
<div class="small-muted">All snapshots grouped by status</div>
</div>
<div class="card-body">
<canvas id="chartStatus" height="180"></canvas>
</div>
</div>
</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>
{snapshot_table_html}
</div>
<script>
const trends = {_json.dumps(trends)};
const dist = {_json.dumps(status_dist)};
const labels = trends.map(t => t.day);
const rates = trends.map(t => t.success_rate);
new Chart(document.getElementById('chartTrend'), {{
type: 'line',
data: {{
labels,
datasets: [{{ label: 'Success rate (%)', data: rates, tension: 0.25 }}]
}},
options: {{
responsive: true,
plugins: {{
legend: {{ display: true }}
}},
scales: {{
y: {{ min: 0, max: 100 }}
}}
}}
}});
new Chart(document.getElementById('chartStatus'), {{
type: 'doughnut',
data: {{
labels: Object.keys(dist),
datasets: [{{ data: Object.values(dist) }}]
}},
options: {{
responsive: true,
plugins: {{
legend: {{ position: 'bottom' }}
}}
}}
}});
</script>
</body>
</html>
"""
resp = Response(html, mimetype="text/html")
resp.headers["Content-Disposition"] = f'inline; filename="report-{report_id}.html"'
return resp
def _export_pdf_response(report: ReportDefinition, report_id: int, view: str):
stats = _build_report_stats_payload(report_id)
summary = stats.get("summary") or {}
trends = (stats.get("charts") or {}).get("trends") or []
from reportlab.pdfgen import canvas
from reportlab.lib.pagesizes import A4
from reportlab.lib.units import mm
buf = io.BytesIO()
c = canvas.Canvas(buf, pagesize=A4)
w, h = A4
margin = 18 * mm
y = h - margin
c.setTitle(report.name or f"Report {report_id}")
c.setFont("Helvetica-Bold", 16)
c.drawString(margin, y, report.name or "Report")
y -= 8 * mm
c.setFont("Helvetica", 9)
period_s = report.period_start.isoformat() if report.period_start else ""
period_e = report.period_end.isoformat() if report.period_end else ""
c.drawString(margin, y, f"Report ID {report_id} Period {period_s}{period_e}")
y -= 10 * mm
c.setFont("Helvetica-Bold", 11)
c.drawString(margin, y, "Summary")
y -= 6 * mm
c.setFont("Helvetica", 10)
summary_lines = [
f"Total runs: {summary.get('total_runs', 0)}",
f"Success: {summary.get('success_count', 0)}",
f"Warning: {summary.get('warning_count', 0)}",
f"Failed: {summary.get('failed_count', 0)}",
f"Missed: {summary.get('missed_count', 0)}",
f"Success rate: {summary.get('success_rate', 0)}%",
]
for ln in summary_lines:
c.drawString(margin, y, ln)
y -= 5 * mm
y -= 4 * mm
c.setFont("Helvetica-Bold", 11)
c.drawString(margin, y, "Success rate trend")
y -= 6 * mm
chart_w = w - (2 * margin)
chart_h = 55 * mm
chart_x = margin
chart_y = y - chart_h
c.setLineWidth(1)
c.rect(chart_x, chart_y, chart_w, chart_h)
if trends:
points = []
n = len(trends)
for i, t in enumerate(trends):
try:
rate = float(t.get("success_rate") or 0.0)
except Exception:
rate = 0.0
x = chart_x + (i * (chart_w / max(1, n - 1)))
yv = chart_y + (rate / 100.0) * chart_h
points.append((x, yv))
c.setLineWidth(1.5)
for i in range(1, len(points)):
c.line(points[i - 1][0], points[i - 1][1], points[i][0], points[i][1])
c.setFont("Helvetica", 8)
first_day = (trends[0].get("day") or "")[:10]
last_day = (trends[-1].get("day") or "")[:10]
c.drawString(chart_x, chart_y - 4 * mm, first_day)
c.drawRightString(chart_x + chart_w, chart_y - 4 * mm, last_day)
c.drawRightString(chart_x - 2, chart_y, "0%")
c.drawRightString(chart_x - 2, chart_y + chart_h, "100%")
else:
c.setFont("Helvetica", 9)
c.drawString(chart_x + 4 * mm, chart_y + chart_h / 2, "No trend data available (generate the report first).")
c.showPage()
c.save()
buf.seek(0)
return send_file(buf, mimetype="application/pdf", as_attachment=True, download_name=f"report-{report_id}.pdf")
@main_bp.route("/api/reports/<int:report_id>/export", methods=["GET"])
@login_required
def api_reports_export(report_id: int):
err = _require_reporting_role() err = _require_reporting_role()
if err is not None: if err is not None:
return err return err
report = ReportDefinition.query.get_or_404(report_id) report = ReportDefinition.query.get_or_404(report_id)
view = (request.args.get("view") or "summary").strip().lower() view = (request.args.get("view") or "summary").strip().lower()
fmt = (request.args.get("format") or report.output_format or "csv").strip().lower()
if view not in ("summary", "snapshot"): if fmt not in ("csv", "html", "pdf"):
view = "summary" fmt = "csv"
output = io.StringIO() if fmt == "csv":
writer = csv.writer(output) return _export_csv_response(report, report_id, view)
if view == "summary": if fmt == "html":
writer.writerow([ return _export_html_response(report, report_id, view)
"object_name",
"customer_id",
"customer_name",
"total_runs",
"success_count",
"success_override_count",
"warning_count",
"failed_count",
"missed_count",
"success_rate",
])
rows = (
db.session.query(ReportObjectSummary)
.filter(ReportObjectSummary.report_id == report_id)
.order_by(db.func.coalesce(ReportObjectSummary.customer_name, '').asc(), ReportObjectSummary.object_name.asc())
.all()
)
for r in rows:
writer.writerow([
r.object_name or "",
r.customer_id or "",
r.customer_name or "",
int(r.total_runs or 0),
int(r.success_count or 0),
int(r.success_override_count or 0),
int(r.warning_count or 0),
int(r.failed_count or 0),
int(r.missed_count or 0),
round(float(r.success_rate or 0.0), 2),
])
filename = f"report-{report_id}-summary.csv"
else:
writer.writerow([
"object_name",
"customer_id",
"customer_name",
"job_id",
"job_name",
"backup_software",
"backup_type",
"run_id",
"run_at",
"status",
"missed",
"override_applied",
"reviewed_at",
"ticket_number",
"remark",
])
rows = (
db.session.query(ReportObjectSnapshot)
.filter(ReportObjectSnapshot.report_id == report_id)
.order_by(ReportObjectSnapshot.object_name.asc(), ReportObjectSnapshot.run_at.asc())
.all()
)
for r in rows:
writer.writerow([
r.object_name or "",
r.customer_id or "",
r.customer_name or "",
r.job_id or "",
r.job_name or "",
r.backup_software or "",
r.backup_type or "",
r.run_id or "",
r.run_at.isoformat() if r.run_at else "",
r.status or "",
"1" if r.missed else "0",
"1" if r.override_applied else "0",
r.reviewed_at.isoformat() if r.reviewed_at else "",
r.ticket_number or "",
(r.remark or "").replace("\r", " ").replace("\n", " ").strip(),
])
filename = f"report-{report_id}-snapshot.csv"
csv_bytes = output.getvalue().encode("utf-8") return _export_pdf_response(report, report_id, view)
mem = io.BytesIO(csv_bytes)
mem.seek(0)
return send_file(mem, mimetype="text/csv", as_attachment=True, download_name=filename) @main_bp.route("/api/reports/<int:report_id>/export.csv", methods=["GET"])
@login_required
def api_reports_export_csv(report_id: int):
# Backward compatible route: always returns CSV.
err = _require_reporting_role()
if err is not None:
return err
report = ReportDefinition.query.get_or_404(report_id)
view = (request.args.get("view") or "summary").strip().lower()
return _export_csv_response(report, report_id, view)

View File

@ -53,7 +53,7 @@
<a class="btn btn-sm btn-outline-secondary" href="{{ url_for('main.reports_edit', report_id=item.id) }}">Edit</a> <a class="btn btn-sm btn-outline-secondary" href="{{ url_for('main.reports_edit', report_id=item.id) }}">Edit</a>
<button type="button" class="btn btn-sm btn-outline-primary rep-generate-btn" data-id="{{ item.id }}">Generate</button> <button type="button" class="btn btn-sm btn-outline-primary rep-generate-btn" data-id="{{ item.id }}">Generate</button>
<button type="button" class="btn btn-sm btn-outline-secondary ms-1 rep-view-btn" data-id="{{ item.id }}">View raw</button> <button type="button" class="btn btn-sm btn-outline-secondary ms-1 rep-view-btn" data-id="{{ item.id }}">View raw</button>
<a class="btn btn-sm btn-outline-success rep-download-btn ms-1" href="/api/reports/{{ item.id }}/export.csv" target="_blank" rel="noopener">Download</a> <a class="btn btn-sm btn-outline-success rep-download-btn ms-1" href="/api/reports/{{ item.id }}/export" target="_blank" rel="noopener">Download</a>
{% if active_role in ('admin','operator','reporter') %} {% if active_role in ('admin','operator','reporter') %}
<button type="button" class="btn btn-sm btn-outline-danger rep-delete-btn ms-1" data-id="{{ item.id }}">Delete</button> <button type="button" class="btn btn-sm btn-outline-danger rep-delete-btn ms-1" data-id="{{ item.id }}">Delete</button>
{% endif %} {% endif %}
@ -281,7 +281,7 @@
return; return;
} }
btn.classList.remove('disabled'); btn.classList.remove('disabled');
btn.setAttribute('href', '/api/reports/' + rawReportId + '/export.csv?view=' + rawView); btn.setAttribute('href', '/api/reports/' + rawReportId + '/export?view=' + rawView);
} }
function updateRawMeta(total) { function updateRawMeta(total) {
@ -394,7 +394,7 @@ function loadRawData() {
'<a class="btn btn-sm btn-outline-secondary me-1" href="/reports/' + item.id + '/edit">Edit</a>' + '<a class="btn btn-sm btn-outline-secondary me-1" href="/reports/' + item.id + '/edit">Edit</a>' +
'<button type="button" class="btn btn-sm btn-outline-primary me-1 rep-generate-btn" data-id="' + item.id + '">Generate</button>' + '<button type="button" class="btn btn-sm btn-outline-primary me-1 rep-generate-btn" data-id="' + item.id + '">Generate</button>' +
'<button type="button" class="btn btn-sm btn-outline-secondary me-1 rep-view-btn" data-id="' + item.id + '">View raw</button>' + '<button type="button" class="btn btn-sm btn-outline-secondary me-1 rep-view-btn" data-id="' + item.id + '">View raw</button>' +
'<a class="btn btn-sm btn-outline-success rep-download-btn" href="/api/reports/' + item.id + '/export.csv" target="_blank" rel="noopener">Download</a>' + '<a class="btn btn-sm btn-outline-success rep-download-btn" href="/api/reports/' + item.id + '/export" target="_blank" rel="noopener">Download</a>' +
(canDeleteReports ? '<button type="button" class="btn btn-sm btn-outline-danger ms-1 rep-delete-btn" data-id="' + item.id + '">Delete</button>' : '') + (canDeleteReports ? '<button type="button" class="btn btn-sm btn-outline-danger ms-1 rep-delete-btn" data-id="' + item.id + '">Delete</button>' : '') +
'</td>';; '</td>';;

View File

@ -30,8 +30,9 @@
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<label class="form-label">Output format</label> <label class="form-label">Output format</label>
<select class="form-select" id="rep_output_format"> <select class="form-select" id="rep_output_format">
<option value="csv" selected>CSV</option> <option value="csv">CSV</option>
<option value="pdf" disabled>PDF (coming soon)</option> <option value="html">HTML</option>
<option value="pdf">PDF</option>
</select> </select>
</div> </div>

View File

@ -161,6 +161,17 @@
- Updated the report selection UI to make the “Backup software” and “Backup type” select boxes equal in size. - Updated the report selection UI to make the “Backup software” and “Backup type” select boxes equal in size.
- Both select boxes are now displayed with a fixed height of 6 visible rows for consistent layout and improved usability. - Both select boxes are now displayed with a fixed height of 6 visible rows for consistent layout and improved usability.
---
## v20260103-19-reports-output-format-html
- Added "HTML" as an output format option next to CSV and PDF in the report definition form.
- Enabled generic report export to support CSV, HTML and PDF outputs.
- Implemented an HTML export preview with a graphical layout (summary cards + charts) to validate report styling.
- Implemented a basic PDF export with summary and a simple success-rate trend chart.
- Updated the Reports overview to use the new generic export endpoint (no longer hardcoded to export.csv).
- Added ReportLab dependency for PDF generation.
================================================================================================================================================ ================================================================================================================================================