From d145161e4e7cb7a7b4db7062c135e970681bee35 Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Fri, 29 May 2026 11:17:17 +0200 Subject: [PATCH] Add unified operations realtime dashboard - add /portal/ws/operations WebSocket snapshot endpoint - add shared operations-live.js with reconnect and operations:snapshot events - update Operations, Jobs, and Workers pages to use realtime snapshots - keep job detail live logs on dedicated /ws/jobs/{job_id}/logs endpoint - add Jobs and Workers pagination/filtering - remove old page auto-refresh behavior from jobs/deployments - normalize main navigation labels - move New App CTA from menu to Apps page - add backup download action for admins - add Workers pages and operations dashboard data helpers --- app/db/jobs.py | 63 +++++++++++-- app/db/operations.py | 101 +++++++++++++++++++++ app/routes/apps.py | 2 +- app/routes/deployments.py | 8 +- app/routes/jobs.py | 75 +++++++++++++--- app/routes/operations.py | 66 +++++++++----- app/routes/workers.py | 94 ++++++++++++++++--- app/static/operations-live.js | 164 ++++++++++++++++++++++++++++++++++ app/templates/layout.py | 13 ++- 9 files changed, 519 insertions(+), 67 deletions(-) create mode 100644 app/static/operations-live.js 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

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}
@@ -210,13 +246,30 @@ def jobs_page( + Reset
+
+

Recent Jobs

+
Aplikace
+ + + + + + + + + +
IDStatusTypeTargetSourceCreated At
+
+

Fronta

+ {pagination} @@ -229,9 +282,11 @@ def jobs_page( - {rows} + {rows}
IDFinished at Akce
+ {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}
+
+ +
+

Filtry

+
+ + + + + Reset +

Workers

+ {pagination} @@ -103,9 +167,11 @@ def workers_page(request: Request, user=Depends(require_user)): - {rows} + {rows}
Worker IDStarted At Akce
+ {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"""