Auto-commit local changes before build (2026-01-03 22:57:17)
This commit is contained in:
parent
b0de64c9fd
commit
22a2e7146b
@ -1 +1 @@
|
||||
v20260103-18-reports-job-filter-selectbox-size-6-rows
|
||||
v20260103-19-reports-output-format-html
|
||||
|
||||
@ -6,3 +6,4 @@ psycopg2-binary==2.9.9
|
||||
python-dateutil==2.9.0.post0
|
||||
gunicorn==23.0.0
|
||||
requests==2.32.3
|
||||
reportlab==4.2.5
|
||||
|
||||
@ -675,75 +675,48 @@ def _normalize_status_row(status: str, missed: bool) -> str:
|
||||
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)
|
||||
|
||||
# If the report hasn't been generated yet, these tables can be empty.
|
||||
# Return empty-but-valid structures so the UI can render deterministically.
|
||||
engine = db.get_engine()
|
||||
|
||||
with engine.begin() as conn:
|
||||
# KPI (runs)
|
||||
kpi = conn.execute(
|
||||
with db.engine.connect() as conn:
|
||||
status_rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*)::INTEGER AS total_runs,
|
||||
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 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 '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
|
||||
COALESCE(status,'') AS status,
|
||||
missed AS missed,
|
||||
COUNT(*)::INTEGER AS cnt
|
||||
FROM report_object_snapshots
|
||||
WHERE report_id = :rid
|
||||
GROUP BY 1, 2
|
||||
"""
|
||||
),
|
||||
{"rid": report_id},
|
||||
).fetchone()
|
||||
).fetchall()
|
||||
|
||||
total_runs = int(kpi.total_runs or 0) if kpi else 0
|
||||
success_runs = int(kpi.success_runs or 0) if kpi else 0
|
||||
success_override_runs = int(kpi.success_override_runs or 0) if kpi else 0
|
||||
warning_runs = int(kpi.warning_runs or 0) if kpi else 0
|
||||
failed_runs = int(kpi.failed_runs or 0) if kpi else 0
|
||||
missed_runs = int(kpi.missed_runs or 0) if kpi else 0
|
||||
total_jobs = int(kpi.total_jobs or 0) if kpi else 0
|
||||
status_distribution = {
|
||||
"success": 0,
|
||||
"warning": 0,
|
||||
"failed": 0,
|
||||
"missed": 0,
|
||||
"unknown": 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
|
||||
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.
|
||||
# Success rate trend over time (daily)
|
||||
trend_rows = conn.execute(
|
||||
text(
|
||||
"""
|
||||
SELECT
|
||||
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 (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 'fail%' AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS failed_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 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(*)::INTEGER AS total_runs
|
||||
FROM report_object_snapshots
|
||||
@ -759,13 +732,13 @@ def api_reports_stats(report_id: int):
|
||||
trends = []
|
||||
for tr in trend_rows or []:
|
||||
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
|
||||
if day_total > 0:
|
||||
day_rate = (day_success / float(day_total)) * 100.0
|
||||
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),
|
||||
"failed_runs": int(tr.failed_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 = {
|
||||
"avg_runtime_seconds": None,
|
||||
"top_jobs_by_runtime": [],
|
||||
"top_jobs_by_data": [],
|
||||
# Performance by customer (for summary view)
|
||||
perf_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()
|
||||
|
||||
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 {
|
||||
"report": {
|
||||
"id": report.id,
|
||||
"name": report.name,
|
||||
"report_type": report.report_type,
|
||||
"output_format": report.output_format,
|
||||
"period_start": report.period_start.isoformat() if report.period_start else "",
|
||||
"period_end": report.period_end.isoformat() if report.period_end else "",
|
||||
"kpis": {
|
||||
"total_jobs": total_jobs,
|
||||
"total_runs": total_runs,
|
||||
"success_runs": success_runs + success_override_runs,
|
||||
"warning_runs": warning_runs,
|
||||
"failed_runs": failed_runs,
|
||||
"missed_runs": missed_runs,
|
||||
"success_rate": success_rate,
|
||||
"created_at": report.created_at.isoformat() if report.created_at else "",
|
||||
},
|
||||
"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": {
|
||||
"status_distribution": status_distribution,
|
||||
@ -801,59 +834,102 @@ 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
|
||||
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
|
||||
|
||||
report = ReportDefinition.query.get_or_404(report_id)
|
||||
view = (request.args.get("view") or "summary").strip().lower()
|
||||
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)
|
||||
|
||||
if view == "summary":
|
||||
writer.writerow([
|
||||
"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:
|
||||
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.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),
|
||||
total_runs,
|
||||
success_count,
|
||||
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),
|
||||
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_id",
|
||||
"customer_name",
|
||||
"job_id",
|
||||
"job_name",
|
||||
@ -868,16 +944,9 @@ def api_reports_export_csv(report_id: int):
|
||||
"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:
|
||||
for r in rows or []:
|
||||
writer.writerow([
|
||||
r.object_name or "",
|
||||
r.customer_id or "",
|
||||
r.customer_name or "",
|
||||
r.job_id or "",
|
||||
r.job_name or "",
|
||||
@ -898,3 +967,421 @@ def api_reports_export_csv(report_id: int):
|
||||
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("&", "&").replace("<", "<").replace(">", ">")
|
||||
|
||||
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()
|
||||
if err is not None:
|
||||
return err
|
||||
|
||||
report = ReportDefinition.query.get_or_404(report_id)
|
||||
view = (request.args.get("view") or "summary").strip().lower()
|
||||
fmt = (request.args.get("format") or report.output_format or "csv").strip().lower()
|
||||
|
||||
if fmt not in ("csv", "html", "pdf"):
|
||||
fmt = "csv"
|
||||
|
||||
if fmt == "csv":
|
||||
return _export_csv_response(report, report_id, view)
|
||||
|
||||
if fmt == "html":
|
||||
return _export_html_response(report, report_id, view)
|
||||
|
||||
return _export_pdf_response(report, report_id, view)
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
@ -53,7 +53,7 @@
|
||||
<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-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') %}
|
||||
<button type="button" class="btn btn-sm btn-outline-danger rep-delete-btn ms-1" data-id="{{ item.id }}">Delete</button>
|
||||
{% endif %}
|
||||
@ -281,7 +281,7 @@
|
||||
return;
|
||||
}
|
||||
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) {
|
||||
@ -394,7 +394,7 @@ function loadRawData() {
|
||||
'<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-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>' : '') +
|
||||
'</td>';;
|
||||
|
||||
|
||||
@ -30,8 +30,9 @@
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-label">Output format</label>
|
||||
<select class="form-select" id="rep_output_format">
|
||||
<option value="csv" selected>CSV</option>
|
||||
<option value="pdf" disabled>PDF (coming soon)</option>
|
||||
<option value="csv">CSV</option>
|
||||
<option value="html">HTML</option>
|
||||
<option value="pdf">PDF</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
|
||||
@ -161,6 +161,17 @@
|
||||
- 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.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
|
||||
================================================================================================================================================
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user