ed194093e6
Přehled má nové karty: Zdravé služby, Nezdravé služby, Nedostupné služby. Přehled zobrazuje sekci Služby vyžadující pozornost pro unhealthy a unreachable. Snapshot /portal/ws/operations teď obsahuje health data a stránka je aktualizuje přes existující realtime vrstvu. Seznam služeb zobrazuje aktuální health status a poslední kontrolu, včetně řazení Podle zdraví. Detail služby má sekce Zdraví služby a Historie kontrol s posledními 50 záznamy. Přidal jsem audit event service.health.view. Browser title teď používá formát CSBot Services Portal - ..., takže detail služby odpovídá požadavku. Nepoužil jsem ORM, vše je přes SQLite dotazy.
260 lines
9.9 KiB
Python
260 lines
9.9 KiB
Python
import asyncio
|
|
import html
|
|
from urllib.parse import quote
|
|
|
|
from fastapi import APIRouter, Depends, Request, WebSocket, WebSocketDisconnect
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
from app.auth import current_user, require_user
|
|
from app.db.audit import log_audit_event
|
|
from app.db.operations import (
|
|
get_operations_snapshot,
|
|
)
|
|
from app.routes.deployments import render_status_pill
|
|
from app.routes.jobs import render_job_status
|
|
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:
|
|
job_id = html.escape(str(job.get("id", "")))
|
|
job_type = html.escape(job.get("type", "") or "")
|
|
target_type = html.escape(job.get("target_type", "") or "")
|
|
target_id = html.escape(job.get("target_id", "") or "")
|
|
source = html.escape(job.get("source", "") or "")
|
|
created_at = html.escape(job.get("created_at", "") or "")
|
|
|
|
rows += f"""
|
|
<tr>
|
|
<td><strong><a href="/portal/jobs/{job_id}">#{job_id}</a></strong></td>
|
|
<td>{render_job_status(job.get("status"))}</td>
|
|
<td>{job_type}</td>
|
|
<td>{target_type}: <strong>{target_id}</strong></td>
|
|
<td>{source}</td>
|
|
<td>{created_at}</td>
|
|
</tr>
|
|
"""
|
|
|
|
if not rows:
|
|
rows = '<tr><td colspan="6">Zatím nejsou evidované žádné joby.</td></tr>'
|
|
|
|
return rows
|
|
|
|
|
|
def render_recent_deployments(deployments: list[dict]) -> str:
|
|
rows = ""
|
|
for deployment in deployments:
|
|
deployment_id = html.escape(str(deployment.get("id", "")))
|
|
raw_app_id = deployment.get("app_id", "") or ""
|
|
app_id = html.escape(raw_app_id)
|
|
app_url_id = quote(raw_app_id, safe="")
|
|
kind = html.escape(deployment.get("kind", "") or "")
|
|
started_at = html.escape(deployment.get("started_at", "") or "")
|
|
finished_at = html.escape(deployment.get("finished_at", "") or "")
|
|
source = html.escape(deployment.get("trigger_source", "") or "")
|
|
|
|
rows += f"""
|
|
<tr>
|
|
<td><strong><a href="/portal/deployments/{deployment_id}">#{deployment_id}</a></strong></td>
|
|
<td><a href="/portal/apps/{app_url_id}">{app_id}</a></td>
|
|
<td>{kind}</td>
|
|
<td>{render_status_pill(deployment.get("status"))}</td>
|
|
<td>{source}</td>
|
|
<td>{started_at}</td>
|
|
<td>{finished_at}</td>
|
|
</tr>
|
|
"""
|
|
|
|
if not rows:
|
|
rows = '<tr><td colspan="7">Zatím nejsou evidovaná žádná nasazení.</td></tr>'
|
|
|
|
return rows
|
|
|
|
|
|
def render_recent_audit_events(events: list[dict]) -> str:
|
|
rows = ""
|
|
for event in events:
|
|
rows += f"""
|
|
<tr>
|
|
<td>{html.escape(event.get("created_at", "") or "")}</td>
|
|
<td>{html.escape(event.get("username", "") or "")}</td>
|
|
<td><span class="pill pill-muted">{html.escape(event.get("action", "") or "")}</span></td>
|
|
<td>{html.escape(event.get("target_type", "") or "")}</td>
|
|
<td>{html.escape(str(event.get("target_id") or ""))}</td>
|
|
<td>{html.escape(event.get("source", "") or "")}</td>
|
|
</tr>
|
|
"""
|
|
|
|
if not rows:
|
|
rows = '<tr><td colspan="6">Zatím nejsou evidované žádné auditní události.</td></tr>'
|
|
|
|
return rows
|
|
|
|
|
|
def render_health_status(status: str | None) -> str:
|
|
value = status or ""
|
|
normalized = value.lower()
|
|
labels = {
|
|
"healthy": "zdravá",
|
|
"unhealthy": "nezdravá",
|
|
"unreachable": "nedostupná",
|
|
}
|
|
class_name = "pill pill-muted"
|
|
if normalized == "healthy":
|
|
class_name = "pill pill-success"
|
|
elif normalized == "unhealthy":
|
|
class_name = "pill pill-warning"
|
|
elif normalized == "unreachable":
|
|
class_name = "pill pill-danger"
|
|
return f'<span class="{class_name}">{html.escape(labels.get(normalized, value))}</span>'
|
|
|
|
|
|
def render_health_problems(items: list[dict]) -> str:
|
|
rows = ""
|
|
for item in items:
|
|
service_id = html.escape(item.get("service_id", "") or "")
|
|
service_name = html.escape(item.get("service_name") or item.get("service_id", "") or "")
|
|
error_text = item.get("error_text", "") or ""
|
|
error_preview = error_text if len(error_text) <= 120 else f"{error_text[:117]}..."
|
|
rows += f"""
|
|
<tr>
|
|
<td><a href="/portal/apps/{quote(service_id, safe='')}">{service_name}</a></td>
|
|
<td>{render_health_status(item.get("status"))}</td>
|
|
<td>{html.escape(item.get("checked_at", "") or "")}</td>
|
|
<td>{html.escape(str(item.get("response_time_ms") or ""))}</td>
|
|
<td>{html.escape(error_preview)}</td>
|
|
</tr>
|
|
"""
|
|
|
|
if not rows:
|
|
rows = '<tr><td colspan="5">Žádné služby nevyžadují pozornost.</td></tr>'
|
|
return rows
|
|
|
|
|
|
@router.get("/operations", response_class=HTMLResponse)
|
|
def operations_dashboard(request: Request, user=Depends(require_user)):
|
|
snapshot = get_operations_snapshot()
|
|
log_audit_event(
|
|
user,
|
|
action="operations.dashboard.view",
|
|
target_type="operations",
|
|
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"],
|
|
},
|
|
},
|
|
)
|
|
|
|
return page(
|
|
"Přehled",
|
|
f"""
|
|
<div class="card">
|
|
<h2>Přehled systému</h2>
|
|
<p class="muted">Rychlá odpověď na otázku, zda jsou služby, úlohy a nasazení v pořádku.</p>
|
|
</div>
|
|
|
|
<div class="stats-grid">
|
|
<div class="stat-card stat-success"><span>Běžící služby</span><strong data-live-count="apps.active">{snapshot["apps"]["active"]}</strong></div>
|
|
<div class="stat-card stat-danger"><span>Problémové služby</span><strong data-live-count="apps.problematic">{snapshot["apps"]["problematic"]}</strong></div>
|
|
<div class="stat-card stat-success"><span>Zdravé služby</span><strong data-live-count="health.healthy">{snapshot["health"]["healthy"]}</strong></div>
|
|
<div class="stat-card stat-warning"><span>Nezdravé služby</span><strong data-live-count="health.unhealthy">{snapshot["health"]["unhealthy"]}</strong></div>
|
|
<div class="stat-card stat-danger"><span>Nedostupné služby</span><strong data-live-count="health.unreachable">{snapshot["health"]["unreachable"]}</strong></div>
|
|
<div class="stat-card stat-warning"><span>Aktivní úlohy</span><strong data-live-count="jobs.running">{snapshot["jobs"]["running"]}</strong></div>
|
|
<div class="stat-card stat-total"><span>Nasazení dnes</span><strong data-live-count="deployments.today">{snapshot["deployments"]["today"]}</strong></div>
|
|
<div class="stat-card stat-success"><span>Online workery</span><strong data-live-count="workers.online">{snapshot["workers"]["online"]}</strong></div>
|
|
<div class="stat-card stat-danger"><span>Selhané úlohy (24 h)</span><strong data-live-count="jobs.failed_24h">{snapshot["jobs"]["failed_24h"]}</strong></div>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Služby vyžadující pozornost</h2>
|
|
<table>
|
|
<tr>
|
|
<th>Služba</th>
|
|
<th>Status</th>
|
|
<th>Poslední kontrola</th>
|
|
<th>Odezva</th>
|
|
<th>Chyba</th>
|
|
</tr>
|
|
<tbody data-live-table="health.problems">{render_health_problems(snapshot["health"]["problems"])}</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Poslední incidenty</h2>
|
|
<table>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Status</th>
|
|
<th>Typ</th>
|
|
<th>Cíl</th>
|
|
<th>Zdroj</th>
|
|
<th>Vytvořeno</th>
|
|
</tr>
|
|
<tbody data-live-table="jobs.failed">{render_recent_jobs(snapshot["jobs"]["recent_failed"])}</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Poslední neúspěšná nasazení</h2>
|
|
<table>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>Služba</th>
|
|
<th>Typ</th>
|
|
<th>Status</th>
|
|
<th>Zdroj</th>
|
|
<th>Spuštěno</th>
|
|
<th>Dokončeno</th>
|
|
</tr>
|
|
<tbody data-live-table="deployments.failed">{render_recent_deployments(snapshot["deployments"]["recent_failed"])}</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2>Poslední auditní události</h2>
|
|
<table>
|
|
<tr>
|
|
<th>Čas</th>
|
|
<th>Uživatel</th>
|
|
<th>Akce</th>
|
|
<th>Typ cíle</th>
|
|
<th>Cíl</th>
|
|
<th>Zdroj</th>
|
|
</tr>
|
|
<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,
|
|
)
|