|
|
|
|
@ -223,22 +223,23 @@ def build_report_columns_meta():
|
|
|
|
|
],
|
|
|
|
|
"jobs": [
|
|
|
|
|
"object_name",
|
|
|
|
|
"customer_name",
|
|
|
|
|
"job_name",
|
|
|
|
|
"backup_software",
|
|
|
|
|
"backup_type",
|
|
|
|
|
"run_at",
|
|
|
|
|
"status",
|
|
|
|
|
"missed",
|
|
|
|
|
"override_applied",
|
|
|
|
|
"ticket_number",
|
|
|
|
|
"remark",
|
|
|
|
|
"total_runs",
|
|
|
|
|
"success_count",
|
|
|
|
|
"warning_count",
|
|
|
|
|
"failed_count",
|
|
|
|
|
"missed_count",
|
|
|
|
|
"success_rate",
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
"groups": [
|
|
|
|
|
{
|
|
|
|
|
"name": "Summary metrics",
|
|
|
|
|
"items": [
|
|
|
|
|
{"key": "object_name", "label": "Object", "views": ["summary"]},
|
|
|
|
|
{"key": "object_name", "label": "Text", "views": ["summary"]},
|
|
|
|
|
{"key": "total_runs", "label": "Total runs", "views": ["summary"]},
|
|
|
|
|
{"key": "success_count", "label": "Success", "views": ["summary"]},
|
|
|
|
|
{"key": "success_override_count", "label": "Success (override)", "views": ["summary"]},
|
|
|
|
|
@ -248,6 +249,18 @@ def build_report_columns_meta():
|
|
|
|
|
{"key": "success_rate", "label": "Success rate", "views": ["summary"]},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "Job summary",
|
|
|
|
|
"items": [
|
|
|
|
|
{"key": "total_runs", "label": "Total runs", "views": ["jobs"]},
|
|
|
|
|
{"key": "success_count", "label": "Success", "views": ["jobs"]},
|
|
|
|
|
{"key": "success_override_count", "label": "Success (override)", "views": ["jobs"]},
|
|
|
|
|
{"key": "warning_count", "label": "Warning", "views": ["jobs"]},
|
|
|
|
|
{"key": "failed_count", "label": "Failed", "views": ["jobs"]},
|
|
|
|
|
{"key": "missed_count", "label": "Missed", "views": ["jobs"]},
|
|
|
|
|
{"key": "success_rate", "label": "Success rate", "views": ["jobs"]},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "Job Information",
|
|
|
|
|
"items": [
|
|
|
|
|
@ -277,9 +290,9 @@ def build_report_columns_meta():
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
"name": "Object",
|
|
|
|
|
"name": "Text",
|
|
|
|
|
"items": [
|
|
|
|
|
{"key": "object_name", "label": "Object", "views": ["snapshot", "summary", "jobs"]},
|
|
|
|
|
{"key": "object_name", "label": "Text", "views": ["snapshot", "summary", "jobs"]},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
@ -614,7 +627,7 @@ def api_reports_data(report_id: int):
|
|
|
|
|
include_keys = _get_success_rate_keys_from_report(report)
|
|
|
|
|
|
|
|
|
|
view = (request.args.get("view") or "summary").strip().lower()
|
|
|
|
|
if view not in ("summary", "snapshot"):
|
|
|
|
|
if view not in ("summary", "snapshot", "jobs"):
|
|
|
|
|
view = "summary"
|
|
|
|
|
|
|
|
|
|
limit = _clamp_int(request.args.get("limit"), default=100, min_v=1, max_v=500)
|
|
|
|
|
@ -660,6 +673,86 @@ def api_reports_data(report_id: int):
|
|
|
|
|
],
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if view == "jobs":
|
|
|
|
|
# Aggregated per-job summary over the reporting period (not latest snapshot).
|
|
|
|
|
include_keys_jobs = _get_success_rate_keys_from_report(report, view_key="jobs")
|
|
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
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 (override_applied = TRUE AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_override_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,2,3,4,5
|
|
|
|
|
ORDER BY customer_name ASC, object_name ASC, job_name ASC
|
|
|
|
|
LIMIT :limit
|
|
|
|
|
OFFSET :offset
|
|
|
|
|
"""
|
|
|
|
|
),
|
|
|
|
|
{"rid": report_id, "limit": limit, "offset": offset},
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
|
|
|
|
total_rows = conn.execute(
|
|
|
|
|
text(
|
|
|
|
|
"""
|
|
|
|
|
SELECT COUNT(*)::INTEGER AS cnt FROM (
|
|
|
|
|
SELECT 1
|
|
|
|
|
FROM report_object_snapshots
|
|
|
|
|
WHERE report_id = :rid
|
|
|
|
|
GROUP BY COALESCE(object_name,''), COALESCE(customer_name,''), COALESCE(job_name,''), COALESCE(backup_software,''), COALESCE(backup_type,'')
|
|
|
|
|
) x
|
|
|
|
|
"""
|
|
|
|
|
),
|
|
|
|
|
{"rid": report_id},
|
|
|
|
|
).scalar() or 0
|
|
|
|
|
|
|
|
|
|
items = []
|
|
|
|
|
for r in rows or []:
|
|
|
|
|
rate = _compute_success_rate(
|
|
|
|
|
success=int(r.success_count or 0),
|
|
|
|
|
warning=int(r.warning_count or 0),
|
|
|
|
|
failed=int(r.failed_count or 0),
|
|
|
|
|
missed=int(r.missed_count or 0),
|
|
|
|
|
include_keys=include_keys_jobs,
|
|
|
|
|
)
|
|
|
|
|
items.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 "",
|
|
|
|
|
"total_runs": int(r.total_runs or 0),
|
|
|
|
|
"success_count": int(r.success_count or 0),
|
|
|
|
|
"success_override_count": int(r.success_override_count or 0),
|
|
|
|
|
"warning_count": int(r.warning_count or 0),
|
|
|
|
|
"failed_count": int(r.failed_count or 0),
|
|
|
|
|
"missed_count": int(r.missed_count or 0),
|
|
|
|
|
"success_rate": round(rate, 2),
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
"view": "jobs",
|
|
|
|
|
"total": int(total_rows),
|
|
|
|
|
"limit": int(limit),
|
|
|
|
|
"offset": int(offset),
|
|
|
|
|
"items": items,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
q = db.session.query(ReportObjectSnapshot).filter(ReportObjectSnapshot.report_id == report_id)
|
|
|
|
|
total = q.count()
|
|
|
|
|
rows = (
|
|
|
|
|
@ -709,7 +802,7 @@ def _normalize_status_row(status: str, missed: bool) -> str:
|
|
|
|
|
return "unknown"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _get_success_rate_keys_from_report(report: ReportDefinition) -> set[str]:
|
|
|
|
|
def _get_success_rate_keys_from_report(report: ReportDefinition, view_key: str = "summary") -> set[str]:
|
|
|
|
|
"""Return which status count keys should contribute to success rate.
|
|
|
|
|
|
|
|
|
|
The report UI allows choosing summary columns. Success rate should be
|
|
|
|
|
@ -719,11 +812,11 @@ def _get_success_rate_keys_from_report(report: ReportDefinition) -> set[str]:
|
|
|
|
|
|
|
|
|
|
rc = _safe_json_dict(getattr(report, "report_config", None))
|
|
|
|
|
cols = (rc.get("columns") or {}) if isinstance(rc, dict) else {}
|
|
|
|
|
summary_cols = cols.get("summary") if isinstance(cols, dict) else None
|
|
|
|
|
view_cols = cols.get(view_key) if isinstance(cols, dict) else None
|
|
|
|
|
|
|
|
|
|
selected = set()
|
|
|
|
|
if isinstance(summary_cols, list):
|
|
|
|
|
for k in summary_cols:
|
|
|
|
|
if isinstance(view_cols, list):
|
|
|
|
|
for k in view_cols:
|
|
|
|
|
if not isinstance(k, str):
|
|
|
|
|
continue
|
|
|
|
|
kk = k.strip()
|
|
|
|
|
@ -957,7 +1050,7 @@ def api_reports_stats(report_id: int):
|
|
|
|
|
|
|
|
|
|
def _export_csv_response(report: ReportDefinition, report_id: int, view: str):
|
|
|
|
|
view = (view or "summary").strip().lower()
|
|
|
|
|
if view not in ("summary", "snapshot"):
|
|
|
|
|
if view not in ("summary", "snapshot", "jobs"):
|
|
|
|
|
view = "summary"
|
|
|
|
|
|
|
|
|
|
include_keys = _get_success_rate_keys_from_report(report)
|
|
|
|
|
@ -1182,33 +1275,32 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
|
|
|
|
|
want_snapshot_table = (view or "summary").strip().lower() == "snapshot"
|
|
|
|
|
|
|
|
|
|
jobs_table_html = ""
|
|
|
|
|
jobs_chart_html = ""
|
|
|
|
|
if include_jobs and not want_snapshot_table:
|
|
|
|
|
# Job table (latest snapshot per job/object) for a single-customer report.
|
|
|
|
|
# Job table (aggregated per job) for a single-customer report.
|
|
|
|
|
include_keys_jobs = _get_success_rate_keys_from_report(report, view_key="jobs")
|
|
|
|
|
|
|
|
|
|
jobs_rows = []
|
|
|
|
|
with db.engine.connect() as conn:
|
|
|
|
|
rows = conn.execute(
|
|
|
|
|
text(
|
|
|
|
|
"""
|
|
|
|
|
SELECT DISTINCT ON (COALESCE(object_name,''), COALESCE(job_name,''), COALESCE(backup_software,''), COALESCE(backup_type,''))
|
|
|
|
|
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
|
|
|
|
|
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 (override_applied = TRUE AND missed = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_override_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
|
|
|
|
|
ORDER BY
|
|
|
|
|
COALESCE(object_name,''),
|
|
|
|
|
COALESCE(job_name,''),
|
|
|
|
|
COALESCE(backup_software,''),
|
|
|
|
|
COALESCE(backup_type,''),
|
|
|
|
|
run_at DESC NULLS LAST
|
|
|
|
|
GROUP BY 1,2,3,4,5
|
|
|
|
|
ORDER BY customer_name ASC, object_name ASC, job_name ASC
|
|
|
|
|
LIMIT 1000
|
|
|
|
|
"""
|
|
|
|
|
),
|
|
|
|
|
@ -1216,6 +1308,13 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
|
|
|
|
|
).fetchall()
|
|
|
|
|
|
|
|
|
|
for r in rows or []:
|
|
|
|
|
rate = _compute_success_rate(
|
|
|
|
|
success=int(r.success_count or 0),
|
|
|
|
|
warning=int(r.warning_count or 0),
|
|
|
|
|
failed=int(r.failed_count or 0),
|
|
|
|
|
missed=int(r.missed_count or 0),
|
|
|
|
|
include_keys=include_keys_jobs,
|
|
|
|
|
)
|
|
|
|
|
jobs_rows.append(
|
|
|
|
|
{
|
|
|
|
|
"object_name": r.object_name or "",
|
|
|
|
|
@ -1223,12 +1322,13 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
|
|
|
|
|
"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(),
|
|
|
|
|
"total_runs": str(int(r.total_runs or 0)),
|
|
|
|
|
"success_count": str(int(r.success_count or 0)),
|
|
|
|
|
"success_override_count": str(int(r.success_override_count or 0)),
|
|
|
|
|
"warning_count": str(int(r.warning_count or 0)),
|
|
|
|
|
"failed_count": str(int(r.failed_count or 0)),
|
|
|
|
|
"missed_count": str(int(r.missed_count or 0)),
|
|
|
|
|
"success_rate": f"{round(rate, 2)}%",
|
|
|
|
|
}
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
@ -1239,7 +1339,7 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
|
|
|
|
|
<div class="card shadow-sm">
|
|
|
|
|
<div class="card-header bg-white">
|
|
|
|
|
<div class="fw-semibold">Jobs</div>
|
|
|
|
|
<div class="small-muted">Latest snapshot per job</div>
|
|
|
|
|
<div class="small-muted">Aggregated per job over the report period</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<div class="table-responsive">
|
|
|
|
|
@ -1256,6 +1356,72 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
|
|
|
|
|
</div>
|
|
|
|
|
</div>"""
|
|
|
|
|
|
|
|
|
|
# Per-job success rate chart (top 20 by total runs)
|
|
|
|
|
chart_rows = []
|
|
|
|
|
for it in jobs_rows:
|
|
|
|
|
try:
|
|
|
|
|
tr = int(it.get("total_runs") or 0)
|
|
|
|
|
except Exception:
|
|
|
|
|
tr = 0
|
|
|
|
|
try:
|
|
|
|
|
sr = float(str(it.get("success_rate") or "0").replace("%", ""))
|
|
|
|
|
except Exception:
|
|
|
|
|
sr = 0.0
|
|
|
|
|
label = (it.get("job_name") or it.get("object_name") or "(job)").strip()
|
|
|
|
|
chart_rows.append((tr, sr, label))
|
|
|
|
|
|
|
|
|
|
chart_rows.sort(key=lambda x: (-x[0], x[2]))
|
|
|
|
|
chart_rows = chart_rows[:20]
|
|
|
|
|
|
|
|
|
|
job_labels = [c[2] for c in chart_rows]
|
|
|
|
|
job_rates = [round(float(c[1]), 2) for c in chart_rows]
|
|
|
|
|
|
|
|
|
|
jobs_chart_html = f"""
|
|
|
|
|
<div class="row g-3 mb-3">
|
|
|
|
|
<div class="col-12">
|
|
|
|
|
<div class="card shadow-sm">
|
|
|
|
|
<div class="card-header bg-white">
|
|
|
|
|
<div class="fw-semibold">Success rate per job</div>
|
|
|
|
|
<div class="small-muted">Top {len(job_labels)} jobs by total runs</div>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="card-body">
|
|
|
|
|
<canvas id="jobSuccessRateChart" height="120"></canvas>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
<script>
|
|
|
|
|
(function() {{
|
|
|
|
|
const labels = {_json.dumps(job_labels)};
|
|
|
|
|
const data = {_json.dumps(job_rates)};
|
|
|
|
|
const ctx = document.getElementById('jobSuccessRateChart');
|
|
|
|
|
if (!ctx) return;
|
|
|
|
|
new Chart(ctx, {{
|
|
|
|
|
type: 'bar',
|
|
|
|
|
data: {{
|
|
|
|
|
labels: labels,
|
|
|
|
|
datasets: [{{
|
|
|
|
|
label: 'Success rate (%)',
|
|
|
|
|
data: data,
|
|
|
|
|
}}]
|
|
|
|
|
}},
|
|
|
|
|
options: {{
|
|
|
|
|
responsive: true,
|
|
|
|
|
plugins: {{
|
|
|
|
|
legend: {{ display: false }}
|
|
|
|
|
}},
|
|
|
|
|
scales: {{
|
|
|
|
|
y: {{
|
|
|
|
|
beginAtZero: true,
|
|
|
|
|
max: 100
|
|
|
|
|
}}
|
|
|
|
|
}}
|
|
|
|
|
}}
|
|
|
|
|
}});
|
|
|
|
|
}})();
|
|
|
|
|
</script>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
snapshot_table_html = ""
|
|
|
|
|
if want_snapshot_table:
|
|
|
|
|
snap_rows = []
|
|
|
|
|
@ -1463,6 +1629,8 @@ def _export_html_response(report: ReportDefinition, report_id: int, view: str):
|
|
|
|
|
|
|
|
|
|
{perf_table_html}
|
|
|
|
|
|
|
|
|
|
{jobs_chart_html}
|
|
|
|
|
|
|
|
|
|
{jobs_table_html}
|
|
|
|
|
|
|
|
|
|
{snapshot_table_html}
|
|
|
|
|
|