Files
appfactory-portal/app/routes/workers.py
T
JiriUhlir d145161e4e 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
2026-05-29 11:17:17 +02:00

239 lines
8.7 KiB
Python

import html
import json
import math
from urllib.parse import quote, urlencode
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth import require_user
from app.db.audit import log_audit_event
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:
if online:
return '<span class="pill pill-success">ONLINE</span>'
return '<span class="pill pill-danger">OFFLINE</span>'
def pretty_json(value: str | None) -> str:
if not value:
return "{}"
try:
return json.dumps(json.loads(value), ensure_ascii=False, indent=2, sort_keys=True)
except (TypeError, ValueError):
return value
@router.get("/workers", response_class=HTMLResponse)
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,
action="workers.view",
target_type="workers",
metadata={
"online": online_count,
"offline": offline_count,
"total": total_count,
},
)
rows = ""
for worker in workers:
worker_id_raw = worker.get("id", "") or ""
worker_id = html.escape(worker_id_raw)
worker_url_id = quote(worker_id_raw, safe="")
status = html.escape(worker.get("status", "") or "")
last_seen_at = html.escape(worker.get("last_seen_at", "") or "")
started_at = html.escape(worker.get("started_at", "") or "")
badge = render_worker_badge(bool(worker.get("online")))
current_job_id = worker.get("current_job_id")
current_job = ""
if current_job_id is not None:
current_job_label = html.escape(str(current_job_id))
current_job = f'<a href="/portal/jobs/{current_job_label}">#{current_job_label}</a>'
rows += f"""
<tr data-worker-id="{worker_id}">
<td>
<strong><a href="/portal/workers/{worker_url_id}">{worker_id}</a></strong>
</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>
"""
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",
f"""
<div class="card">
<h2>Workers</h2>
<p class="muted">Přehled worker procesů obsluhujících AppFactory joby.</p>
</div>
<div class="stats-grid">
<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>
<th>Status</th>
<th>Last Seen</th>
<th>Current Job</th>
<th>Started At</th>
<th>Akce</th>
</tr>
<tbody{live_table_attr}>{rows}</tbody>
</table>
{pagination}
</div>
<script src="/portal/static/operations-live.js"></script>
""",
user=user,
)
@router.get("/workers/{worker_id}", response_class=HTMLResponse)
def worker_detail_page(worker_id: str, request: Request, user=Depends(require_user)):
worker = get_worker(worker_id)
if not worker:
raise HTTPException(status_code=404, detail="Worker not found")
log_audit_event(
user,
action="workers.view",
target_type="worker",
target_id=worker_id,
)
worker_id_html = html.escape(worker.get("id", "") or "")
status = html.escape(worker.get("status", "") or "")
last_seen_at = html.escape(worker.get("last_seen_at", "") or "")
started_at = html.escape(worker.get("started_at", "") or "")
badge = render_worker_badge(bool(worker.get("online")))
current_job_id = worker.get("current_job_id")
current_job = ""
if current_job_id is not None:
current_job_label = html.escape(str(current_job_id))
current_job = f'<a href="/portal/jobs/{current_job_label}">#{current_job_label}</a>'
metadata = html.escape(pretty_json(worker.get("metadata_json")))
metadata_block = ""
if worker.get("metadata_json"):
metadata_block = f"""
<div class="card">
<h2>Metadata</h2>
<pre class="log-viewer log-stdout">{metadata}</pre>
</div>
"""
return page(
f"Worker {worker_id_html}",
f"""
<div class="card">
<h2>Worker {worker_id_html}</h2>
<p>
<a class="btn" href="/portal/workers">&larr; Zpět na workers</a>
</p>
</div>
<div class="card">
<h2>Souhrn</h2>
<table>
<tr><th>Worker ID</th><td>{worker_id_html}</td></tr>
<tr><th>Status</th><td>{badge}<br><span class="muted">{status}</span></td></tr>
<tr><th>Last Seen</th><td>{last_seen_at}</td></tr>
<tr><th>Current Job</th><td>{current_job}</td></tr>
<tr><th>Started At</th><td>{started_at}</td></tr>
</table>
</div>
{metadata_block}
""",
user=user,
)