diff --git a/.last-branch b/.last-branch index 92ab6e6..37742c9 100644 --- a/.last-branch +++ b/.last-branch @@ -1 +1 @@ -v20260103-12-reports-columns-selector-init-fix +v20260104-14-reports-stats-total-runs-success-rate-fix diff --git a/containers/backupchecks/requirements.txt b/containers/backupchecks/requirements.txt index 102ffd8..5db362e 100644 --- a/containers/backupchecks/requirements.txt +++ b/containers/backupchecks/requirements.txt @@ -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 diff --git a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py index 73428ee..1f1035e 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reporting_api.py +++ b/containers/backupchecks/src/backend/app/main/routes_reporting_api.py @@ -139,9 +139,12 @@ def api_reports_create(): name = (payload.get("name") or "").strip() or "Report" description = (payload.get("description") or "").strip() or None report_type = (payload.get("report_type") or "one-time").strip() or "one-time" - output_format = (payload.get("output_format") or "csv").strip() or "csv" + output_format = (payload.get("output_format") or "csv").strip().lower() or "csv" schedule = (payload.get("schedule") or "").strip() or None + if output_format not in ("csv", "html", "pdf"): + return {"error": "Invalid output_format. Use csv, html, or pdf."}, 400 + report_config = payload.get("report_config") if report_config is not None and not isinstance(report_config, (dict, list, str)): report_config = None @@ -189,10 +192,12 @@ def build_report_columns_meta(): "views": [ {"key": "summary", "label": "Summary"}, {"key": "snapshot", "label": "Snapshot"}, + {"key": "jobs", "label": "Jobs"}, ], "defaults": { "summary": [ "object_name", + "customer_name", "total_runs", "success_count", "warning_count", @@ -216,12 +221,23 @@ def build_report_columns_meta(): "remark", "reviewed_at", ], + "jobs": [ + "customer_name", + "job_name", + "backup_software", + "backup_type", + "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": "total_runs", "label": "Total runs", "views": ["summary"]}, {"key": "success_count", "label": "Success", "views": ["summary"]}, {"key": "success_override_count", "label": "Success (override)", "views": ["summary"]}, @@ -231,38 +247,50 @@ 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": [ - {"key": "job_name", "label": "Job name", "views": ["snapshot"]}, + # NOTE: object_name is currently populated with the job name in the reporting snapshot + # generator. Keep the key stable (report_config stores keys) but present it as "Job name" + # so it can be toggled like other columns. + {"key": "object_name", "label": "Job name", "views": ["summary", "snapshot", "jobs"]}, + {"key": "job_name", "label": "Job name", "views": ["snapshot", "jobs"]}, {"key": "job_id", "label": "Job ID", "views": ["snapshot"]}, - {"key": "backup_software", "label": "Job type", "views": ["snapshot"]}, - {"key": "backup_type", "label": "Repository / Target", "views": ["snapshot"]}, - {"key": "customer_name", "label": "Customer", "views": ["snapshot"]}, + {"key": "backup_software", "label": "Software", "views": ["snapshot", "jobs"]}, + {"key": "backup_type", "label": "Type", "views": ["snapshot", "jobs"]}, + {"key": "customer_name", "label": "Customer", "views": ["snapshot", "summary", "jobs"]}, ], }, { "name": "Status", "items": [ - {"key": "status", "label": "Last run status", "views": ["snapshot"]}, - {"key": "missed", "label": "Missed", "views": ["snapshot"]}, - {"key": "override_applied", "label": "Override applied", "views": ["snapshot"]}, - {"key": "ticket_number", "label": "Ticket number", "views": ["snapshot"]}, + {"key": "status", "label": "Status", "views": ["snapshot", "jobs"]}, + {"key": "missed", "label": "Missed", "views": ["snapshot", "jobs"]}, + {"key": "override_applied", "label": "Override", "views": ["snapshot", "jobs"]}, + {"key": "run_id", "label": "Run ID", "views": ["snapshot"]}, + {"key": "ticket_number", "label": "Ticket", "views": ["snapshot", "jobs"]}, + {"key": "remark", "label": "Remark", "views": ["snapshot", "jobs"]}, ], }, { "name": "Time", "items": [ - {"key": "run_at", "label": "Start time", "views": ["snapshot"]}, + {"key": "run_at", "label": "Last run", "views": ["snapshot", "jobs"]}, {"key": "reviewed_at", "label": "Reviewed at", "views": ["snapshot"]}, ], }, - { - "name": "Object", - "items": [ - {"key": "object_name", "label": "Object name", "views": ["snapshot", "summary"]}, - ], - }, { "name": "Summary counts", "items": [ @@ -299,6 +327,65 @@ def build_report_columns_meta(): ], } + +def build_report_job_filters_meta(): + """Build job filter metadata for reporting UIs. + + Provides available backup_softwares and backup_types derived from active jobs. + """ + # Distinct values across active jobs (exclude known informational jobs). + info_backup_types = {"license key"} + + rows = ( + db.session.query(Job.backup_software, Job.backup_type) + .filter(Job.active.is_(True)) + .all() + ) + + backup_softwares_set = set() + backup_types_set = set() + by_backup_software = {} + + for bs, bt in rows: + bs_val = (bs or "").strip() + bt_val = (bt or "").strip() + if bt_val.lower() in info_backup_types: + continue + + if bs_val: + backup_softwares_set.add(bs_val) + if bt_val: + backup_types_set.add(bt_val) + + if bs_val and bt_val: + by_backup_software.setdefault(bs_val, set()).add(bt_val) + + def _sort_key(v: str): + return (v or "").casefold() + + backup_softwares = [{"key": v, "label": v} for v in sorted(backup_softwares_set, key=_sort_key)] + backup_types = [{"key": v, "label": v} for v in sorted(backup_types_set, key=_sort_key)] + by_backup_software_out = { + k: sorted(list(vset), key=_sort_key) for k, vset in sorted(by_backup_software.items(), key=lambda kv: _sort_key(kv[0])) + } + + return { + "backup_softwares": backup_softwares, + "backup_types": backup_types, + "by_backup_software": by_backup_software_out, + "excluded_backup_types": ["License Key"], + } + + +@main_bp.route("/api/reports/job-filters", methods=["GET"]) +@login_required +def api_reports_job_filters(): + err = _require_reporting_role() + if err is not None: + return err + return build_report_job_filters_meta() + + @main_bp.route("/api/reports/columns", methods=["GET"]) @login_required def api_reports_columns(): @@ -344,6 +431,7 @@ def api_reports_update(report_id: int): - name - description - report_config + - output_format """ err = _require_reporting_role() if err is not None: @@ -358,6 +446,13 @@ def api_reports_update(report_id: int): desc = (payload.get("description") or "").strip() report.description = desc or None + if "output_format" in payload: + fmt = (payload.get("output_format") or "").strip().lower() + if fmt: + if fmt not in ("csv", "html", "pdf"): + return {"error": "Invalid output_format. Use csv, html, or pdf."}, 400 + report.output_format = fmt + if "report_config" in payload: rc = payload.get("report_config") if rc is None: @@ -400,6 +495,26 @@ def api_reports_generate(report_id: int): # run_object_links.run_id -> job_runs -> jobs where_customer = "" params = {"rid": report_id, "start_ts": report.period_start, "end_ts": report.period_end} + # Job filters from report_config + where_filters = " AND COALESCE(j.backup_type,'') NOT ILIKE 'license key' " + rc = _safe_json_dict(getattr(report, "report_config", None)) + filters = rc.get("filters") if isinstance(rc, dict) else None + if isinstance(filters, dict): + sel_sw = filters.get("backup_softwares") + sel_bt = filters.get("backup_types") + if isinstance(sel_sw, list): + sel_sw = [str(v).strip() for v in sel_sw if str(v).strip()] + if sel_sw: + where_filters += " AND j.backup_software = ANY(:sel_backup_softwares) " + params["sel_backup_softwares"] = sel_sw + if isinstance(sel_bt, list): + sel_bt = [str(v).strip() for v in sel_bt if str(v).strip()] + # Always exclude known informational jobs like License Key. + sel_bt = [v for v in sel_bt if v.lower() != "license key"] + if sel_bt: + where_filters += " AND j.backup_type = ANY(:sel_backup_types) " + params["sel_backup_types"] = sel_bt + if scope in ("single", "multiple") and customer_ids: where_customer = " AND j.customer_id = ANY(:customer_ids) " params["customer_ids"] = customer_ids @@ -436,6 +551,7 @@ def api_reports_generate(report_id: int): AND jr.run_at >= :start_ts AND jr.run_at < :end_ts {where_customer} + {where_filters} ''' ), params, @@ -446,11 +562,14 @@ def api_reports_generate(report_id: int): text( ''' INSERT INTO report_object_summaries - (report_id, object_name, total_runs, success_count, success_override_count, + (report_id, object_name, customer_id, customer_name, + total_runs, success_count, success_override_count, warning_count, failed_count, missed_count, success_rate, created_at) SELECT :rid AS report_id, COALESCE(s.job_name, '(unknown job)') AS object_name, + s.customer_id AS customer_id, + s.customer_name AS customer_name, COUNT(*)::INTEGER AS total_runs, SUM(CASE WHEN (COALESCE(s.status,'') ILIKE 'success%' AND s.override_applied = FALSE) THEN 1 ELSE 0 END)::INTEGER AS success_count, SUM(CASE WHEN (s.override_applied = TRUE) THEN 1 ELSE 0 END)::INTEGER AS success_override_count, @@ -469,7 +588,7 @@ def api_reports_generate(report_id: int): NOW() AS created_at FROM report_object_snapshots s WHERE s.report_id = :rid - GROUP BY s.job_id, s.job_name + GROUP BY s.customer_id, s.customer_name, s.job_id, s.job_name ''' ), {"rid": report_id}, @@ -500,12 +619,15 @@ def api_reports_data(report_id: int): if err is not None: return err - ReportDefinition.query.get_or_404(report_id) + report = ReportDefinition.query.get_or_404(report_id) + include_keys = None view = (request.args.get("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, view_key=view) + limit = _clamp_int(request.args.get("limit"), default=100, min_v=1, max_v=500) offset = _clamp_int(request.args.get("offset"), default=0, min_v=0, max_v=1_000_000) @@ -513,7 +635,7 @@ def api_reports_data(report_id: int): q = db.session.query(ReportObjectSummary).filter(ReportObjectSummary.report_id == report_id) total = q.count() rows = ( - q.order_by(ReportObjectSummary.object_name.asc()) + q.order_by(db.func.coalesce(ReportObjectSummary.customer_name, '').asc(), ReportObjectSummary.object_name.asc()) .offset(offset) .limit(limit) .all() @@ -526,18 +648,109 @@ def api_reports_data(report_id: int): "items": [ { "object_name": r.object_name or "", - "total_runs": int(r.total_runs or 0), + "customer_id": int(r.customer_id) if getattr(r, "customer_id", None) is not None else "", + "customer_name": r.customer_name or "", + "total_runs": _compute_total_runs(int(r.success_count or 0), int(r.warning_count or 0), int(r.failed_count or 0), int(r.missed_count or 0), include_keys), "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": float(r.success_rate or 0.0), + "success_rate": round( + _compute_success_rate( + success=int(r.success_count or 0), + warning=int(r.warning_count or 0), + failed=int(r.failed_count or 0), + missed=int(r.missed_count or 0), + include_keys=include_keys, + ), + 2, + ), } for r in rows ], } + 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": _compute_total_runs(int(r.success_count or 0), int(r.warning_count or 0), int(r.failed_count or 0), int(r.missed_count or 0), include_keys), + "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 = ( @@ -587,74 +800,128 @@ def _normalize_status_row(status: str, missed: bool) -> str: return "unknown" -@main_bp.route("/api/reports//stats", methods=["GET"]) -@login_required -def api_reports_stats(report_id: int): - """Return aggregated KPI + chart data for a report. +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. - Designed to support the "Overview" tab (KPIs + charts) described in the reporting proposal. + The report UI allows choosing summary columns. Success rate should be + calculated based on the selected status columns to let users exclude + noisy counts (e.g. missed runs). """ - err = _require_reporting_role() - if err is not None: - return err + rc = _safe_json_dict(getattr(report, "report_config", None)) + cols = (rc.get("columns") or {}) if isinstance(rc, dict) else {} + view_cols = cols.get(view_key) if isinstance(cols, dict) else None + + # "Configured" means the user explicitly saved a column list for this view. + # This includes the case where they saved an empty list (all columns off). + configured = isinstance(view_cols, list) + + selected = set() + if configured: + for k in view_cols: + if not isinstance(k, str): + continue + kk = k.strip() + if kk: + selected.add(kk) + + # Map selected columns to status keys. + status_keys = {"success_count", "warning_count", "failed_count", "missed_count"} + out = set(k for k in selected if k in status_keys) + + # Always include success_count in the denominator to prevent odd rates. + out.add("success_count") + + # If the user explicitly configured an empty column list for this view, + # default the denominator to the most common "signal" statuses so totals and + # rates remain meaningful (exclude missed unless explicitly selected). + if configured and not selected: + out = {"success_count", "failed_count"} + + # If config is missing for this view, fall back to all status keys. + if not configured and not selected: + out = set(status_keys) + + return out + + + +def _compute_total_runs(success: int, warning: int, failed: int, missed: int, include_keys: set[str]) -> int: + total = 0 + if "success_count" in include_keys: + total += int(success or 0) + if "warning_count" in include_keys: + total += int(warning or 0) + if "failed_count" in include_keys: + total += int(failed or 0) + if "missed_count" in include_keys: + total += int(missed or 0) + return int(total) + + +def _compute_success_rate(success: int, warning: int, failed: int, missed: int, include_keys: set[str]) -> float: + denom = 0 + if "success_count" in include_keys: + denom += int(success or 0) + if "warning_count" in include_keys: + denom += int(warning or 0) + if "failed_count" in include_keys: + denom += int(failed or 0) + if "missed_count" in include_keys: + denom += int(missed or 0) + if denom <= 0: + return 0.0 + return (int(success or 0) / float(denom)) * 100.0 + + + + +def _build_report_stats_payload(report_id: int, view_key: str = "summary") -> 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() + view_key = (view_key or "summary").strip().lower() + if view_key not in ("summary", "snapshot", "jobs"): + view_key = "summary" - with engine.begin() as conn: - # KPI (runs) - kpi = conn.execute( + include_keys = _get_success_rate_keys_from_report(report, view_key=view_key) + + 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 - - # 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 @@ -669,40 +936,123 @@ 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_warning = int(tr.warning_runs or 0) + day_failed = int(tr.failed_runs or 0) + day_missed = int(tr.missed_runs or 0) day_rate = 0.0 - if day_total > 0: - day_rate = (day_success / float(day_total)) * 100.0 + denom = 0 + if "success_count" in include_keys: + denom += day_success + if "warning_count" in include_keys: + denom += day_warning + if "failed_count" in include_keys: + denom += day_failed + if "missed_count" in include_keys: + denom += day_missed + day_total = int(denom or 0) + if denom > 0: + day_rate = (day_success / float(denom)) * 100.0 trends.append( { - "day": tr.day.isoformat() if tr.day else "", - "success_rate": day_rate, - "failed_runs": int(tr.failed_runs or 0), - "warning_runs": int(tr.warning_runs or 0), - "missed_runs": int(tr.missed_runs or 0), + "day": tr.day.date().isoformat() if tr.day else "", + "success_rate": round(day_rate, 2), + "failed_runs": day_failed, + "warning_runs": day_warning, + "missed_runs": day_missed, "total_runs": day_total, } ) - # 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 []: + 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) + + total_runs = _compute_total_runs(success_count, warning_count, failed_count, missed_count, include_keys) + + 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 + rate = _compute_success_rate( + success=success_count, + warning=warning_count, + failed=failed_count, + missed=missed_count, + include_keys=include_keys, + ) + + 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 + overall_rate = _compute_success_rate( + success=totals["success_count"], + warning=totals["warning_count"], + failed=totals["failed_count"], + missed=totals["missed_count"], + include_keys=include_keys, + ) 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, @@ -712,96 +1062,822 @@ def api_reports_stats(report_id: int): } -@main_bp.route("/api/reports//export.csv", methods=["GET"]) + +@main_bp.route("/api/reports//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 + + view = (request.args.get("view") or "summary").strip().lower() + if view not in ("summary", "snapshot", "jobs"): + view = "summary" + + return _build_report_stats_payload(report_id, view_key=view) + + + +def _export_csv_response(report: ReportDefinition, report_id: int, view: str): + view = (view or "summary").strip().lower() + if view not in ("summary", "snapshot", "jobs"): + view = "summary" + + include_keys = _get_success_rate_keys_from_report(report, view_key=view) + + 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 []: + success_count = int(r.success_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) + total_runs = _compute_total_runs(success_count, warning_count, failed_count, missed_count, include_keys) + rate = _compute_success_rate( + success=success_count, + warning=warning_count, + failed=failed_count, + missed=missed_count, + include_keys=include_keys, + ) + writer.writerow([ + r.customer_name or "", + total_runs, + success_count, + warning_count, + failed_count, + missed_count, + 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, view_key=view) + 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(">", ">") + + rc = _safe_json_dict(getattr(report, "report_config", None)) + pres = rc.get("presentation") if isinstance(rc, dict) else None + html_content = None + if isinstance(pres, dict): + html_content = (pres.get("html_content") or "").strip().lower() or None + + # Default behavior: + # - honor selected view: Summary should not show Snapshot rows unless explicitly requested + # - when html_content is not set, pick a sensible default based on scope + view + scope = (getattr(report, "customer_scope", None) or "all").strip().lower() + if not html_content: + # For a single-customer report, the most useful summary is a job list. + # For all-customer reports, the summary defaults to performance by customer. + html_content = "jobs" if scope == "single" else "customers" + + include_customers = html_content in ("customers", "both") + include_jobs = html_content in ("jobs", "both") + + cols_meta = build_report_columns_meta() + label_map: dict[str, str] = {} + allowed_by_view: dict[str, set[str]] = {} + for g in (cols_meta.get("groups") or []): + for it in (g.get("items") or []): + k = (it.get("key") or "").strip() + if not k: + continue + label_map[k] = (it.get("label") or k) + for v in (it.get("views") or []): + allowed_by_view.setdefault(v, set()).add(k) + + def _selected_cols(view_key: str) -> list[str]: + # Only apply defaults when the report config does not define a list for this view. + # If a list is present but empty, the user intentionally selected no columns. + user_provided = False + cols: list[str] | None = None + + rc_cols = rc.get("columns") if isinstance(rc, dict) else None + if isinstance(rc_cols, dict) and view_key in rc_cols: + v = rc_cols.get(view_key) + if isinstance(v, list): + user_provided = True + cols = [str(x).strip() for x in v if str(x).strip()] + + if cols is None: + d = (cols_meta.get("defaults") or {}).get(view_key) + if isinstance(d, list): + cols = [str(x).strip() for x in d if str(x).strip()] + else: + cols = [] + + allowed = allowed_by_view.get(view_key) or set() + filtered = [c for c in cols if c in allowed] + + # If the user provided keys but all were invalid/out-of-scope, fall back to defaults. + if user_provided and cols and not filtered: + filtered = [c for c in (cols_meta.get("defaults") or {}).get(view_key, []) if c in allowed] + + # If the user explicitly selected none, keep it empty. + return filtered + + def _td(value: str, cls: str = "") -> str: + cl = f" class='{cls}'" if cls else "" + return f"{value}" + + def _th(label: str, cls: str = "") -> str: + cl = f" class='{cls}'" if cls else "" + return f"{label}" + + def _render_table(view_key: str, rows: list[dict]) -> tuple[str, str]: + cols = _selected_cols(view_key) + + if not cols: + # Keep the HTML valid and explicit when the user deselects all columns. + return _th("No columns selected", "text-muted"), "No columns selected." + + th = [] + for k in cols: + cls = "text-end" if k in ("missed", "override_applied") else "" + th.append(_th(_esc(label_map.get(k) or k), cls)) + + tr = [] + for r in rows: + tds = [] + for k in cols: + if k in ("missed", "override_applied"): + v = "1" if bool(r.get(k)) else "0" + tds.append(_td(_esc(v), "text-end")) + elif k == "run_at": + tds.append(_td(_esc(r.get(k) or ""), "text-muted small")) + else: + tds.append(_td(_esc(r.get(k) or ""))) + tr.append("" + "".join(tds) + "") + + return "".join(th), "\n".join(tr) + + # Snapshot preview table can be requested explicitly via view=snapshot. + 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 (aggregated per job) for a single-customer report. + include_keys_jobs = _get_success_rate_keys_from_report(report, view_key="jobs") + + jobs_selected_cols = _selected_cols("jobs") + jobs_sort_order = ["customer_name", "backup_software", "backup_type", "job_name"] + jobs_sort_fields = [k for k in jobs_sort_order if k in set(jobs_selected_cols or [])] + if not jobs_sort_fields: + jobs_sort_fields = list(jobs_sort_order) + + jobs_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, + 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 1000 + """ + ), + {"rid": report_id}, + ).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, + ) + + jr_success = int(r.success_count or 0) + jr_warning = int(r.warning_count or 0) + jr_failed = int(r.failed_count or 0) + jr_missed = int(r.missed_count or 0) + jr_total = 0 + if "success_count" in include_keys_jobs: + jr_total += jr_success + if "warning_count" in include_keys_jobs: + jr_total += jr_warning + if "failed_count" in include_keys_jobs: + jr_total += jr_failed + if "missed_count" in include_keys_jobs: + jr_total += jr_missed + + jobs_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 "", + "total_runs": str(int(jr_total or 0)), + "success_count": str(jr_success), + "success_override_count": str(int(r.success_override_count or 0)), + "warning_count": str(jr_warning), + "failed_count": str(jr_failed), + "missed_count": str(jr_missed), + "success_rate": f"{round(rate, 2)}%", + } + ) + + def _sort_key_jobs(it: dict) -> tuple: + return tuple(((it.get(k) or "").strip().lower()) for k in jobs_sort_fields) + + jobs_rows.sort(key=_sort_key_jobs) + + jobs_th, jobs_tr = _render_table("jobs", jobs_rows) + + jobs_table_html = f"""
+
+
+
+
Jobs
+
Aggregated per job over the report period
+
+
+
+ + + {jobs_th} + + +""" + jobs_tr + """ +
+
+
+
+
+
""" + + # 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""" +
+
+
+
+
Success rate per job
+
Top {len(job_labels)} jobs by total runs
+
+
+ +
+
+
+
+ +""" + + snapshot_table_html = "" + if want_snapshot_table: + 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(), + } + ) + + snap_th, snap_tr = _render_table("snapshot", snap_rows) + + snapshot_table_html = """
+
+
+
+
Snapshot preview
+
First 500 rows
+
+
+
+ + + {snap_th} + + +""" + snap_tr + """ +
+
+
+
+
+
""" + + 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 = [] + if include_customers: + for p in perf: + perf_rows.append( + "" + f"{_esc(p.get('customer_name') or '')}" + f"{int(p.get('total_runs') or 0)}" + f"{int(p.get('success_count') or 0)}" + f"{int(p.get('warning_count') or 0)}" + f"{int(p.get('failed_count') or 0)}" + f"{int(p.get('missed_count') or 0)}" + f"{p.get('success_rate', 0)}%" + "" + ) + + perf_table_html = "" + if include_customers: + perf_table_html = f""" +
+
+
+
+
Performance by customer
+
Aggregated from generated snapshot data
+
+
+
+ + + + + + + + + + + + + + {''.join(perf_rows)} + +
CustomerTotalSuccessWarningFailedMissedSuccess %
+
+
+
+
+
+""" + + html = f""" + + + + + {title} + + + + + +
+
+
+

{title}

+
Report ID {report_id} · Period {period_s} → {period_e}
+
+
+
Generated HTML preview
+
+
+ +
+
+
+
Total runs
+
{summary.get('total_runs', 0)}
+
+
+
+
+
Success
+
{summary.get('success_count', 0)}
+
+
+
+
+
Failed
+
{summary.get('failed_count', 0)}
+
+
+
+
+
Success rate
+
{summary.get('success_rate', 0)}%
+
+
+
+ +
+
+
+
+
Success rate trend
+
Daily success rate based on generated snapshots
+
+
+ +
+
+
+
+
+
+
Status distribution
+
All snapshots grouped by status
+
+
+ +
+
+
+
+ + {perf_table_html} + + {jobs_chart_html} + + {jobs_table_html} + + {snapshot_table_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//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", - "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(ReportObjectSummary.object_name.asc()) - .all() - ) - for r in rows: - writer.writerow([ - r.object_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), - float(r.success_rate or 0.0), - ]) - 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//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) diff --git a/containers/backupchecks/src/backend/app/main/routes_reports.py b/containers/backupchecks/src/backend/app/main/routes_reports.py index 5b29dc6..84be6f9 100644 --- a/containers/backupchecks/src/backend/app/main/routes_reports.py +++ b/containers/backupchecks/src/backend/app/main/routes_reports.py @@ -1,6 +1,6 @@ from .routes_shared import * # noqa: F401,F403 from datetime import date, timedelta -from .routes_reporting_api import build_report_columns_meta +from .routes_reporting_api import build_report_columns_meta, build_report_job_filters_meta def get_default_report_period(): """Return default report period (last 7 days).""" @@ -21,6 +21,17 @@ def _safe_json_list(value): return [] +def _safe_json_dict(value): + if not value: + return {} + if isinstance(value, dict): + return value + try: + return json.loads(value) + except Exception: + return {} + + def _build_report_item(r): return { "id": int(r.id), @@ -33,6 +44,7 @@ def _build_report_item(r): "period_start": r.period_start.isoformat() if getattr(r, "period_start", None) else "", "period_end": r.period_end.isoformat() if getattr(r, "period_end", None) else "", "schedule": r.schedule or "", + "report_config": _safe_json_dict(getattr(r, "report_config", None)), "created_at": r.created_at.isoformat() if getattr(r, "created_at", None) else "", } @@ -55,6 +67,7 @@ def reports(): "main/reports.html", initial_reports=items, columns_meta=build_report_columns_meta(), + job_filters_meta=build_report_job_filters_meta(), default_period_start=period_start.isoformat(), default_period_end=period_end.isoformat(), ) @@ -72,4 +85,38 @@ def reports_new(): ) customer_items = [{"id": int(c.id), "name": c.name or ""} for c in customers] - return render_template("main/reports_new.html", initial_customers=customer_items, columns_meta=build_report_columns_meta()) + return render_template( + "main/reports_new.html", + initial_customers=customer_items, + columns_meta=build_report_columns_meta(), + job_filters_meta=build_report_job_filters_meta(), + is_edit=False, + initial_report=None, + ) + + +@main_bp.route("/reports//edit") +@login_required +def reports_edit(report_id: int): + # Editing reports is limited to the same roles that can create them. + if get_active_role() not in ("admin", "operator", "reporter"): + return abort(403) + + r = ReportDefinition.query.get_or_404(report_id) + + customers = ( + db.session.query(Customer) + .filter(Customer.active.is_(True)) + .order_by(Customer.name.asc()) + .all() + ) + customer_items = [{"id": int(c.id), "name": c.name or ""} for c in customers] + + return render_template( + "main/reports_new.html", + initial_customers=customer_items, + columns_meta=build_report_columns_meta(), + job_filters_meta=build_report_job_filters_meta(), + is_edit=True, + initial_report=_build_report_item(r), + ) diff --git a/containers/backupchecks/src/backend/app/migrations.py b/containers/backupchecks/src/backend/app/migrations.py index 2da1bc4..49e304d 100644 --- a/containers/backupchecks/src/backend/app/migrations.py +++ b/containers/backupchecks/src/backend/app/migrations.py @@ -1371,6 +1371,8 @@ def migrate_reporting_tables() -> None: id SERIAL PRIMARY KEY, report_id INTEGER NOT NULL REFERENCES report_definitions(id) ON DELETE CASCADE, object_name TEXT NOT NULL, + customer_id INTEGER NULL, + customer_name TEXT NULL, total_runs INTEGER NOT NULL DEFAULT 0, success_count INTEGER NOT NULL DEFAULT 0, success_override_count INTEGER NOT NULL DEFAULT 0, @@ -1395,5 +1397,7 @@ def migrate_reporting_tables() -> None: conn.execute(text("ALTER TABLE report_definitions ADD COLUMN IF NOT EXISTS customer_scope VARCHAR(16) NOT NULL DEFAULT 'all'")) conn.execute(text("ALTER TABLE report_definitions ADD COLUMN IF NOT EXISTS customer_ids TEXT NULL")) conn.execute(text("ALTER TABLE report_object_snapshots ADD COLUMN IF NOT EXISTS customer_id INTEGER NULL")) + conn.execute(text("ALTER TABLE report_object_summaries ADD COLUMN IF NOT EXISTS customer_id INTEGER NULL")) + conn.execute(text("ALTER TABLE report_object_summaries ADD COLUMN IF NOT EXISTS customer_name TEXT NULL")) print("[migrations] reporting tables created/verified.") diff --git a/containers/backupchecks/src/backend/app/models.py b/containers/backupchecks/src/backend/app/models.py index 350fddf..a66644c 100644 --- a/containers/backupchecks/src/backend/app/models.py +++ b/containers/backupchecks/src/backend/app/models.py @@ -536,7 +536,7 @@ class ReportDefinition(db.Model): # one-time | scheduled report_type = db.Column(db.String(32), nullable=False, default="one-time") - # csv | pdf (pdf is future) + # csv | html | pdf output_format = db.Column(db.String(16), nullable=False, default="csv") # customer scope for report generation @@ -612,6 +612,9 @@ class ReportObjectSummary(db.Model): report_id = db.Column(db.Integer, db.ForeignKey("report_definitions.id"), nullable=False) object_name = db.Column(db.Text, nullable=False) + customer_id = db.Column(db.Integer, nullable=True) + customer_name = db.Column(db.Text, nullable=True) + total_runs = db.Column(db.Integer, nullable=False, default=0) success_count = db.Column(db.Integer, nullable=False, default=0) success_override_count = db.Column(db.Integer, nullable=False, default=0) diff --git a/containers/backupchecks/src/templates/main/reports.html b/containers/backupchecks/src/templates/main/reports.html index ddc0fc0..b3c8f7f 100644 --- a/containers/backupchecks/src/templates/main/reports.html +++ b/containers/backupchecks/src/templates/main/reports.html @@ -40,7 +40,7 @@ {% for item in initial_reports %} {{ item.name }}
{{ item.description }}
- {{ item.created_at.replace('T',' ') if item.created_at else '' }} + {{ item.report_type }} {% if item.period_start or item.period_end %} {{ item.period_start.replace('T',' ') if item.period_start else '' }} → {{ item.period_end.replace('T',' ') if item.period_end else '' }} @@ -50,9 +50,10 @@ {{ item.output_format }} + Edit - Download + Download {% if active_role in ('admin','operator','reporter') %} {% endif %} @@ -64,7 +65,7 @@ No reports found. {% endif %} - > + @@ -195,22 +196,58 @@ if (items[j].key === key) return items[j].label || key; } } + // Backwards compatibility: object_name was used for job name in older configs. + if (key === 'job_name') { + for (var i2 = 0; i2 < reportColumnsMeta.groups.length; i2++) { + var items2 = reportColumnsMeta.groups[i2].items || []; + for (var j2 = 0; j2 < items2.length; j2++) { + if (items2[j2].key === 'object_name') return items2[j2].label || 'Job name'; + } + } + return 'Job name'; + } return key; } + function uniqPreserveOrder(arr) { + var out = []; + var seen = {}; + (arr || []).forEach(function (k) { + var key = String(k); + if (seen[key]) return; + seen[key] = true; + out.push(k); + }); + return out; + } + + function normalizeCols(arr) { + if (!Array.isArray(arr)) return []; + return uniqPreserveOrder(arr.map(function (k) { return (k === 'object_name') ? 'job_name' : k; })); + } + function defaultColsFor(view) { if (reportColumnsMeta && reportColumnsMeta.defaults && reportColumnsMeta.defaults[view]) { - return reportColumnsMeta.defaults[view].slice(); + return normalizeCols(reportColumnsMeta.defaults[view].slice()); } // hard fallback - if (view === 'snapshot') return ['object_name','customer_name','job_id','job_name','status','run_at']; - return ['object_name','total_runs','success_count','warning_count','failed_count','missed_count','success_rate']; + if (view === 'snapshot') return ['job_name','customer_name','job_id','status','run_at']; + return ['job_name','total_runs','success_count','warning_count','failed_count','missed_count','success_rate']; } function selectedColsFor(view) { var cfg = rawReportConfig || {}; - var cols = (cfg.columns && cfg.columns[view]) ? cfg.columns[view] : null; - if (cols && cols.length) return cols; + var cols = null; + var hasView = false; + if (cfg.columns && typeof cfg.columns === 'object') { + hasView = Object.prototype.hasOwnProperty.call(cfg.columns, view); + cols = cfg.columns[view]; + } + if (hasView && Array.isArray(cols)) { + // If an empty list is saved, keep it empty. + return normalizeCols(cols); + } + if (cols && cols.length) return normalizeCols(cols); return defaultColsFor(view); } @@ -271,12 +308,41 @@ } } + function setRawDownloadLink() { + var btn = qs('rep_raw_download_btn'); + if (!btn) return; + if (!rawReportId) { + btn.setAttribute('href', '#'); + btn.classList.add('disabled'); + return; + } + btn.classList.remove('disabled'); + btn.setAttribute('href', '/api/reports/' + rawReportId + '/export?view=' + rawView); + } + + function updateRawMeta(total) { + var t = parseInt(total || 0, 10) || 0; + var start = t ? (rawOffset + 1) : 0; + var end = t ? Math.min(rawOffset + rawLimit, t) : 0; + var label = (rawView === 'snapshot') ? 'Snapshot' : 'Summary'; + qs('rep_raw_meta').textContent = label + ' · Rows ' + start + '-' + end + ' of ' + t; + qs('rep_raw_prev_btn').disabled = (rawOffset <= 0); + qs('rep_raw_next_btn').disabled = ((rawOffset + rawLimit) >= t); + } + function renderRawTable(view, items) { var thead = qs('rep_raw_thead'); var tbody = qs('rep_raw_tbody'); var cols = selectedColsFor(view); + if (!cols || !cols.length) { + thead.innerHTML = 'No columns selected'; + tbody.innerHTML = 'No columns selected.'; + setRawDownloadLink(); + return; + } + function thRow(keys) { return '' + keys.map(function (k) { return '' + escapeHtml(colLabel(k)) + ''; }).join('') + ''; } @@ -298,7 +364,8 @@ return ( '' + cols.map(function (k) { - return td(r[k], (k === 'run_at' || k === 'reviewed_at' || k === 'job_id' || k === 'run_id' || k === 'customer_name')); + var val = (k === 'job_name') ? ((r.job_name !== null && r.job_name !== undefined && String(r.job_name).length) ? r.job_name : r.object_name) : r[k]; + return td(val, (k === 'run_at' || k === 'reviewed_at' || k === 'job_id' || k === 'run_id' || k === 'customer_name')); }).join('') + '' ); @@ -368,9 +435,10 @@ function loadRawData() { '' + period + '' + '' + fmt + '' + '' + + 'Edit' + '' + '' + - 'Download' + + 'Download' + (canDeleteReports ? '' : '') + '';; diff --git a/containers/backupchecks/src/templates/main/reports_new.html b/containers/backupchecks/src/templates/main/reports_new.html index 66486db..27e9b67 100644 --- a/containers/backupchecks/src/templates/main/reports_new.html +++ b/containers/backupchecks/src/templates/main/reports_new.html @@ -3,8 +3,8 @@
-

New report

-
Create a one-time report definition. Generate output from the Reports overview.
+

{{ 'Edit report' if is_edit else 'New report' }}

+
{{ 'Update this report definition. Generate output from the Reports overview.' if is_edit else 'Create a one-time report definition. Generate output from the Reports overview.' }}
Back @@ -30,11 +30,22 @@
+
+ + +
Controls whether the HTML/PDF output shows a customer list, a job list, or both.
+
+
@@ -118,10 +129,32 @@
-
- Jobs selection is set to all jobs for each selected customer in this iteration. +
Jobs filter
+
+ Filter which jobs are included in the report by backup software and backup type. Informational jobs (e.g. License Key) are always excluded.
+ +
+ + +
Leave empty to include all backup software.
+
+ +
+ + +
Leave empty to include all backup types (except informational types).
+
+
@@ -133,6 +166,7 @@
+
Select columns for the summary view.
@@ -157,7 +191,7 @@
- + Cancel
@@ -198,7 +232,10 @@