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
+
+
+ | ID |
+ Status |
+ Type |
+ Target |
+ Source |
+ Created At |
+
+ {render_recent_jobs(recent_jobs)}
+
+
+
+
+
Recent Deployments
+
+
+ | ID |
+ Aplikace |
+ Typ |
+ Status |
+ Zdroj |
+ Spuštěno |
+ Dokončeno |
+
+ {render_recent_deployments(recent_deployments)}
+
+
+
+
+
Recent Audit Events
+
+
+ | Čas |
+ Uživatel |
+ Akce |
+ Typ cíle |
+ Cíl |
+ Zdroj |
+
+ {render_recent_audit_events(recent_audit_events)}
+
+
+ """,
+ 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