diff --git a/app/db/health.py b/app/db/health.py new file mode 100644 index 0000000..45a9843 --- /dev/null +++ b/app/db/health.py @@ -0,0 +1,102 @@ +from app.db.database import get_connection + + +HEALTH_STATUSES = ("healthy", "unhealthy", "unreachable") + + +def get_latest_service_health(): + con = get_connection() + + try: + rows = con.execute( + """ + SELECT + h.service_id, + COALESCE(a.name, h.service_id) AS service_name, + h.status, + h.http_status, + h.response_time_ms, + h.error_text, + h.checked_at + FROM service_health h + LEFT JOIN apps a ON a.id = h.service_id + INNER JOIN ( + SELECT service_id, MAX(id) AS max_id + FROM service_health + GROUP BY service_id + ) latest ON latest.max_id = h.id + """ + ).fetchall() + except Exception: + rows = [] + + con.close() + return {row["service_id"]: dict(row) for row in rows} + + +def get_service_health(service_id: str): + con = get_connection() + + try: + row = con.execute( + """ + SELECT + h.service_id, + COALESCE(a.name, h.service_id) AS service_name, + h.status, + h.http_status, + h.response_time_ms, + h.error_text, + h.checked_at + FROM service_health h + LEFT JOIN apps a ON a.id = h.service_id + WHERE h.service_id = ? + ORDER BY h.id DESC + LIMIT 1 + """, + (service_id,), + ).fetchone() + except Exception: + row = None + + con.close() + return dict(row) if row else None + + +def get_service_health_history(service_id: str, limit: int = 50): + con = get_connection() + + try: + rows = con.execute( + """ + SELECT id, service_id, status, http_status, response_time_ms, error_text, checked_at + FROM service_health + WHERE service_id = ? + ORDER BY id DESC + LIMIT ? + """, + (service_id, limit), + ).fetchall() + except Exception: + rows = [] + + con.close() + return [dict(row) for row in rows] + + +def get_health_summary(): + latest = get_latest_service_health() + summary = {"healthy": 0, "unhealthy": 0, "unreachable": 0} + for item in latest.values(): + status = item.get("status") + if status in summary: + summary[status] += 1 + return summary + + +def get_problem_service_health(): + latest = get_latest_service_health() + return [ + item for item in latest.values() + if item.get("status") in {"unhealthy", "unreachable"} + ] diff --git a/app/db/operations.py b/app/db/operations.py index 323dc72..88ecc08 100644 --- a/app/db/operations.py +++ b/app/db/operations.py @@ -1,4 +1,5 @@ from app.db.database import get_connection +from app.db.health import get_health_summary, get_problem_service_health from app.db.migrations import run_migrations from app.db.workers import is_worker_online @@ -197,6 +198,8 @@ def get_operations_snapshot(): WHERE LOWER(COALESCE(status, '')) IN ('failed', 'error', 'disabled', 'deleted', 'archived') """ ).fetchone()["count"] + health_summary = get_health_summary() + health_problems = get_problem_service_health() jobs_summary = con.execute( """ @@ -296,6 +299,12 @@ def get_operations_snapshot(): "active": active_applications, "problematic": problematic_applications, }, + "health": { + "healthy": health_summary["healthy"], + "unhealthy": health_summary["unhealthy"], + "unreachable": health_summary["unreachable"], + "problems": health_problems, + }, "workers": { "online": workers_online, "offline": max(0, total_workers - workers_online), diff --git a/app/routes/apps.py b/app/routes/apps.py index d99af2f..ff3ae65 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -17,6 +17,7 @@ from ..config import ( ) from ..db.apps import get_app, get_app_deployments, get_apps, update_app_resources from ..db.audit import log_audit_event +from ..db.health import get_latest_service_health, get_service_health, get_service_health_history from ..db.jobs import create_job, get_jobs, has_active_deploy_job from ..routes.deployments import render_status_pill from ..shell import run_command @@ -26,6 +27,24 @@ router = APIRouter() DEFAULT_PAGE_SIZE = 20 +def render_health_status(status: str | None) -> str: + value = status or "" + normalized = value.lower() + labels = { + "healthy": "zdravá", + "unhealthy": "nezdravá", + "unreachable": "nedostupná", + } + class_name = "pill pill-muted" + if normalized == "healthy": + class_name = "pill pill-success" + elif normalized == "unhealthy": + class_name = "pill pill-warning" + elif normalized == "unreachable": + class_name = "pill pill-danger" + return f'{html.escape(labels.get(normalized, value or "neznámá"))}' + + @router.get("/") def portal_home(user=Depends(require_user)): return RedirectResponse(url="/portal/operations", status_code=303) @@ -40,6 +59,7 @@ def apps_page( user=Depends(require_user), ): apps = get_apps() + latest_health = get_latest_service_health() query = q.strip() selected_status = status.strip() if query: @@ -50,6 +70,16 @@ def apps_page( ] if selected_status: apps = [item for item in apps if (item.get("status", "") or "") == selected_status] + sort = request.query_params.get("sort", "").strip() + if sort == "health": + order = {"unreachable": 0, "unhealthy": 1, "healthy": 2} + apps = sorted( + apps, + key=lambda item: ( + order.get((latest_health.get(item.get("id", "")) or {}).get("status"), 3), + item.get("id", ""), + ), + ) status_values = sorted({item.get("status", "") for item in get_apps() if item.get("status")}) total_apps = len(apps) total_pages = max(1, math.ceil(total_apps / DEFAULT_PAGE_SIZE)) @@ -68,6 +98,9 @@ def apps_page( app_id = html.escape(item.get("id", "")) app_url_id = quote(item.get("id", ""), safe="") status = html.escape(item.get("status", "")) + health = latest_health.get(item.get("id", "")) or {} + health_status = render_health_status(health.get("status")) + health_checked_at = html.escape(health.get("checked_at", "") or "") docs = html.escape(item.get("docs", f"/apps/{app_id}/docs")) memory = item.get("memory", "") cpus = item.get("cpus", "") @@ -104,6 +137,7 @@ def apps_page( /apps/{app_id}