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
+80 -14
View File
@@ -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'<a href="/portal/jobs/{current_job_label}">#{current_job_label}</a>'
rows += f"""
<tr>
<tr data-worker-id="{worker_id}">
<td>
<strong><a href="/portal/workers/{worker_url_id}">{worker_id}</a></strong>
</td>
<td>{badge}<br><span class="muted">{status}</span></td>
<td>{last_seen_at}</td>
<td>{current_job}</td>
<td data-worker-field="status">{badge}<br><span class="muted">{status}</span></td>
<td data-worker-field="last_seen_at">{last_seen_at}</td>
<td data-worker-field="current_job">{current_job}</td>
<td>{started_at}</td>
<td class="actions-cell"><a class="btn" href="/portal/workers/{worker_url_id}">Detail</a></td>
</tr>
@@ -77,6 +103,32 @@ def workers_page(request: Request, user=Depends(require_user)):
if not rows:
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(
"Workers",
@@ -87,13 +139,25 @@ def workers_page(request: Request, user=Depends(require_user)):
</div>
<div class="stats-grid">
<div class="stat-card stat-success"><span>Online Workers</span><strong>{online_count}</strong></div>
<div class="stat-card stat-danger"><span>Offline Workers</span><strong>{offline_count}</strong></div>
<div class="stat-card stat-total"><span>Total Workers</span><strong>{total_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 data-live-count="workers.offline">{offline_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 class="card">
<h2>Workers</h2>
{pagination}
<table>
<tr>
<th>Worker ID</th>
@@ -103,9 +167,11 @@ def workers_page(request: Request, user=Depends(require_user)):
<th>Started At</th>
<th>Akce</th>
</tr>
{rows}
<tbody{live_table_attr}>{rows}</tbody>
</table>
{pagination}
</div>
<script src="/portal/static/operations-live.js"></script>
""",
user=user,
)