Přidal jsem Health Monitoring do portálu:

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.
This commit is contained in:
JiriUhlir
2026-05-29 11:57:57 +02:00
parent 56f0b632de
commit ed194093e6
6 changed files with 303 additions and 3 deletions
+97 -2
View File
@@ -17,6 +17,7 @@ from ..config import (
)
from ..db.apps import get_app, get_app_deployments, get_apps, update_app_resources
from ..db.audit import log_audit_event
from ..db.health import get_latest_service_health, get_service_health, get_service_health_history
from ..db.jobs import create_job, get_jobs, has_active_deploy_job
from ..routes.deployments import render_status_pill
from ..shell import run_command
@@ -26,6 +27,24 @@ router = APIRouter()
DEFAULT_PAGE_SIZE = 20
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 or "neznámá"))}</span>'
@router.get("/")
def portal_home(user=Depends(require_user)):
return RedirectResponse(url="/portal/operations", status_code=303)
@@ -40,6 +59,7 @@ def apps_page(
user=Depends(require_user),
):
apps = get_apps()
latest_health = get_latest_service_health()
query = q.strip()
selected_status = status.strip()
if query:
@@ -50,6 +70,16 @@ def apps_page(
]
if selected_status:
apps = [item for item in apps if (item.get("status", "") or "") == selected_status]
sort = request.query_params.get("sort", "").strip()
if sort == "health":
order = {"unreachable": 0, "unhealthy": 1, "healthy": 2}
apps = sorted(
apps,
key=lambda item: (
order.get((latest_health.get(item.get("id", "")) or {}).get("status"), 3),
item.get("id", ""),
),
)
status_values = sorted({item.get("status", "") for item in get_apps() if item.get("status")})
total_apps = len(apps)
total_pages = max(1, math.ceil(total_apps / DEFAULT_PAGE_SIZE))
@@ -68,6 +98,9 @@ def apps_page(
app_id = html.escape(item.get("id", ""))
app_url_id = quote(item.get("id", ""), safe="")
status = html.escape(item.get("status", ""))
health = latest_health.get(item.get("id", "")) or {}
health_status = render_health_status(health.get("status"))
health_checked_at = html.escape(health.get("checked_at", "") or "")
docs = html.escape(item.get("docs", f"/apps/{app_id}/docs"))
memory = item.get("memory", "")
cpus = item.get("cpus", "")
@@ -104,6 +137,7 @@ def apps_page(
<span class="muted">/apps/{app_id}</span>
</td>
<td><span class="pill">{status}</span></td>
<td>{health_status}<br><span class="muted">{health_checked_at}</span></td>
<td><a href="{docs}">Swagger</a></td>
<td>
<form method="post" action="/portal/update-resources">
@@ -150,7 +184,7 @@ def apps_page(
"""
if not rows:
rows = '<tr><td colspan="6">Zatím nejsou nasazené žádné služby.</td></tr>'
rows = '<tr><td colspan="7">Zatím nejsou nasazené žádné služby.</td></tr>'
status_options = ['<option value="">Všechny stavy</option>']
for value in status_values:
selected = " selected" if selected_status == value else ""
@@ -165,6 +199,8 @@ def apps_page(
params["q"] = query
if selected_status:
params["status"] = selected_status
if sort:
params["sort"] = sort
return f"/portal/apps?{urlencode(params)}"
previous_link = (
@@ -216,6 +252,10 @@ def apps_page(
<form method="get" action="/portal/apps" class="filter-form">
<input name="q" value="{html.escape(query)}" placeholder="Název nebo ID služby">
<select name="status">{"".join(status_options)}</select>
<select name="sort">
<option value="">Výchozí řazení</option>
<option value="health"{" selected" if sort == "health" else ""}>Podle zdraví</option>
</select>
<input type="hidden" name="page" value="1">
<button type="submit">Filtrovat</button>
<a class="btn btn-secondary" href="/portal/apps">Reset</a>
@@ -225,6 +265,7 @@ def apps_page(
<tr>
<th>Služba</th>
<th>Status</th>
<th>Zdraví</th>
<th>Dokumentace</th>
<th>Prostředky</th>
<th>Git</th>
@@ -245,6 +286,13 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
if not app:
raise HTTPException(status_code=404, detail="App not found")
log_audit_event(
user,
action="service.health.view",
target_type="service",
target_id=app_id,
)
escaped_app_id = html.escape(app.get("id", ""))
app_url_id = quote(app.get("id", ""), safe="")
name = html.escape(app.get("name", "") or "")
@@ -254,6 +302,8 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
memory = html.escape(app.get("memory", "") or "")
cpus = html.escape(app.get("cpus", "") or "")
updated_at = html.escape(app.get("updated_at", "") or "")
current_health = get_service_health(app.get("id", ""))
health_history = get_service_health_history(app.get("id", ""), limit=50)
rows = ""
for deployment in get_app_deployments(app.get("id", ""), limit=10):
@@ -294,8 +344,29 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
if not job_rows:
job_rows = '<tr><td colspan="4">Zatím nejsou evidované žádné úlohy této služby.</td></tr>'
health_status = render_health_status(current_health.get("status") if current_health else None)
health_http_status = html.escape(str(current_health.get("http_status") or "")) if current_health else ""
health_response_time = html.escape(str(current_health.get("response_time_ms") or "")) if current_health else ""
health_checked_at = html.escape(current_health.get("checked_at", "") or "") if current_health else ""
health_rows = ""
for item in health_history:
error_text = item.get("error_text", "") or ""
error_preview = error_text if len(error_text) <= 140 else f"{error_text[:137]}..."
health_rows += f"""
<tr>
<td>{html.escape(item.get("checked_at", "") or "")}</td>
<td>{render_health_status(item.get("status"))}</td>
<td>{html.escape(str(item.get("http_status") or ""))}</td>
<td>{html.escape(str(item.get("response_time_ms") or ""))}</td>
<td>{html.escape(error_preview)}</td>
</tr>
"""
if not health_rows:
health_rows = '<tr><td colspan="5">Zatím nejsou evidované žádné kontroly zdraví.</td></tr>'
return page(
f"Služba {escaped_app_id}",
name or escaped_app_id,
f"""
<div class="card">
<h2>{escaped_app_id}</h2>
@@ -325,6 +396,30 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
</table>
</div>
<div class="card">
<h2>Zdraví služby</h2>
<table>
<tr><th>Aktuální status</th><td>{health_status}</td></tr>
<tr><th>HTTP status</th><td>{health_http_status}</td></tr>
<tr><th>Odezva</th><td>{health_response_time}</td></tr>
<tr><th>Poslední kontrola</th><td>{health_checked_at}</td></tr>
</table>
</div>
<div class="card">
<h2>Historie kontrol</h2>
<table>
<tr>
<th>Čas</th>
<th>Status</th>
<th>HTTP status</th>
<th>Odezva</th>
<th>Chyba</th>
</tr>
{health_rows}
</table>
</div>
<div class="card">
<h2>Historie nasazení</h2>
<table>