|
|
|
|
@ -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 {
|
|
|
|
|
"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,
|
|
|
|
|
"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 "",
|
|
|
|
|
"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,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
|
|
|
|
|
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("&", "&").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 view not in ("summary", "snapshot"):
|
|
|
|
|
view = "summary"
|
|
|
|
|
if fmt not in ("csv", "html", "pdf"):
|
|
|
|
|
fmt = "csv"
|
|
|
|
|
|
|
|
|
|
output = io.StringIO()
|
|
|
|
|
writer = csv.writer(output)
|
|
|
|
|
if fmt == "csv":
|
|
|
|
|
return _export_csv_response(report, report_id, view)
|
|
|
|
|
|
|
|
|
|
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:
|
|
|
|
|
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"
|
|
|
|
|
if fmt == "html":
|
|
|
|
|
return _export_html_response(report, report_id, view)
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
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)
|
|
|
|
|
|