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:
+1
-1
@@ -129,7 +129,6 @@ def index(request: Request, user=Depends(require_user)):
|
||||
<div class="card">
|
||||
<h2>Aplikace</h2>
|
||||
<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 class="card">
|
||||
<h2>Zálohy</h2>
|
||||
@@ -145,6 +144,7 @@ def index(request: Request, user=Depends(require_user)):
|
||||
|
||||
<div class="card">
|
||||
<h2>Nasazené aplikace</h2>
|
||||
<p><a class="btn" href="/portal/new-app">+ Nová aplikace</a></p>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Aplikace</th>
|
||||
|
||||
@@ -161,9 +161,7 @@ async def deployments_page(
|
||||
)
|
||||
stats = get_deployment_stats()
|
||||
options = get_deployment_filter_options()
|
||||
refresh = "<script>setTimeout(() => window.location.reload(), 5000);</script>" 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 = "<script>setTimeout(() => window.location.reload(), 5000);</script>" if is_running(
|
||||
deployment.get("status")
|
||||
) else ""
|
||||
refresh = ""
|
||||
failed_notice = (
|
||||
f'<div class="alert alert-danger">Deployment selhal. Návratový kód: {returncode_label}</div>'
|
||||
if is_failed(deployment.get("status"))
|
||||
|
||||
+65
-10
@@ -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,
|
||||
)
|
||||
|
||||
+46
-20
@@ -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)):
|
||||
</div>
|
||||
|
||||
<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-warning"><span>Running Jobs</span><strong>{summary.get("running_jobs") or 0}</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-danger"><span>Failed Jobs (24h)</span><strong>{summary.get("failed_jobs_24h") or 0}</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-total"><span>Active Applications</span><strong>{summary.get("active_applications") 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 data-live-count="jobs.running">{snapshot["jobs"]["running"]}</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 data-live-count="jobs.failed_24h">{snapshot["jobs"]["failed_24h"]}</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-danger"><span>Failed Deployments (24h)</span><strong data-live-count="deployments.failed_24h">{snapshot["deployments"]["failed_24h"]}</strong></div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
@@ -137,7 +162,7 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
|
||||
<th>Source</th>
|
||||
<th>Created At</th>
|
||||
</tr>
|
||||
{render_recent_jobs(recent_jobs)}
|
||||
<tbody data-live-table="jobs.recent">{render_recent_jobs(snapshot["jobs"]["recent"])}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -153,7 +178,7 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
|
||||
<th>Spuštěno</th>
|
||||
<th>Dokončeno</th>
|
||||
</tr>
|
||||
{render_recent_deployments(recent_deployments)}
|
||||
<tbody data-live-table="deployments.recent">{render_recent_deployments(snapshot["deployments"]["recent"])}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@@ -168,9 +193,10 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
|
||||
<th>Cíl</th>
|
||||
<th>Zdroj</th>
|
||||
</tr>
|
||||
{render_recent_audit_events(recent_audit_events)}
|
||||
<tbody data-live-table="audit.recent">{render_recent_audit_events(snapshot["audit"]["recent"])}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<script src="/portal/static/operations-live.js"></script>
|
||||
""",
|
||||
user=user,
|
||||
)
|
||||
|
||||
+80
-14
@@ -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,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user