From 6e5d9b312008f0dd806c9c4064265b3903322ed9 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Fri, 29 May 2026 10:44:25 +0200 Subject: [PATCH] operations --- app/db/operations.py | 144 ++++++++++++++++++++++++++++++++ app/main.py | 3 +- app/routes/operations.py | 176 +++++++++++++++++++++++++++++++++++++++ app/templates/layout.py | 1 + 4 files changed, 323 insertions(+), 1 deletion(-) create mode 100644 app/db/operations.py create mode 100644 app/routes/operations.py diff --git a/app/db/operations.py b/app/db/operations.py new file mode 100644 index 0000000..2383962 --- /dev/null +++ b/app/db/operations.py @@ -0,0 +1,144 @@ +from app.db.database import get_connection +from app.db.migrations import run_migrations +from app.db.workers import is_worker_online + + +def _table_columns(con, table_name: str) -> set[str]: + rows = con.execute(f"PRAGMA table_info({table_name})").fetchall() + return {row["name"] for row in rows} + + +def get_operations_summary(): + run_migrations() + con = get_connection() + + workers = con.execute( + """ + SELECT last_seen_at + FROM workers + """ + ).fetchall() + workers_online = sum(1 for worker in workers if is_worker_online(worker["last_seen_at"])) + + running_jobs = con.execute( + """ + SELECT COUNT(*) AS count + FROM jobs + WHERE status = 'running' + """ + ).fetchone()["count"] + queued_jobs = con.execute( + """ + SELECT COUNT(*) AS count + FROM jobs + WHERE status = 'queued' + """ + ).fetchone()["count"] + failed_jobs_24h = con.execute( + """ + SELECT COUNT(*) AS count + FROM jobs + WHERE status = 'failed' + AND created_at >= datetime('now', '-24 hours') + """ + ).fetchone()["count"] + + deployment_columns = _table_columns(con, "deployments") + deployments_today = 0 + if "created_at" in deployment_columns: + deployments_today = con.execute( + """ + SELECT COUNT(*) AS count + FROM deployments + WHERE date(created_at) = date('now') + """ + ).fetchone()["count"] + elif "started_at" in deployment_columns: + deployments_today = con.execute( + """ + SELECT COUNT(*) AS count + FROM deployments + WHERE date(started_at) = date('now') + """ + ).fetchone()["count"] + + app_columns = _table_columns(con, "apps") + if "enabled" in app_columns: + active_applications = con.execute( + """ + SELECT COUNT(*) AS count + FROM apps + WHERE enabled = 1 + OR LOWER(CAST(enabled AS TEXT)) IN ('1', 'true', 'yes', 'enabled') + """ + ).fetchone()["count"] + else: + active_applications = con.execute( + """ + SELECT COUNT(*) AS count + FROM apps + WHERE LOWER(COALESCE(status, '')) NOT IN ('disabled', 'deleted', 'archived') + """ + ).fetchone()["count"] + + con.close() + return { + "workers_online": workers_online, + "running_jobs": running_jobs, + "queued_jobs": queued_jobs, + "failed_jobs_24h": failed_jobs_24h, + "deployments_today": deployments_today, + "active_applications": active_applications, + } + + +def get_recent_jobs(limit: int = 20): + run_migrations() + con = get_connection() + + rows = con.execute( + """ + SELECT id, type, target_type, target_id, status, source, created_at + FROM jobs + ORDER BY id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + + con.close() + return [dict(row) for row in rows] + + +def get_recent_deployments(limit: int = 20): + con = get_connection() + + rows = con.execute( + """ + SELECT id, app_id, kind, status, started_at, finished_at, trigger_source + FROM deployments + ORDER BY id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + + con.close() + return [dict(row) for row in rows] + + +def get_recent_audit_events(limit: int = 20): + con = get_connection() + + rows = con.execute( + """ + SELECT id, username, action, target_type, target_id, source, created_at + FROM audit_events + ORDER BY id DESC + LIMIT ? + """, + (limit,), + ).fetchall() + + con.close() + return [dict(row) for row in rows] diff --git a/app/main.py b/app/main.py index 4fffa03..c9a66e0 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 apps, audit, auth, backups, deployments, health, jobs, workers +from .routes import apps, audit, auth, backups, deployments, health, jobs, operations, workers def create_app() -> FastAPI: @@ -27,6 +27,7 @@ def create_app() -> FastAPI: app.include_router(apps.router) app.include_router(backups.router) app.include_router(deployments.router) + app.include_router(operations.router) app.include_router(jobs.router) app.include_router(workers.router) app.include_router(audit.router) diff --git a/app/routes/operations.py b/app/routes/operations.py new file mode 100644 index 0000000..a80dc8e --- /dev/null +++ b/app/routes/operations.py @@ -0,0 +1,176 @@ +import html +from urllib.parse import quote + +from fastapi import APIRouter, Depends, Request +from fastapi.responses import HTMLResponse + +from app.auth import require_user +from app.db.audit import log_audit_event +from app.db.operations import ( + get_operations_summary, + get_recent_audit_events, + get_recent_deployments, + get_recent_jobs, +) +from app.routes.deployments import render_status_pill +from app.routes.jobs import render_job_status +from app.templates.layout import page + +router = APIRouter() + + +def render_recent_jobs(jobs: list[dict]) -> str: + rows = "" + for job in jobs: + job_id = html.escape(str(job.get("id", ""))) + job_type = html.escape(job.get("type", "") or "") + target_type = html.escape(job.get("target_type", "") or "") + target_id = html.escape(job.get("target_id", "") or "") + source = html.escape(job.get("source", "") or "") + created_at = html.escape(job.get("created_at", "") or "") + + rows += f""" + + #{job_id} + {render_job_status(job.get("status"))} + {job_type} + {target_type}: {target_id} + {source} + {created_at} + + """ + + if not rows: + rows = 'Zatím nejsou evidované žádné joby.' + + return rows + + +def render_recent_deployments(deployments: list[dict]) -> str: + rows = "" + for deployment in deployments: + deployment_id = html.escape(str(deployment.get("id", ""))) + raw_app_id = deployment.get("app_id", "") or "" + app_id = html.escape(raw_app_id) + app_url_id = quote(raw_app_id, safe="") + kind = html.escape(deployment.get("kind", "") or "") + started_at = html.escape(deployment.get("started_at", "") or "") + finished_at = html.escape(deployment.get("finished_at", "") or "") + source = html.escape(deployment.get("trigger_source", "") or "") + + rows += f""" + + #{deployment_id} + {app_id} + {kind} + {render_status_pill(deployment.get("status"))} + {source} + {started_at} + {finished_at} + + """ + + if not rows: + rows = 'Zatím nejsou evidovaná žádná nasazení.' + + return rows + + +def render_recent_audit_events(events: list[dict]) -> str: + rows = "" + for event in events: + rows += f""" + + {html.escape(event.get("created_at", "") or "")} + {html.escape(event.get("username", "") or "")} + {html.escape(event.get("action", "") or "")} + {html.escape(event.get("target_type", "") or "")} + {html.escape(str(event.get("target_id") or ""))} + {html.escape(event.get("source", "") or "")} + + """ + + if not rows: + rows = 'Zatím nejsou evidované žádné auditní události.' + + return rows + + +@router.get("/operations", response_class=HTMLResponse) +def operations_dashboard(request: Request, user=Depends(require_user)): + summary = get_operations_summary() + log_audit_event( + user, + action="operations.dashboard.view", + target_type="operations", + metadata=summary, + ) + recent_jobs = get_recent_jobs() + recent_deployments = get_recent_deployments() + recent_audit_events = get_recent_audit_events() + + return page( + "Operations", + f""" +
+

Operations Dashboard

+

Provozní přehled workerů, jobů, deploymentů a auditních událostí.

+
+ +
+
Workers Online{summary.get("workers_online") or 0}
+
Running Jobs{summary.get("running_jobs") or 0}
+
Queued Jobs{summary.get("queued_jobs") or 0}
+
Failed Jobs (24h){summary.get("failed_jobs_24h") or 0}
+
Deployments Today{summary.get("deployments_today") or 0}
+
Active Applications{summary.get("active_applications") or 0}
+
+ +
+

Recent Jobs

+ + + + + + + + + + {render_recent_jobs(recent_jobs)} +
IDStatusTypeTargetSourceCreated At
+
+ +
+

Recent Deployments

+ + + + + + + + + + + {render_recent_deployments(recent_deployments)} +
IDAplikaceTypStatusZdrojSpuštěnoDokončeno
+
+ +
+

Recent Audit Events

+ + + + + + + + + + {render_recent_audit_events(recent_audit_events)} +
ČasUživatelAkceTyp cíleCílZdroj
+
+ """, + user=user, + ) diff --git a/app/templates/layout.py b/app/templates/layout.py index 43cfd3f..240317e 100644 --- a/app/templates/layout.py +++ b/app/templates/layout.py @@ -16,6 +16,7 @@ def page(title: str, body: str, user=None) -> str: Nová aplikace Nasazení Joby + Operations -> Dashboard Operations -> Workers Zálohy Audit