From e6ffeb8fef9438cb91ce2514b2417ad1f026f513 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Thu, 28 May 2026 13:22:51 +0200 Subject: [PATCH] deployment observability, filters, stats, details --- app/db/apps.py | 169 ++++++++++++++++++++++++++- app/routes/apps.py | 129 ++++++++++++++++++++- app/routes/deployments.py | 232 ++++++++++++++++++++++++++++++++++---- app/shell.py | 16 +++ app/static/styles.css | 73 +++++++++++- 5 files changed, 585 insertions(+), 34 deletions(-) diff --git a/app/db/apps.py b/app/db/apps.py index fe4ba9c..298c309 100644 --- a/app/db/apps.py +++ b/app/db/apps.py @@ -1,6 +1,11 @@ from app.db.database import get_connection +SUCCESS_STATUSES = ("ok", "success", "succeeded", "done", "deployed", "completed") +FAILED_STATUSES = ("failed", "failure", "error", "cancelled", "canceled") +RUNNING_STATUSES = ("running", "pending", "queued", "in_progress", "starting") + + def get_apps(): con = get_connection() @@ -38,6 +43,22 @@ def get_apps(): return [dict(row) for row in rows] +def get_app(app_id: str): + con = get_connection() + + row = con.execute( + """ + SELECT id, name, language, version, status, memory, cpus, updated_at + FROM apps + WHERE id = ? + """, + (app_id,), + ).fetchone() + + con.close() + return dict(row) if row else None + + def update_app_resources(app_id: str, memory: str, cpus: str): con = get_connection() @@ -60,17 +81,143 @@ def update_app_resources(app_id: str, memory: str, cpus: str): con.close() -def get_deployments(limit: int = 100): +def get_deployments( + limit: int = 100, + status: str | None = None, + app_id: str | None = None, + source: str | None = None, +): + con = get_connection() + + filters = [] + params = [] + + if status: + normalized_status = status.strip().lower() + if normalized_status == "success": + filters.append(f"LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in SUCCESS_STATUSES)})") + params.extend(SUCCESS_STATUSES) + elif normalized_status == "failed": + filters.append(f"LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in FAILED_STATUSES)})") + params.extend(FAILED_STATUSES) + elif normalized_status == "running": + filters.append(f"LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in RUNNING_STATUSES)})") + params.extend(RUNNING_STATUSES) + else: + filters.append("LOWER(status) = LOWER(?)") + params.append(status) + + if app_id: + filters.append("app_id = ?") + params.append(app_id) + + if source: + filters.append("trigger_source = ?") + params.append(source) + + where = f"WHERE {' AND '.join(filters)}" if filters else "" + rows = con.execute( + f""" + SELECT + id, + app_id, + kind, + status, + started_at, + finished_at, + returncode, + trigger_source, + triggered_by_username, + triggered_by_display_name + FROM deployments + {where} + ORDER BY + CASE + WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in RUNNING_STATUSES)}) THEN 0 + ELSE 1 + END, + id DESC + LIMIT ? + """, + (*params, *RUNNING_STATUSES, limit), + ).fetchall() + + con.close() + return [dict(row) for row in rows] + + +def get_deployment_filter_options(): + con = get_connection() + + apps = con.execute( + """ + SELECT DISTINCT app_id + FROM deployments + WHERE app_id IS NOT NULL AND app_id != '' + ORDER BY app_id + """ + ).fetchall() + sources = con.execute( + """ + SELECT DISTINCT trigger_source + FROM deployments + WHERE trigger_source IS NOT NULL AND trigger_source != '' + ORDER BY trigger_source + """ + ).fetchall() + + con.close() + return { + "apps": [row["app_id"] for row in apps], + "sources": [row["trigger_source"] for row in sources], + } + + +def get_deployment_stats(): + con = get_connection() + + row = con.execute( + f""" + SELECT + COUNT(*) AS total, + SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in FAILED_STATUSES)}) THEN 1 ELSE 0 END) AS failed, + SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in RUNNING_STATUSES)}) THEN 1 ELSE 0 END) AS running, + SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in SUCCESS_STATUSES)}) THEN 1 ELSE 0 END) AS success + FROM deployments + """, + (*FAILED_STATUSES, *RUNNING_STATUSES, *SUCCESS_STATUSES), + ).fetchone() + + con.close() + stats = dict(row) if row else {"total": 0, "failed": 0, "running": 0, "success": 0} + total = stats.get("total") or 0 + success = stats.get("success") or 0 + stats["success_rate"] = round((success / total) * 100, 1) if total else 0 + return stats + + +def get_app_deployments(app_id: str, limit: int = 10): con = get_connection() rows = con.execute( """ - SELECT id, app_id, kind, status, started_at, finished_at, returncode + SELECT + id, + app_id, + kind, + status, + started_at, + finished_at, + returncode, + trigger_source, + triggered_by_username, + triggered_by_display_name FROM deployments + WHERE app_id = ? ORDER BY id DESC LIMIT ? """, - (limit,), + (app_id, limit), ).fetchall() con.close() @@ -82,7 +229,21 @@ def get_deployment(deployment_id: int): row = con.execute( """ - SELECT id, app_id, kind, status, started_at, finished_at, returncode, stdout, stderr + SELECT + id, + app_id, + kind, + status, + started_at, + finished_at, + returncode, + stdout, + stderr, + trigger_source, + triggered_by_username, + triggered_by_display_name, + commit_author, + pusher FROM deployments WHERE id = ? """, diff --git a/app/routes/apps.py b/app/routes/apps.py index 519e275..bb49f73 100644 --- a/app/routes/apps.py +++ b/app/routes/apps.py @@ -1,7 +1,8 @@ import html +from urllib.parse import quote -from fastapi import APIRouter, Depends, Form, Request -from fastapi.responses import HTMLResponse +from fastapi import APIRouter, Depends, Form, HTTPException, Request +from fastapi.responses import HTMLResponse, RedirectResponse from ..auth import require_user from ..config import ( @@ -13,9 +14,10 @@ from ..config import ( NEW_APP_SCRIPT, read_env_value, ) -from ..db.apps import get_apps, update_app_resources +from ..db.apps import get_app, get_app_deployments, get_apps, update_app_resources from ..db.audit import log_audit_event -from ..shell import run_command +from ..routes.deployments import render_status_pill +from ..shell import run_command, run_command_background from ..templates.layout import page, render_result router = APIRouter() @@ -33,6 +35,7 @@ def index(request: Request, user=Depends(require_user)): for item in apps: app_id = html.escape(item.get("id", "")) + app_url_id = quote(item.get("id", ""), safe="") status = html.escape(item.get("status", "")) docs = html.escape(item.get("docs", f"/apps/{app_id}/docs")) memory = item.get("memory", "") @@ -102,6 +105,11 @@ def index(request: Request, user=Depends(require_user)):