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
This commit is contained in:
JiriUhlir
2026-05-29 11:17:17 +02:00
parent 0dcf9b7b05
commit d145161e4e
9 changed files with 519 additions and 67 deletions
+54 -9
View File
@@ -248,6 +248,7 @@ def get_next_queued_job():
def get_jobs( def get_jobs(
limit: int = 100, limit: int = 100,
offset: int = 0,
status: str | None = None, status: str | None = None,
job_type: str | None = None, job_type: str | None = None,
target: str | None = None, target: str | None = None,
@@ -282,32 +283,76 @@ def get_jobs(
END, END,
id DESC id DESC
LIMIT ? LIMIT ?
OFFSET ?
""", """,
(*params, limit), (*params, limit, offset),
).fetchall() ).fetchall()
con.close() con.close()
return [dict(row) for row in rows] 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(): def get_job_stats():
run_migrations() run_migrations()
con = get_connection() con = get_connection()
rows = con.execute( 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 FROM jobs
WHERE status IN ('queued', 'running', 'failed', 'success')
GROUP BY status
""" """
).fetchall() ).fetchone()
con.close() con.close()
stats = {"queued": 0, "running": 0, "failed": 0, "success": 0} return dict(rows) if rows else {
for row in rows: "queued": 0,
stats[row["status"]] = row["count"] "running": 0,
return stats "failed": 0,
"success": 0,
"failed_24h": 0,
"success_24h": 0,
}
def get_job(job_id: int): def get_job(job_id: int):
+101
View File
@@ -2,6 +2,10 @@ from app.db.database import get_connection
from app.db.migrations import run_migrations from app.db.migrations import run_migrations
from app.db.workers import is_worker_online 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]: def _table_columns(con, table_name: str) -> set[str]:
rows = con.execute(f"PRAGMA table_info({table_name})").fetchall() rows = con.execute(f"PRAGMA table_info({table_name})").fetchall()
@@ -142,3 +146,100 @@ def get_recent_audit_events(limit: int = 20):
con.close() con.close()
return [dict(row) for row in rows] 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],
},
}
+1 -1
View File
@@ -129,7 +129,6 @@ def index(request: Request, user=Depends(require_user)):
<div class="card"> <div class="card">
<h2>Aplikace</h2> <h2>Aplikace</h2>
<p class="muted">Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.</p> <p class="muted">Vytváření, nasazení, klonování, nastavení prostředků a mazání služeb.</p>
<a class="btn" href="/portal/new-app">+ Nová aplikace</a>
</div> </div>
<div class="card"> <div class="card">
<h2>Zálohy</h2> <h2>Zálohy</h2>
@@ -145,6 +144,7 @@ def index(request: Request, user=Depends(require_user)):
<div class="card"> <div class="card">
<h2>Nasazené aplikace</h2> <h2>Nasazené aplikace</h2>
<p><a class="btn" href="/portal/new-app">+ Nová aplikace</a></p>
<table> <table>
<tr> <tr>
<th>Aplikace</th> <th>Aplikace</th>
+2 -6
View File
@@ -161,9 +161,7 @@ async def deployments_page(
) )
stats = get_deployment_stats() stats = get_deployment_stats()
options = get_deployment_filter_options() options = get_deployment_filter_options()
refresh = "<script>setTimeout(() => window.location.reload(), 5000);</script>" if any( refresh = ""
is_running(item.get("status")) for item in deployments
) else ""
rows = "" rows = ""
for deployment in deployments: for deployment in deployments:
@@ -320,9 +318,7 @@ async def deployment_detail_page(
returncode_label = "" if returncode is None else html.escape(str(returncode)) returncode_label = "" if returncode is None else html.escape(str(returncode))
stdout = html.escape(deployment.get("stdout", "") or "") stdout = html.escape(deployment.get("stdout", "") or "")
stderr = html.escape(deployment.get("stderr", "") or "") stderr = html.escape(deployment.get("stderr", "") or "")
refresh = "<script>setTimeout(() => window.location.reload(), 5000);</script>" if is_running( refresh = ""
deployment.get("status")
) else ""
failed_notice = ( failed_notice = (
f'<div class="alert alert-danger">Deployment selhal. Návratový kód: {returncode_label}</div>' f'<div class="alert alert-danger">Deployment selhal. Návratový kód: {returncode_label}</div>'
if is_failed(deployment.get("status")) if is_failed(deployment.get("status"))
+65 -10
View File
@@ -1,14 +1,15 @@
import asyncio import asyncio
import html import html
import json 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 import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth import current_user, require_user from app.auth import current_user, require_user
from app.db.audit import log_audit_event 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.routes.deployments import calculate_duration, render_status_pill
from app.templates.layout import page 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_STATUSES = ("queued", "running", "success", "failed", "cancelled")
JOB_FILTER_TYPES = ("deploy_app", "deploy_core_service") JOB_FILTER_TYPES = ("deploy_app", "deploy_core_service")
IGNORED_RETRY_REPOSITORIES = {"appfactory-tools", "appfactory-infrastructure"} IGNORED_RETRY_REPOSITORIES = {"appfactory-tools", "appfactory-infrastructure"}
DEFAULT_PAGE_SIZE = 20
def render_job_status(status: str | None) -> str: def render_job_status(status: str | None) -> str:
@@ -130,6 +132,7 @@ def jobs_page(
status: str = Query(""), status: str = Query(""),
job_type: str = Query("", alias="type"), job_type: str = Query("", alias="type"),
target: str = Query(""), target: str = Query(""),
page_number: int = Query(1, alias="page", ge=1),
user=Depends(require_user), user=Depends(require_user),
): ):
selected_status = status.strip().lower() selected_status = status.strip().lower()
@@ -140,15 +143,25 @@ def jobs_page(
if selected_type not in JOB_FILTER_TYPES: if selected_type not in JOB_FILTER_TYPES:
selected_type = "" 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( jobs = get_jobs(
limit=DEFAULT_PAGE_SIZE,
offset=offset,
status=selected_status or None, status=selected_status or None,
job_type=selected_type or None, job_type=selected_type or None,
target=selected_target or None, target=selected_target or None,
) )
stats = get_job_stats() stats = get_job_stats()
refresh = "<script>setTimeout(() => window.location.reload(), 5000);</script>" if any( refresh = ""
(job.get("status") or "").lower() in LIVE_STATUSES for job in jobs
) else ""
rows = "" rows = ""
for job in jobs: for job in jobs:
@@ -187,6 +200,29 @@ def jobs_page(
status_options = render_options(JOB_FILTER_STATUSES, selected_status, "Všechny statusy") status_options = render_options(JOB_FILTER_STATUSES, selected_status, "Všechny statusy")
type_options = render_options(JOB_FILTER_TYPES, selected_type, "Všechny typy") type_options = render_options(JOB_FILTER_TYPES, selected_type, "Všechny typy")
target_value = html.escape(selected_target) 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"""
<div class="pagination">
<span>Zobrazeno {first_item}-{last_item} z {total_jobs}</span>
<div class="pagination-actions">
<a class="btn btn-secondary{' disabled' if page_number <= 1 else ''}" href="{page_url(max(1, page_number - 1))}">Předchozí</a>
<span class="page-indicator">Strana {page_number} / {total_pages}</span>
<a class="btn btn-secondary{' disabled' if page_number >= total_pages else ''}" href="{page_url(min(total_pages, page_number + 1))}">Další</a>
</div>
</div>
"""
return page( return page(
"Joby", "Joby",
@@ -198,10 +234,10 @@ def jobs_page(
</div> </div>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card stat-warning"><span>Queued</span><strong>{stats.get("queued") or 0}</strong></div> <div class="stat-card stat-warning"><span>Queued</span><strong data-live-count="jobs.queued">{stats.get("queued") or 0}</strong></div>
<div class="stat-card stat-warning"><span>Running</span><strong>{stats.get("running") or 0}</strong></div> <div class="stat-card stat-warning"><span>Running</span><strong data-live-count="jobs.running">{stats.get("running") or 0}</strong></div>
<div class="stat-card stat-danger"><span>Failed</span><strong>{stats.get("failed") or 0}</strong></div> <div class="stat-card stat-danger"><span>Failed (24h)</span><strong data-live-count="jobs.failed_24h">{stats.get("failed_24h") or 0}</strong></div>
<div class="stat-card stat-success"><span>Success</span><strong>{stats.get("success") or 0}</strong></div> <div class="stat-card stat-success"><span>Success (24h)</span><strong data-live-count="jobs.success_24h">{stats.get("success_24h") or 0}</strong></div>
</div> </div>
<div class="card"> <div class="card">
@@ -210,13 +246,30 @@ def jobs_page(
<select name="status">{status_options}</select> <select name="status">{status_options}</select>
<select name="type">{type_options}</select> <select name="type">{type_options}</select>
<input name="target" value="{target_value}" placeholder="Target"> <input name="target" value="{target_value}" placeholder="Target">
<input type="hidden" name="page" value="1">
<button type="submit">Filtrovat</button> <button type="submit">Filtrovat</button>
<a class="btn btn-secondary" href="/portal/jobs">Reset</a> <a class="btn btn-secondary" href="/portal/jobs">Reset</a>
</form> </form>
</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>
<tbody data-live-table="jobs.recent"></tbody>
</table>
</div>
<div class="card"> <div class="card">
<h2>Fronta</h2> <h2>Fronta</h2>
{pagination}
<table> <table>
<tr> <tr>
<th>ID</th> <th>ID</th>
@@ -229,9 +282,11 @@ def jobs_page(
<th>Finished at</th> <th>Finished at</th>
<th>Akce</th> <th>Akce</th>
</tr> </tr>
{rows} <tbody>{rows}</tbody>
</table> </table>
{pagination}
</div> </div>
<script src="/portal/static/operations-live.js"></script>
""", """,
user=user, user=user,
) )
+46 -20
View File
@@ -1,16 +1,14 @@
import asyncio
import html import html
from urllib.parse import quote 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 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.audit import log_audit_event
from app.db.operations import ( from app.db.operations import (
get_operations_summary, get_operations_snapshot,
get_recent_audit_events,
get_recent_deployments,
get_recent_jobs,
) )
from app.routes.deployments import render_status_pill from app.routes.deployments import render_status_pill
from app.routes.jobs import render_job_status from app.routes.jobs import render_job_status
@@ -19,6 +17,22 @@ from app.templates.layout import page
router = APIRouter() 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: def render_recent_jobs(jobs: list[dict]) -> str:
rows = "" rows = ""
for job in jobs: for job in jobs:
@@ -98,16 +112,27 @@ def render_recent_audit_events(events: list[dict]) -> str:
@router.get("/operations", response_class=HTMLResponse) @router.get("/operations", response_class=HTMLResponse)
def operations_dashboard(request: Request, user=Depends(require_user)): def operations_dashboard(request: Request, user=Depends(require_user)):
summary = get_operations_summary() snapshot = get_operations_snapshot()
log_audit_event( log_audit_event(
user, user,
action="operations.dashboard.view", action="operations.dashboard.view",
target_type="operations", 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( return page(
"Operations", "Operations",
@@ -118,12 +143,12 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
</div> </div>
<div class="stats-grid"> <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-success"><span>Workers Online</span><strong data-live-count="workers.online">{snapshot["workers"]["online"]}</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>Running Jobs</span><strong data-live-count="jobs.running">{snapshot["jobs"]["running"]}</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-warning"><span>Queued Jobs</span><strong data-live-count="jobs.queued">{snapshot["jobs"]["queued"]}</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-danger"><span>Failed Jobs (24h)</span><strong data-live-count="jobs.failed_24h">{snapshot["jobs"]["failed_24h"]}</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-warning"><span>Running Deployments</span><strong data-live-count="deployments.running">{snapshot["deployments"]["running"]}</strong></div>
<div class="stat-card stat-total"><span>Active Applications</span><strong>{summary.get("active_applications") or 0}</strong></div> <div class="stat-card stat-danger"><span>Failed Deployments (24h)</span><strong data-live-count="deployments.failed_24h">{snapshot["deployments"]["failed_24h"]}</strong></div>
</div> </div>
<div class="card"> <div class="card">
@@ -137,7 +162,7 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
<th>Source</th> <th>Source</th>
<th>Created At</th> <th>Created At</th>
</tr> </tr>
{render_recent_jobs(recent_jobs)} <tbody data-live-table="jobs.recent">{render_recent_jobs(snapshot["jobs"]["recent"])}</tbody>
</table> </table>
</div> </div>
@@ -153,7 +178,7 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
<th>Spuštěno</th> <th>Spuštěno</th>
<th>Dokončeno</th> <th>Dokončeno</th>
</tr> </tr>
{render_recent_deployments(recent_deployments)} <tbody data-live-table="deployments.recent">{render_recent_deployments(snapshot["deployments"]["recent"])}</tbody>
</table> </table>
</div> </div>
@@ -168,9 +193,10 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
<th>Cíl</th> <th>Cíl</th>
<th>Zdroj</th> <th>Zdroj</th>
</tr> </tr>
{render_recent_audit_events(recent_audit_events)} <tbody data-live-table="audit.recent">{render_recent_audit_events(snapshot["audit"]["recent"])}</tbody>
</table> </table>
</div> </div>
<script src="/portal/static/operations-live.js"></script>
""", """,
user=user, user=user,
) )
+80 -14
View File
@@ -1,8 +1,9 @@
import html import html
import json 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 fastapi.responses import HTMLResponse
from app.auth import require_user 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 from app.templates.layout import page
router = APIRouter() router = APIRouter()
DEFAULT_PAGE_SIZE = 20
def render_worker_badge(online: bool) -> str: def render_worker_badge(online: bool) -> str:
@@ -30,11 +32,35 @@ def pretty_json(value: str | None) -> str:
@router.get("/workers", response_class=HTMLResponse) @router.get("/workers", response_class=HTMLResponse)
def workers_page(request: Request, user=Depends(require_user)): def workers_page(
workers = get_workers() request: Request,
online_count = sum(1 for worker in workers if worker.get("online")) status: str = Query(""),
total_count = len(workers) 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 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( log_audit_event(
user, user,
@@ -63,13 +89,13 @@ def workers_page(request: Request, user=Depends(require_user)):
current_job = f'<a href="/portal/jobs/{current_job_label}">#{current_job_label}</a>' current_job = f'<a href="/portal/jobs/{current_job_label}">#{current_job_label}</a>'
rows += f""" rows += f"""
<tr> <tr data-worker-id="{worker_id}">
<td> <td>
<strong><a href="/portal/workers/{worker_url_id}">{worker_id}</a></strong> <strong><a href="/portal/workers/{worker_url_id}">{worker_id}</a></strong>
</td> </td>
<td>{badge}<br><span class="muted">{status}</span></td> <td data-worker-field="status">{badge}<br><span class="muted">{status}</span></td>
<td>{last_seen_at}</td> <td data-worker-field="last_seen_at">{last_seen_at}</td>
<td>{current_job}</td> <td data-worker-field="current_job">{current_job}</td>
<td>{started_at}</td> <td>{started_at}</td>
<td class="actions-cell"><a class="btn" href="/portal/workers/{worker_url_id}">Detail</a></td> <td class="actions-cell"><a class="btn" href="/portal/workers/{worker_url_id}">Detail</a></td>
</tr> </tr>
@@ -77,6 +103,32 @@ def workers_page(request: Request, user=Depends(require_user)):
if not rows: if not rows:
rows = '<tr><td colspan="6">Zatím nejsou evidovaní žádní workeři.</td></tr>' rows = '<tr><td colspan="6">Zatím nejsou evidovaní žádní workeři.</td></tr>'
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"""
<div class="pagination">
<span>Zobrazeno {first_item}-{last_item} z {total_filtered}</span>
<div class="pagination-actions">
<a class="btn btn-secondary{' disabled' if page_number <= 1 else ''}" href="{page_url(max(1, page_number - 1))}">Předchozí</a>
<span class="page-indicator">Strana {page_number} / {total_pages}</span>
<a class="btn btn-secondary{' disabled' if page_number >= total_pages else ''}" href="{page_url(min(total_pages, page_number + 1))}">Další</a>
</div>
</div>
"""
status_options = "".join(
f'<option value="{value}"{" selected" if selected_status == value else ""}>{label}</option>'
for value, label in [("", "Všichni workeři"), ("online", "Online"), ("offline", "Offline")]
)
return page( return page(
"Workers", "Workers",
@@ -87,13 +139,25 @@ def workers_page(request: Request, user=Depends(require_user)):
</div> </div>
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card stat-success"><span>Online Workers</span><strong>{online_count}</strong></div> <div class="stat-card stat-success"><span>Online Workers</span><strong data-live-count="workers.online">{online_count}</strong></div>
<div class="stat-card stat-danger"><span>Offline Workers</span><strong>{offline_count}</strong></div> <div class="stat-card stat-danger"><span>Offline Workers</span><strong data-live-count="workers.offline">{offline_count}</strong></div>
<div class="stat-card stat-total"><span>Total Workers</span><strong>{total_count}</strong></div> <div class="stat-card stat-total"><span>Total Workers</span><strong data-live-count="workers.total">{total_count}</strong></div>
</div>
<div class="card">
<h2>Filtry</h2>
<form method="get" action="/portal/workers" class="filter-form">
<select name="status">{status_options}</select>
<input name="q" value="{html.escape(query)}" placeholder="Worker ID">
<input type="hidden" name="page" value="1">
<button type="submit">Filtrovat</button>
<a class="btn btn-secondary" href="/portal/workers">Reset</a>
</form>
</div> </div>
<div class="card"> <div class="card">
<h2>Workers</h2> <h2>Workers</h2>
{pagination}
<table> <table>
<tr> <tr>
<th>Worker ID</th> <th>Worker ID</th>
@@ -103,9 +167,11 @@ def workers_page(request: Request, user=Depends(require_user)):
<th>Started At</th> <th>Started At</th>
<th>Akce</th> <th>Akce</th>
</tr> </tr>
{rows} <tbody{live_table_attr}>{rows}</tbody>
</table> </table>
{pagination}
</div> </div>
<script src="/portal/static/operations-live.js"></script>
""", """,
user=user, user=user,
) )
+164
View File
@@ -0,0 +1,164 @@
(() => {
if (window.AppFactoryOperationsLive) {
return;
}
const state = {
socket: null,
reconnectTimer: null,
reconnectDelay: 1500,
};
const escapeHtml = (value) => String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;");
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 `<span class="${className}">${escapeHtml(value)}</span>`;
};
const workerBadge = (online) => online
? '<span class="pill pill-success">ONLINE</span>'
: '<span class="pill pill-danger">OFFLINE</span>';
const setText = (selector, value) => {
document.querySelectorAll(selector).forEach((el) => {
el.textContent = value ?? 0;
});
};
const renderJobs = (jobs) => {
const rows = (jobs || []).map((job) => `
<tr>
<td><strong><a href="/portal/jobs/${escapeHtml(job.id)}">#${escapeHtml(job.id)}</a></strong></td>
<td>${statusBadge(job.status)}</td>
<td>${escapeHtml(job.type)}</td>
<td>${escapeHtml(job.target_type)}: <strong>${escapeHtml(job.target_id)}</strong></td>
<td>${escapeHtml(job.source)}</td>
<td>${escapeHtml(job.created_at)}</td>
</tr>
`).join("");
return rows || '<tr><td colspan="6">Zatím nejsou evidované žádné joby.</td></tr>';
};
const renderWorkers = (workers) => {
const rows = (workers || []).map((worker) => {
const currentJob = worker.current_job_id === null || worker.current_job_id === undefined
? ""
: `<a href="/portal/jobs/${escapeHtml(worker.current_job_id)}">#${escapeHtml(worker.current_job_id)}</a>`;
return `
<tr data-worker-id="${escapeHtml(worker.id)}">
<td><strong><a href="/portal/workers/${encodeURIComponent(worker.id || "")}">${escapeHtml(worker.id)}</a></strong></td>
<td data-worker-field="status">${workerBadge(Boolean(worker.online))}<br><span class="muted">${escapeHtml(worker.status)}</span></td>
<td data-worker-field="last_seen_at">${escapeHtml(worker.last_seen_at)}</td>
<td data-worker-field="current_job">${currentJob}</td>
<td>${escapeHtml(worker.started_at)}</td>
<td class="actions-cell"><a class="btn" href="/portal/workers/${encodeURIComponent(worker.id || "")}">Detail</a></td>
</tr>
`;
}).join("");
return rows || '<tr><td colspan="6">Zatím nejsou evidovaní žádní workeři.</td></tr>';
};
const renderDeployments = (deployments) => {
const rows = (deployments || []).map((deployment) => `
<tr>
<td><strong><a href="/portal/deployments/${escapeHtml(deployment.id)}">#${escapeHtml(deployment.id)}</a></strong></td>
<td><a href="/portal/apps/${encodeURIComponent(deployment.app_id || "")}">${escapeHtml(deployment.app_id)}</a></td>
<td>${escapeHtml(deployment.kind)}</td>
<td>${statusBadge(deployment.status)}</td>
<td>${escapeHtml(deployment.trigger_source)}</td>
<td>${escapeHtml(deployment.started_at)}</td>
<td>${escapeHtml(deployment.finished_at)}</td>
</tr>
`).join("");
return rows || '<tr><td colspan="7">Zatím nejsou evidovaná žádná nasazení.</td></tr>';
};
const renderAudit = (events) => {
const rows = (events || []).map((event) => `
<tr>
<td>${escapeHtml(event.created_at)}</td>
<td>${escapeHtml(event.username)}</td>
<td><span class="pill pill-muted">${escapeHtml(event.action)}</span></td>
<td>${escapeHtml(event.target_type)}</td>
<td>${escapeHtml(event.target_id)}</td>
<td>${escapeHtml(event.source)}</td>
</tr>
`).join("");
return rows || '<tr><td colspan="6">Zatím nejsou evidované žádné auditní události.</td></tr>';
};
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
? ""
: `<a href="/portal/jobs/${escapeHtml(worker.current_job_id)}">#${escapeHtml(worker.current_job_id)}</a>`;
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))}<br><span class="muted">${escapeHtml(worker.status)}</span>`;
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();
})();
+6 -7
View File
@@ -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", "")) display_name = html.escape(user.get("display_name") or user.get("username", ""))
nav = """ nav = """
<nav> <nav>
<a href="/portal">Aplikace</a> <a href="/portal/operations">Dashboard</a>
<a href="/portal/new-app">Nová aplikace</a> <a href="/portal">Apps</a>
<a href="/portal/deployments">Nasazení</a> <a href="/portal/jobs">Jobs</a>
<a href="/portal/jobs">Joby</a> <a href="/portal/deployments">Deployments</a>
<a href="/portal/operations">Operations -> Dashboard</a> <a href="/portal/workers">Workers</a>
<a href="/portal/workers">Operations -> Workers</a>
<a href="/portal/backups">Zálohy</a>
<a href="/portal/audit">Audit</a> <a href="/portal/audit">Audit</a>
<a href="/portal/backups">Backups</a>
</nav> </nav>
""" """
user_panel = f""" user_panel = f"""