diff --git a/app/db/jobs.py b/app/db/jobs.py
index f3d43bc..05d4a46 100644
--- a/app/db/jobs.py
+++ b/app/db/jobs.py
@@ -248,6 +248,7 @@ def get_next_queued_job():
def get_jobs(
limit: int = 100,
+ offset: int = 0,
status: str | None = None,
job_type: str | None = None,
target: str | None = None,
@@ -282,32 +283,76 @@ def get_jobs(
END,
id DESC
LIMIT ?
+ OFFSET ?
""",
- (*params, limit),
+ (*params, limit, offset),
).fetchall()
con.close()
return [dict(row) for row in rows]
+def count_jobs(
+ status: str | None = None,
+ job_type: str | None = None,
+ target: str | None = None,
+):
+ run_migrations()
+ con = get_connection()
+
+ filters = []
+ params: list[Any] = []
+ if status:
+ filters.append("status = ?")
+ params.append(status)
+ if job_type:
+ filters.append("type = ?")
+ params.append(job_type)
+ if target:
+ filters.append("(target_type LIKE ? OR target_id LIKE ?)")
+ target_pattern = f"%{target}%"
+ params.extend([target_pattern, target_pattern])
+
+ where_sql = f"WHERE {' AND '.join(filters)}" if filters else ""
+ row = con.execute(
+ f"""
+ SELECT COUNT(*) AS count
+ FROM jobs
+ {where_sql}
+ """,
+ params,
+ ).fetchone()
+
+ con.close()
+ return row["count"] if row else 0
+
+
def get_job_stats():
run_migrations()
con = get_connection()
rows = con.execute(
"""
- SELECT status, COUNT(*) AS count
+ SELECT
+ SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END) AS queued,
+ SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running,
+ SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) AS failed,
+ SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) AS success,
+ SUM(CASE WHEN status = 'failed' AND created_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS failed_24h,
+ SUM(CASE WHEN status = 'success' AND created_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS success_24h
FROM jobs
- WHERE status IN ('queued', 'running', 'failed', 'success')
- GROUP BY status
"""
- ).fetchall()
+ ).fetchone()
con.close()
- stats = {"queued": 0, "running": 0, "failed": 0, "success": 0}
- for row in rows:
- stats[row["status"]] = row["count"]
- return stats
+ return dict(rows) if rows else {
+ "queued": 0,
+ "running": 0,
+ "failed": 0,
+ "success": 0,
+ "failed_24h": 0,
+ "success_24h": 0,
+ }
def get_job(job_id: int):
diff --git a/app/db/operations.py b/app/db/operations.py
index 2383962..e2a6ffd 100644
--- a/app/db/operations.py
+++ b/app/db/operations.py
@@ -2,6 +2,10 @@ from app.db.database import get_connection
from app.db.migrations import run_migrations
from app.db.workers import is_worker_online
+DEPLOYMENT_SUCCESS_STATUSES = ("ok", "success", "succeeded", "done", "deployed", "completed")
+DEPLOYMENT_FAILED_STATUSES = ("failed", "failure", "error", "cancelled", "canceled")
+DEPLOYMENT_RUNNING_STATUSES = ("running", "pending", "queued", "in_progress", "starting")
+
def _table_columns(con, table_name: str) -> set[str]:
rows = con.execute(f"PRAGMA table_info({table_name})").fetchall()
@@ -142,3 +146,100 @@ def get_recent_audit_events(limit: int = 20):
con.close()
return [dict(row) for row in rows]
+
+
+def get_operations_snapshot():
+ run_migrations()
+ con = get_connection()
+
+ workers = con.execute(
+ """
+ SELECT id, status, started_at, last_seen_at, current_job_id, metadata_json
+ FROM workers
+ ORDER BY last_seen_at DESC, id
+ LIMIT 20
+ """
+ ).fetchall()
+ recent_workers = []
+ workers_online = 0
+ for row in workers:
+ worker = dict(row)
+ worker["online"] = is_worker_online(worker.get("last_seen_at"))
+ workers_online += 1 if worker["online"] else 0
+ recent_workers.append(worker)
+
+ total_workers = con.execute("SELECT COUNT(*) AS count FROM workers").fetchone()["count"]
+ online_all = con.execute("SELECT last_seen_at FROM workers").fetchall()
+ workers_online = sum(1 for worker in online_all if is_worker_online(worker["last_seen_at"]))
+
+ jobs_summary = con.execute(
+ """
+ SELECT
+ SUM(CASE WHEN status = 'queued' THEN 1 ELSE 0 END) AS queued,
+ SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END) AS running,
+ SUM(CASE WHEN status = 'failed' AND created_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS failed_24h,
+ SUM(CASE WHEN status = 'success' AND created_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS success_24h
+ FROM jobs
+ """
+ ).fetchone()
+ recent_jobs = con.execute(
+ """
+ SELECT id, type, target_type, target_id, status, source, created_at, started_at, finished_at
+ FROM jobs
+ ORDER BY id DESC
+ LIMIT 20
+ """
+ ).fetchall()
+
+ deployments_summary = con.execute(
+ f"""
+ SELECT
+ SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in DEPLOYMENT_RUNNING_STATUSES)}) THEN 1 ELSE 0 END) AS running,
+ SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in DEPLOYMENT_FAILED_STATUSES)}) AND started_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS failed_24h,
+ SUM(CASE WHEN LOWER(COALESCE(status, '')) IN ({','.join('?' for _ in DEPLOYMENT_SUCCESS_STATUSES)}) AND started_at >= datetime('now', '-24 hours') THEN 1 ELSE 0 END) AS success_24h
+ FROM deployments
+ """,
+ (*DEPLOYMENT_RUNNING_STATUSES, *DEPLOYMENT_FAILED_STATUSES, *DEPLOYMENT_SUCCESS_STATUSES),
+ ).fetchone()
+ recent_deployments = con.execute(
+ """
+ SELECT id, app_id, kind, status, started_at, finished_at, trigger_source
+ FROM deployments
+ ORDER BY id DESC
+ LIMIT 20
+ """
+ ).fetchall()
+ recent_audit = con.execute(
+ """
+ SELECT id, username, action, target_type, target_id, source, created_at
+ FROM audit_events
+ ORDER BY id DESC
+ LIMIT 20
+ """
+ ).fetchall()
+
+ con.close()
+ return {
+ "type": "operations.snapshot",
+ "jobs": {
+ "queued": jobs_summary["queued"] or 0,
+ "running": jobs_summary["running"] or 0,
+ "failed_24h": jobs_summary["failed_24h"] or 0,
+ "success_24h": jobs_summary["success_24h"] or 0,
+ "recent": [dict(row) for row in recent_jobs],
+ },
+ "workers": {
+ "online": workers_online,
+ "offline": max(0, total_workers - workers_online),
+ "recent": recent_workers,
+ },
+ "deployments": {
+ "running": deployments_summary["running"] or 0,
+ "failed_24h": deployments_summary["failed_24h"] or 0,
+ "success_24h": deployments_summary["success_24h"] or 0,
+ "recent": [dict(row) for row in recent_deployments],
+ },
+ "audit": {
+ "recent": [dict(row) for row in recent_audit],
+ },
+ }
diff --git a/app/routes/apps.py b/app/routes/apps.py
index cdcb588..023223d 100644
--- a/app/routes/apps.py
+++ b/app/routes/apps.py
@@ -129,7 +129,6 @@ def index(request: Request, user=Depends(require_user)):
Aplikace
Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.
-
+ Nová aplikace
Zálohy
@@ -145,6 +144,7 @@ def index(request: Request, user=Depends(require_user)):
Nasazené aplikace
+
+ Nová aplikace
| Aplikace |
diff --git a/app/routes/deployments.py b/app/routes/deployments.py
index 76f9075..c328417 100644
--- a/app/routes/deployments.py
+++ b/app/routes/deployments.py
@@ -161,9 +161,7 @@ async def deployments_page(
)
stats = get_deployment_stats()
options = get_deployment_filter_options()
- refresh = "" if any(
- is_running(item.get("status")) for item in deployments
- ) else ""
+ refresh = ""
rows = ""
for deployment in deployments:
@@ -320,9 +318,7 @@ async def deployment_detail_page(
returncode_label = "" if returncode is None else html.escape(str(returncode))
stdout = html.escape(deployment.get("stdout", "") or "")
stderr = html.escape(deployment.get("stderr", "") or "")
- refresh = "" if is_running(
- deployment.get("status")
- ) else ""
+ refresh = ""
failed_notice = (
f'Deployment selhal. Návratový kód: {returncode_label}
'
if is_failed(deployment.get("status"))
diff --git a/app/routes/jobs.py b/app/routes/jobs.py
index 95ecef9..a0a695a 100644
--- a/app/routes/jobs.py
+++ b/app/routes/jobs.py
@@ -1,14 +1,15 @@
import asyncio
import html
import json
-from urllib.parse import quote
+import math
+from urllib.parse import quote, urlencode
from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth import current_user, require_user
from app.db.audit import log_audit_event
-from app.db.jobs import cancel_job, get_job, get_job_logs, get_job_logs_after, get_job_stats, get_jobs, retry_failed_job
+from app.db.jobs import cancel_job, count_jobs, get_job, get_job_logs, get_job_logs_after, get_job_stats, get_jobs, retry_failed_job
from app.routes.deployments import calculate_duration, render_status_pill
from app.templates.layout import page
@@ -20,6 +21,7 @@ JOB_STATUSES = {"queued", "running", "cancelled_requested", "cancelled", "succes
JOB_FILTER_STATUSES = ("queued", "running", "success", "failed", "cancelled")
JOB_FILTER_TYPES = ("deploy_app", "deploy_core_service")
IGNORED_RETRY_REPOSITORIES = {"appfactory-tools", "appfactory-infrastructure"}
+DEFAULT_PAGE_SIZE = 20
def render_job_status(status: str | None) -> str:
@@ -130,6 +132,7 @@ def jobs_page(
status: str = Query(""),
job_type: str = Query("", alias="type"),
target: str = Query(""),
+ page_number: int = Query(1, alias="page", ge=1),
user=Depends(require_user),
):
selected_status = status.strip().lower()
@@ -140,15 +143,25 @@ def jobs_page(
if selected_type not in JOB_FILTER_TYPES:
selected_type = ""
+ total_jobs = count_jobs(
+ status=selected_status or None,
+ job_type=selected_type or None,
+ target=selected_target or None,
+ )
+ total_pages = max(1, math.ceil(total_jobs / DEFAULT_PAGE_SIZE))
+ if page_number > total_pages:
+ page_number = total_pages
+ offset = (page_number - 1) * DEFAULT_PAGE_SIZE
+
jobs = get_jobs(
+ limit=DEFAULT_PAGE_SIZE,
+ offset=offset,
status=selected_status or None,
job_type=selected_type or None,
target=selected_target or None,
)
stats = get_job_stats()
- refresh = "" if any(
- (job.get("status") or "").lower() in LIVE_STATUSES for job in jobs
- ) else ""
+ refresh = ""
rows = ""
for job in jobs:
@@ -187,6 +200,29 @@ def jobs_page(
status_options = render_options(JOB_FILTER_STATUSES, selected_status, "Všechny statusy")
type_options = render_options(JOB_FILTER_TYPES, selected_type, "Všechny typy")
target_value = html.escape(selected_target)
+ first_item = offset + 1 if total_jobs else 0
+ last_item = min(offset + len(jobs), total_jobs)
+
+ def page_url(page: int) -> str:
+ params = {"page": page}
+ if selected_status:
+ params["status"] = selected_status
+ if selected_type:
+ params["type"] = selected_type
+ if selected_target:
+ params["target"] = selected_target
+ return f"/portal/jobs?{urlencode(params)}"
+
+ pagination = f"""
+
+ """
return page(
"Joby",
@@ -198,10 +234,10 @@ def jobs_page(
-
Queued{stats.get("queued") or 0}
-
Running{stats.get("running") or 0}
-
Failed{stats.get("failed") or 0}
-
Success{stats.get("success") or 0}
+
Queued{stats.get("queued") or 0}
+
Running{stats.get("running") or 0}
+
Failed (24h){stats.get("failed_24h") or 0}
+
Success (24h){stats.get("success_24h") or 0}
+
+
Recent Jobs
+
+
+ | ID |
+ Status |
+ Type |
+ Target |
+ Source |
+ Created At |
+
+
+
+
+
Fronta
+ {pagination}
| ID |
@@ -229,9 +282,11 @@ def jobs_page(
Finished at |
Akce |
- {rows}
+ {rows}
+ {pagination}
+
""",
user=user,
)
diff --git a/app/routes/operations.py b/app/routes/operations.py
index a80dc8e..0c3b30f 100644
--- a/app/routes/operations.py
+++ b/app/routes/operations.py
@@ -1,16 +1,14 @@
+import asyncio
import html
from urllib.parse import quote
-from fastapi import APIRouter, Depends, Request
+from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
-from app.auth import require_user
+from app.auth import current_user, 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,
+ get_operations_snapshot,
)
from app.routes.deployments import render_status_pill
from app.routes.jobs import render_job_status
@@ -19,6 +17,22 @@ from app.templates.layout import page
router = APIRouter()
+@router.websocket("/ws/operations")
+async def operations_websocket(websocket: WebSocket):
+ user = current_user(websocket)
+ if not user:
+ await websocket.close(code=1008)
+ return
+
+ await websocket.accept()
+ try:
+ while True:
+ await websocket.send_json(get_operations_snapshot())
+ await asyncio.sleep(2)
+ except WebSocketDisconnect:
+ return
+
+
def render_recent_jobs(jobs: list[dict]) -> str:
rows = ""
for job in jobs:
@@ -98,16 +112,27 @@ def render_recent_audit_events(events: list[dict]) -> str:
@router.get("/operations", response_class=HTMLResponse)
def operations_dashboard(request: Request, user=Depends(require_user)):
- summary = get_operations_summary()
+ snapshot = get_operations_snapshot()
log_audit_event(
user,
action="operations.dashboard.view",
target_type="operations",
- metadata=summary,
+ metadata={
+ "jobs": {
+ "queued": snapshot["jobs"]["queued"],
+ "running": snapshot["jobs"]["running"],
+ "failed_24h": snapshot["jobs"]["failed_24h"],
+ },
+ "workers": {
+ "online": snapshot["workers"]["online"],
+ "offline": snapshot["workers"]["offline"],
+ },
+ "deployments": {
+ "running": snapshot["deployments"]["running"],
+ "failed_24h": snapshot["deployments"]["failed_24h"],
+ },
+ },
)
- recent_jobs = get_recent_jobs()
- recent_deployments = get_recent_deployments()
- recent_audit_events = get_recent_audit_events()
return page(
"Operations",
@@ -118,12 +143,12 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
-
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}
+
Workers Online{snapshot["workers"]["online"]}
+
Running Jobs{snapshot["jobs"]["running"]}
+
Queued Jobs{snapshot["jobs"]["queued"]}
+
Failed Jobs (24h){snapshot["jobs"]["failed_24h"]}
+
Running Deployments{snapshot["deployments"]["running"]}
+
Failed Deployments (24h){snapshot["deployments"]["failed_24h"]}
@@ -137,7 +162,7 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
Source |
Created At |
- {render_recent_jobs(recent_jobs)}
+ {render_recent_jobs(snapshot["jobs"]["recent"])}
@@ -153,7 +178,7 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
Spuštěno |
Dokončeno |
- {render_recent_deployments(recent_deployments)}
+
{render_recent_deployments(snapshot["deployments"]["recent"])}
@@ -168,9 +193,10 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
Cíl |
Zdroj |
- {render_recent_audit_events(recent_audit_events)}
+ {render_recent_audit_events(snapshot["audit"]["recent"])}
+
""",
user=user,
)
diff --git a/app/routes/workers.py b/app/routes/workers.py
index 2f68293..80d99b0 100644
--- a/app/routes/workers.py
+++ b/app/routes/workers.py
@@ -1,8 +1,9 @@
import html
import json
-from urllib.parse import quote
+import math
+from urllib.parse import quote, urlencode
-from fastapi import APIRouter, Depends, HTTPException, Request
+from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth import require_user
@@ -11,6 +12,7 @@ from app.db.workers import get_worker, get_workers
from app.templates.layout import page
router = APIRouter()
+DEFAULT_PAGE_SIZE = 20
def render_worker_badge(online: bool) -> str:
@@ -30,11 +32,35 @@ def pretty_json(value: str | None) -> str:
@router.get("/workers", response_class=HTMLResponse)
-def workers_page(request: Request, user=Depends(require_user)):
- workers = get_workers()
- online_count = sum(1 for worker in workers if worker.get("online"))
- total_count = len(workers)
+def workers_page(
+ request: Request,
+ status: str = Query(""),
+ q: str = Query(""),
+ page_number: int = Query(1, alias="page", ge=1),
+ user=Depends(require_user),
+):
+ all_workers = get_workers()
+ selected_status = status.strip().lower()
+ query = q.strip()
+ online_count = sum(1 for worker in all_workers if worker.get("online"))
+ total_count = len(all_workers)
offline_count = total_count - online_count
+ workers = all_workers
+ if selected_status == "online":
+ workers = [worker for worker in workers if worker.get("online")]
+ elif selected_status == "offline":
+ workers = [worker for worker in workers if not worker.get("online")]
+ else:
+ selected_status = ""
+ if query:
+ workers = [worker for worker in workers if query.lower() in (worker.get("id", "") or "").lower()]
+
+ total_filtered = len(workers)
+ total_pages = max(1, math.ceil(total_filtered / DEFAULT_PAGE_SIZE))
+ if page_number > total_pages:
+ page_number = total_pages
+ offset = (page_number - 1) * DEFAULT_PAGE_SIZE
+ workers = workers[offset : offset + DEFAULT_PAGE_SIZE]
log_audit_event(
user,
@@ -63,13 +89,13 @@ def workers_page(request: Request, user=Depends(require_user)):
current_job = f'#{current_job_label}'
rows += f"""
-
+
|
{worker_id}
|
- {badge} {status} |
- {last_seen_at} |
- {current_job} |
+ {badge} {status} |
+ {last_seen_at} |
+ {current_job} |
{started_at} |
Detail |
@@ -77,6 +103,32 @@ def workers_page(request: Request, user=Depends(require_user)):
if not rows:
rows = '| Zatím nejsou evidovaní žádní workeři. |
'
+ live_table_attr = ' data-live-table="workers.recent"' if not selected_status and not query and page_number == 1 else ""
+ first_item = offset + 1 if total_filtered else 0
+ last_item = min(offset + len(workers), total_filtered)
+
+ def page_url(page: int) -> str:
+ params = {"page": page}
+ if selected_status:
+ params["status"] = selected_status
+ if query:
+ params["q"] = query
+ return f"/portal/workers?{urlencode(params)}"
+
+ pagination = f"""
+
+ """
+ status_options = "".join(
+ f''
+ for value, label in [("", "Všichni workeři"), ("online", "Online"), ("offline", "Offline")]
+ )
return page(
"Workers",
@@ -87,13 +139,25 @@ def workers_page(request: Request, user=Depends(require_user)):
-
Online Workers{online_count}
-
Offline Workers{offline_count}
-
Total Workers{total_count}
+
Online Workers{online_count}
+
Offline Workers{offline_count}
+
Total Workers{total_count}
+
+
+
Workers
+ {pagination}
| Worker ID |
@@ -103,9 +167,11 @@ def workers_page(request: Request, user=Depends(require_user)):
Started At |
Akce |
- {rows}
+ {rows}
+ {pagination}
+
""",
user=user,
)
diff --git a/app/static/operations-live.js b/app/static/operations-live.js
new file mode 100644
index 0000000..c4c866a
--- /dev/null
+++ b/app/static/operations-live.js
@@ -0,0 +1,164 @@
+(() => {
+ if (window.AppFactoryOperationsLive) {
+ return;
+ }
+
+ const state = {
+ socket: null,
+ reconnectTimer: null,
+ reconnectDelay: 1500,
+ };
+
+ const escapeHtml = (value) => String(value ?? "")
+ .replaceAll("&", "&")
+ .replaceAll("<", "<")
+ .replaceAll(">", ">")
+ .replaceAll('"', """)
+ .replaceAll("'", "'");
+
+ const statusBadge = (status) => {
+ const value = String(status || "");
+ const normalized = value.toLowerCase();
+ let className = "pill pill-muted";
+ if (["success", "ok", "succeeded", "done", "deployed", "completed"].includes(normalized)) {
+ className = "pill pill-success";
+ } else if (["queued", "running", "pending", "in_progress", "starting", "cancelled_requested"].includes(normalized)) {
+ className = "pill pill-warning";
+ } else if (["failed", "failure", "error", "cancelled", "canceled"].includes(normalized)) {
+ className = "pill pill-danger";
+ }
+ return `${escapeHtml(value)}`;
+ };
+
+ const workerBadge = (online) => online
+ ? 'ONLINE'
+ : 'OFFLINE';
+
+ const setText = (selector, value) => {
+ document.querySelectorAll(selector).forEach((el) => {
+ el.textContent = value ?? 0;
+ });
+ };
+
+ const renderJobs = (jobs) => {
+ const rows = (jobs || []).map((job) => `
+
+ | #${escapeHtml(job.id)} |
+ ${statusBadge(job.status)} |
+ ${escapeHtml(job.type)} |
+ ${escapeHtml(job.target_type)}: ${escapeHtml(job.target_id)} |
+ ${escapeHtml(job.source)} |
+ ${escapeHtml(job.created_at)} |
+
+ `).join("");
+ return rows || '| Zatím nejsou evidované žádné joby. |
';
+ };
+
+ const renderWorkers = (workers) => {
+ const rows = (workers || []).map((worker) => {
+ const currentJob = worker.current_job_id === null || worker.current_job_id === undefined
+ ? ""
+ : `#${escapeHtml(worker.current_job_id)}`;
+ return `
+
+ | ${escapeHtml(worker.id)} |
+ ${workerBadge(Boolean(worker.online))} ${escapeHtml(worker.status)} |
+ ${escapeHtml(worker.last_seen_at)} |
+ ${currentJob} |
+ ${escapeHtml(worker.started_at)} |
+ Detail |
+
+ `;
+ }).join("");
+ return rows || '| Zatím nejsou evidovaní žádní workeři. |
';
+ };
+
+ const renderDeployments = (deployments) => {
+ const rows = (deployments || []).map((deployment) => `
+
+ | #${escapeHtml(deployment.id)} |
+ ${escapeHtml(deployment.app_id)} |
+ ${escapeHtml(deployment.kind)} |
+ ${statusBadge(deployment.status)} |
+ ${escapeHtml(deployment.trigger_source)} |
+ ${escapeHtml(deployment.started_at)} |
+ ${escapeHtml(deployment.finished_at)} |
+
+ `).join("");
+ return rows || '| Zatím nejsou evidovaná žádná nasazení. |
';
+ };
+
+ const renderAudit = (events) => {
+ const rows = (events || []).map((event) => `
+
+ | ${escapeHtml(event.created_at)} |
+ ${escapeHtml(event.username)} |
+ ${escapeHtml(event.action)} |
+ ${escapeHtml(event.target_type)} |
+ ${escapeHtml(event.target_id)} |
+ ${escapeHtml(event.source)} |
+
+ `).join("");
+ return rows || '| Zatím nejsou evidované žádné auditní události. |
';
+ };
+
+ const renderSnapshot = (snapshot) => {
+ setText("[data-live-count='jobs.queued']", snapshot.jobs?.queued);
+ setText("[data-live-count='jobs.running']", snapshot.jobs?.running);
+ setText("[data-live-count='jobs.failed_24h']", snapshot.jobs?.failed_24h);
+ setText("[data-live-count='jobs.success_24h']", snapshot.jobs?.success_24h);
+ setText("[data-live-count='workers.online']", snapshot.workers?.online);
+ setText("[data-live-count='workers.offline']", snapshot.workers?.offline);
+ setText("[data-live-count='workers.total']", (snapshot.workers?.online || 0) + (snapshot.workers?.offline || 0));
+ setText("[data-live-count='deployments.running']", snapshot.deployments?.running);
+ setText("[data-live-count='deployments.failed_24h']", snapshot.deployments?.failed_24h);
+ setText("[data-live-count='deployments.success_24h']", snapshot.deployments?.success_24h);
+
+ document.querySelectorAll("[data-live-table='jobs.recent']").forEach((el) => { el.innerHTML = renderJobs(snapshot.jobs?.recent); });
+ document.querySelectorAll("[data-live-table='workers.recent']").forEach((el) => { el.innerHTML = renderWorkers(snapshot.workers?.recent); });
+ document.querySelectorAll("[data-live-table='deployments.recent']").forEach((el) => { el.innerHTML = renderDeployments(snapshot.deployments?.recent); });
+ document.querySelectorAll("[data-live-table='audit.recent']").forEach((el) => { el.innerHTML = renderAudit(snapshot.audit?.recent); });
+
+ (snapshot.workers?.recent || []).forEach((worker) => {
+ const row = Array.from(document.querySelectorAll("tr[data-worker-id]"))
+ .find((item) => item.dataset.workerId === String(worker.id || ""));
+ if (!row) {
+ return;
+ }
+ const currentJob = worker.current_job_id === null || worker.current_job_id === undefined
+ ? ""
+ : `#${escapeHtml(worker.current_job_id)}`;
+ const statusCell = row.querySelector("[data-worker-field='status']");
+ const lastSeenCell = row.querySelector("[data-worker-field='last_seen_at']");
+ const currentJobCell = row.querySelector("[data-worker-field='current_job']");
+ if (statusCell) statusCell.innerHTML = `${workerBadge(Boolean(worker.online))}
${escapeHtml(worker.status)}`;
+ if (lastSeenCell) lastSeenCell.textContent = worker.last_seen_at || "";
+ if (currentJobCell) currentJobCell.innerHTML = currentJob;
+ });
+ };
+
+ const connect = () => {
+ clearTimeout(state.reconnectTimer);
+ const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
+ state.socket = new WebSocket(`${protocol}//${window.location.host}/portal/ws/operations`);
+
+ state.socket.addEventListener("message", (event) => {
+ const snapshot = JSON.parse(event.data);
+ if (snapshot.type !== "operations.snapshot") {
+ return;
+ }
+ window.dispatchEvent(new CustomEvent("operations:snapshot", { detail: snapshot }));
+ renderSnapshot(snapshot);
+ });
+
+ state.socket.addEventListener("close", () => {
+ state.reconnectTimer = setTimeout(connect, state.reconnectDelay);
+ });
+ state.socket.addEventListener("error", () => {
+ state.socket.close();
+ });
+ };
+
+ window.AppFactoryOperationsLive = { connect };
+ connect();
+})();
diff --git a/app/templates/layout.py b/app/templates/layout.py
index 240317e..15c7e00 100644
--- a/app/templates/layout.py
+++ b/app/templates/layout.py
@@ -12,14 +12,13 @@ def page(title: str, body: str, user=None) -> str:
display_name = html.escape(user.get("display_name") or user.get("username", ""))
nav = """
"""
user_panel = f"""