readinness
This commit is contained in:
@@ -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"(?<![A-Z0-9_]){re.escape(token)}(?![A-Z0-9_])", text.upper()))
|
||||
|
||||
|
||||
def _result_data(job: dict[str, Any]) -> 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,
|
||||
}
|
||||
+2
-1
@@ -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)
|
||||
|
||||
@@ -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 '<span class="pill pill-success">READY</span>'
|
||||
return '<span class="pill pill-danger">NOT READY</span>'
|
||||
|
||||
|
||||
@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 = '<tr><td colspan="2">Zatím není uložený žádný výsledek preflight kontroly.</td></tr>'
|
||||
if job:
|
||||
job_id = html.escape(str(job.get("id", "")))
|
||||
job_rows = f"""
|
||||
<tr><th>Úloha</th><td><a href="/portal/jobs/{job_id}">#{job_id}</a></td></tr>
|
||||
<tr><th>Stav úlohy</th><td>{render_job_status(job.get("status"))}</td></tr>
|
||||
<tr><th>Vytvořeno</th><td>{html.escape(job.get("created_at", "") or "")}</td></tr>
|
||||
<tr><th>Spuštěno</th><td>{html.escape(job.get("started_at", "") or "")}</td></tr>
|
||||
<tr><th>Dokončeno</th><td>{html.escape(job.get("finished_at", "") or "")}</td></tr>
|
||||
<tr><th>Worker</th><td>{html.escape(job.get("worker_id", "") or "")}</td></tr>
|
||||
"""
|
||||
|
||||
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"""
|
||||
<div class="card">
|
||||
<h2>Migration Readiness</h2>
|
||||
<p class="muted">Poslední výsledek maintenance skriptu {html.escape(PREFLIGHT_SCRIPT)} uložený v databázi.</p>
|
||||
<form method="post" action="/portal/migration-readiness/run">
|
||||
<button type="submit">Spustit preflight kontrolu</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-card {"stat-success" if summary["ready"] else "stat-danger"}"><span>Stav</span><strong>{render_readiness_pill(summary["ready"])}</strong></div>
|
||||
<div class="stat-card stat-success"><span>OK</span><strong>{html.escape(str(summary["ok_count"]))}</strong></div>
|
||||
<div class="stat-card stat-warning"><span>WARN</span><strong>{html.escape(str(summary["warn_count"]))}</strong></div>
|
||||
<div class="stat-card stat-danger"><span>FAIL</span><strong>{html.escape(str(summary["fail_count"]))}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Poslední úloha</h2>
|
||||
<table>
|
||||
{job_rows}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<h2>Raw log výstup</h2>
|
||||
<pre class="log-viewer log-stdout">{raw_output}</pre>
|
||||
</div>
|
||||
""",
|
||||
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)
|
||||
@@ -21,6 +21,7 @@ def page(title: str, body: str, user=None) -> str:
|
||||
<div class="nav-menu-panel">
|
||||
<a href="/portal/jobs">Úlohy</a>
|
||||
<a href="/portal/scheduled-scripts">Plánované skripty</a>
|
||||
<a href="/portal/migration-readiness">Migration Readiness</a>
|
||||
<a href="/portal/alerting/rules">Alerting</a>
|
||||
<a href="/portal/deployments">Nasazení</a>
|
||||
<a href="/portal/incidents">Incidenty</a>
|
||||
|
||||
Reference in New Issue
Block a user