operations
This commit is contained in:
@@ -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]
|
||||||
+2
-1
@@ -5,7 +5,7 @@ from fastapi.staticfiles import StaticFiles
|
|||||||
from starlette.middleware.sessions import SessionMiddleware
|
from starlette.middleware.sessions import SessionMiddleware
|
||||||
|
|
||||||
from .config import read_env_value
|
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:
|
def create_app() -> FastAPI:
|
||||||
@@ -27,6 +27,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(apps.router)
|
app.include_router(apps.router)
|
||||||
app.include_router(backups.router)
|
app.include_router(backups.router)
|
||||||
app.include_router(deployments.router)
|
app.include_router(deployments.router)
|
||||||
|
app.include_router(operations.router)
|
||||||
app.include_router(jobs.router)
|
app.include_router(jobs.router)
|
||||||
app.include_router(workers.router)
|
app.include_router(workers.router)
|
||||||
app.include_router(audit.router)
|
app.include_router(audit.router)
|
||||||
|
|||||||
@@ -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"""
|
||||||
|
<tr>
|
||||||
|
<td><strong><a href="/portal/jobs/{job_id}">#{job_id}</a></strong></td>
|
||||||
|
<td>{render_job_status(job.get("status"))}</td>
|
||||||
|
<td>{job_type}</td>
|
||||||
|
<td>{target_type}: <strong>{target_id}</strong></td>
|
||||||
|
<td>{source}</td>
|
||||||
|
<td>{created_at}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
rows = '<tr><td colspan="6">Zatím nejsou evidované žádné joby.</td></tr>'
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<tr>
|
||||||
|
<td><strong><a href="/portal/deployments/{deployment_id}">#{deployment_id}</a></strong></td>
|
||||||
|
<td><a href="/portal/apps/{app_url_id}">{app_id}</a></td>
|
||||||
|
<td>{kind}</td>
|
||||||
|
<td>{render_status_pill(deployment.get("status"))}</td>
|
||||||
|
<td>{source}</td>
|
||||||
|
<td>{started_at}</td>
|
||||||
|
<td>{finished_at}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
rows = '<tr><td colspan="7">Zatím nejsou evidovaná žádná nasazení.</td></tr>'
|
||||||
|
|
||||||
|
return rows
|
||||||
|
|
||||||
|
|
||||||
|
def render_recent_audit_events(events: list[dict]) -> str:
|
||||||
|
rows = ""
|
||||||
|
for event in events:
|
||||||
|
rows += f"""
|
||||||
|
<tr>
|
||||||
|
<td>{html.escape(event.get("created_at", "") or "")}</td>
|
||||||
|
<td>{html.escape(event.get("username", "") or "")}</td>
|
||||||
|
<td><span class="pill pill-muted">{html.escape(event.get("action", "") or "")}</span></td>
|
||||||
|
<td>{html.escape(event.get("target_type", "") or "")}</td>
|
||||||
|
<td>{html.escape(str(event.get("target_id") or ""))}</td>
|
||||||
|
<td>{html.escape(event.get("source", "") or "")}</td>
|
||||||
|
</tr>
|
||||||
|
"""
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
rows = '<tr><td colspan="6">Zatím nejsou evidované žádné auditní události.</td></tr>'
|
||||||
|
|
||||||
|
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"""
|
||||||
|
<div class="card">
|
||||||
|
<h2>Operations Dashboard</h2>
|
||||||
|
<p class="muted">Provozní přehled workerů, jobů, deploymentů a auditních událostí.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card stat-success"><span>Workers Online</span><strong>{summary.get("workers_online") or 0}</strong></div>
|
||||||
|
<div class="stat-card stat-warning"><span>Running Jobs</span><strong>{summary.get("running_jobs") or 0}</strong></div>
|
||||||
|
<div class="stat-card stat-warning"><span>Queued Jobs</span><strong>{summary.get("queued_jobs") or 0}</strong></div>
|
||||||
|
<div class="stat-card stat-danger"><span>Failed Jobs (24h)</span><strong>{summary.get("failed_jobs_24h") or 0}</strong></div>
|
||||||
|
<div class="stat-card stat-total"><span>Deployments Today</span><strong>{summary.get("deployments_today") or 0}</strong></div>
|
||||||
|
<div class="stat-card stat-total"><span>Active Applications</span><strong>{summary.get("active_applications") or 0}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent Jobs</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Type</th>
|
||||||
|
<th>Target</th>
|
||||||
|
<th>Source</th>
|
||||||
|
<th>Created At</th>
|
||||||
|
</tr>
|
||||||
|
{render_recent_jobs(recent_jobs)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent Deployments</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Aplikace</th>
|
||||||
|
<th>Typ</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Zdroj</th>
|
||||||
|
<th>Spuštěno</th>
|
||||||
|
<th>Dokončeno</th>
|
||||||
|
</tr>
|
||||||
|
{render_recent_deployments(recent_deployments)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<h2>Recent Audit Events</h2>
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<th>Čas</th>
|
||||||
|
<th>Uživatel</th>
|
||||||
|
<th>Akce</th>
|
||||||
|
<th>Typ cíle</th>
|
||||||
|
<th>Cíl</th>
|
||||||
|
<th>Zdroj</th>
|
||||||
|
</tr>
|
||||||
|
{render_recent_audit_events(recent_audit_events)}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
""",
|
||||||
|
user=user,
|
||||||
|
)
|
||||||
@@ -16,6 +16,7 @@ def page(title: str, body: str, user=None) -> str:
|
|||||||
<a href="/portal/new-app">Nová aplikace</a>
|
<a href="/portal/new-app">Nová aplikace</a>
|
||||||
<a href="/portal/deployments">Nasazení</a>
|
<a href="/portal/deployments">Nasazení</a>
|
||||||
<a href="/portal/jobs">Joby</a>
|
<a href="/portal/jobs">Joby</a>
|
||||||
|
<a href="/portal/operations">Operations -> Dashboard</a>
|
||||||
<a href="/portal/workers">Operations -> Workers</a>
|
<a href="/portal/workers">Operations -> Workers</a>
|
||||||
<a href="/portal/backups">Zálohy</a>
|
<a href="/portal/backups">Zálohy</a>
|
||||||
<a href="/portal/audit">Audit</a>
|
<a href="/portal/audit">Audit</a>
|
||||||
|
|||||||
Reference in New Issue
Block a user