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
+65 -10
View File
@@ -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 = "<script>setTimeout(() => window.location.reload(), 5000);</script>" 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"""
<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(
"Joby",
@@ -198,10 +234,10 @@ def jobs_page(
</div>
<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>Running</span><strong>{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-success"><span>Success</span><strong>{stats.get("success") 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 data-live-count="jobs.running">{stats.get("running") 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 (24h)</span><strong data-live-count="jobs.success_24h">{stats.get("success_24h") or 0}</strong></div>
</div>
<div class="card">
@@ -210,13 +246,30 @@ def jobs_page(
<select name="status">{status_options}</select>
<select name="type">{type_options}</select>
<input name="target" value="{target_value}" placeholder="Target">
<input type="hidden" name="page" value="1">
<button type="submit">Filtrovat</button>
<a class="btn btn-secondary" href="/portal/jobs">Reset</a>
</form>
</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">
<h2>Fronta</h2>
{pagination}
<table>
<tr>
<th>ID</th>
@@ -229,9 +282,11 @@ def jobs_page(
<th>Finished at</th>
<th>Akce</th>
</tr>
{rows}
<tbody>{rows}</tbody>
</table>
{pagination}
</div>
<script src="/portal/static/operations-live.js"></script>
""",
user=user,
)