diff --git a/app/db/migration_readiness.py b/app/db/migration_readiness.py new file mode 100644 index 0000000..d0deff7 --- /dev/null +++ b/app/db/migration_readiness.py @@ -0,0 +1,147 @@ +import json +import re +from typing import Any + +from app.db.database import get_connection +from app.db.migrations import run_migrations + +PREFLIGHT_SCRIPT = "preflight-check.sh" + + +def _job_matches_script(row: dict[str, Any]) -> bool: + if row.get("target_id") == PREFLIGHT_SCRIPT: + return True + + try: + payload = json.loads(row.get("payload_json") or "{}") + except (TypeError, ValueError): + return False + + return payload.get("script_name") == PREFLIGHT_SCRIPT + + +def get_latest_preflight_job(): + run_migrations() + con = get_connection() + + rows = con.execute( + """ + SELECT * + FROM jobs + WHERE type = 'run_script' + AND ( + target_id = ? + OR payload_json LIKE ? + ) + ORDER BY id DESC + LIMIT 20 + """, + (PREFLIGHT_SCRIPT, f'%"{PREFLIGHT_SCRIPT}"%'), + ).fetchall() + + con.close() + for row in rows: + job = dict(row) + if _job_matches_script(job): + return job + return None + + +def get_job_log_output(job_id: int) -> str: + run_migrations() + con = get_connection() + + rows = con.execute( + """ + SELECT stream, message + FROM job_logs + WHERE job_id = ? + ORDER BY id + """, + (job_id,), + ).fetchall() + + con.close() + return "\n".join(f"[{row['stream'] or 'log'}] {row['message'] or ''}" for row in rows) + + +def _count_token(text: str, token: str) -> int: + return len(re.findall(rf"(? dict[str, Any]: + try: + value = json.loads(job.get("result_json") or "{}") + except (TypeError, ValueError): + return {} + return value if isinstance(value, dict) else {} + + +def _first_int(data: dict[str, Any], keys: tuple[str, ...]) -> int | None: + for key in keys: + value = data.get(key) + if value is None: + continue + try: + return int(value) + except (TypeError, ValueError): + continue + return None + + +def summarize_readiness(job: dict[str, Any] | None) -> dict[str, Any]: + if not job: + return { + "ready": False, + "ok_count": 0, + "warn_count": 0, + "fail_count": 0, + "raw_output": "", + "job": None, + } + + raw_output = get_job_log_output(int(job["id"])) + result_data = _result_data(job) + combined_text = "\n".join( + value + for value in ( + raw_output, + job.get("result_json") or "", + job.get("error_text") or "", + ) + if value + ) + + ok_count = _first_int(result_data, ("ok", "ok_count", "okCount")) + warn_count = _first_int(result_data, ("warn", "warn_count", "warnCount", "warnings")) + fail_count = _first_int(result_data, ("fail", "fail_count", "failCount", "failed", "errors")) + + if ok_count is None: + ok_count = _count_token(combined_text, "OK") + if warn_count is None: + warn_count = _count_token(combined_text, "WARN") + if fail_count is None: + fail_count = _count_token(combined_text, "FAIL") + + readiness_value = str( + result_data.get("readiness") + or result_data.get("ready") + or result_data.get("status") + or "" + ).strip().upper() + + if readiness_value in {"READY", "TRUE", "OK", "SUCCESS"}: + ready = fail_count == 0 and (job.get("status") or "").lower() == "success" + elif readiness_value in {"NOT READY", "FALSE", "FAIL", "FAILED", "ERROR"}: + ready = False + else: + ready = fail_count == 0 and (job.get("status") or "").lower() == "success" + + return { + "ready": ready, + "ok_count": ok_count, + "warn_count": warn_count, + "fail_count": fail_count, + "raw_output": raw_output, + "job": job, + } diff --git a/app/main.py b/app/main.py index 6d8adbe..aac7938 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware.sessions import SessionMiddleware from .config import read_env_value -from .routes import alerting, apps, audit, auth, backups, deployments, health, incidents, jobs, operations, scheduled_scripts, workers +from .routes import alerting, apps, audit, auth, backups, deployments, health, incidents, jobs, migration_readiness, operations, scheduled_scripts, workers def create_app() -> FastAPI: @@ -29,6 +29,7 @@ def create_app() -> FastAPI: app.include_router(deployments.router) app.include_router(incidents.router) app.include_router(alerting.router) + app.include_router(migration_readiness.router) app.include_router(operations.router) app.include_router(scheduled_scripts.router) app.include_router(jobs.router) diff --git a/app/routes/migration_readiness.py b/app/routes/migration_readiness.py new file mode 100644 index 0000000..ee72f81 --- /dev/null +++ b/app/routes/migration_readiness.py @@ -0,0 +1,96 @@ +import html + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse, RedirectResponse + +from app.auth import require_user +from app.db.audit import log_audit_event +from app.db.jobs import create_job +from app.db.migration_readiness import PREFLIGHT_SCRIPT, get_latest_preflight_job, summarize_readiness +from app.routes.jobs import render_job_status +from app.templates.layout import page + +router = APIRouter() + + +def render_readiness_pill(ready: bool) -> str: + if ready: + return 'READY' + return 'NOT READY' + + +@router.get("/migration-readiness", response_class=HTMLResponse) +def migration_readiness_page(request: Request, user=Depends(require_user)): + latest_job = get_latest_preflight_job() + summary = summarize_readiness(latest_job) + job = summary["job"] + + log_audit_event( + user, + action="migration_readiness.viewed", + target_type="migration_readiness", + target_id=PREFLIGHT_SCRIPT, + metadata={"job_id": job.get("id") if job else None}, + ) + + job_rows = 'Zatím není uložený žádný výsledek preflight kontroly.' + if job: + job_id = html.escape(str(job.get("id", ""))) + job_rows = f""" + Úloha#{job_id} + Stav úlohy{render_job_status(job.get("status"))} + Vytvořeno{html.escape(job.get("created_at", "") or "")} + Spuštěno{html.escape(job.get("started_at", "") or "")} + Dokončeno{html.escape(job.get("finished_at", "") or "")} + Worker{html.escape(job.get("worker_id", "") or "")} + """ + + raw_output = html.escape(summary.get("raw_output") or "") + if not raw_output: + raw_output = "Zatím není uložený žádný log výstup." + + return page( + "Migration Readiness", + f""" +
+

Migration Readiness

+

Poslední výsledek maintenance skriptu {html.escape(PREFLIGHT_SCRIPT)} uložený v databázi.

+
+ +
+
+ +
+
Stav{render_readiness_pill(summary["ready"])}
+
OK{html.escape(str(summary["ok_count"]))}
+
WARN{html.escape(str(summary["warn_count"]))}
+
FAIL{html.escape(str(summary["fail_count"]))}
+
+ +
+

Poslední úloha

+ + {job_rows} +
+
+ +
+

Raw log výstup

+
{raw_output}
+
+ """, + user=user, + ) + + +@router.post("/migration-readiness/run") +def run_migration_readiness(user=Depends(require_user)): + job_id = create_job( + job_type="run_script", + target_type="maintenance_script", + target_id=PREFLIGHT_SCRIPT, + payload={"script_name": PREFLIGHT_SCRIPT}, + user=user, + source="portal_manual", + ) + return RedirectResponse(url=f"/portal/jobs/{job_id}", status_code=303) diff --git a/app/templates/layout.py b/app/templates/layout.py index 9d7cb84..df44222 100644 --- a/app/templates/layout.py +++ b/app/templates/layout.py @@ -21,6 +21,7 @@ def page(title: str, body: str, user=None) -> str: