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
+102
View File
@@ -0,0 +1,102 @@
from app.db.database import get_connection
HEALTH_STATUSES = ("healthy", "unhealthy", "unreachable")
def get_latest_service_health():
con = get_connection()
try:
rows = con.execute(
"""
SELECT
h.service_id,
COALESCE(a.name, h.service_id) AS service_name,
h.status,
h.http_status,
h.response_time_ms,
h.error_text,
h.checked_at
FROM service_health h
LEFT JOIN apps a ON a.id = h.service_id
INNER JOIN (
SELECT service_id, MAX(id) AS max_id
FROM service_health
GROUP BY service_id
) latest ON latest.max_id = h.id
"""
).fetchall()
except Exception:
rows = []
con.close()
return {row["service_id"]: dict(row) for row in rows}
def get_service_health(service_id: str):
con = get_connection()
try:
row = con.execute(
"""
SELECT
h.service_id,
COALESCE(a.name, h.service_id) AS service_name,
h.status,
h.http_status,
h.response_time_ms,
h.error_text,
h.checked_at
FROM service_health h
LEFT JOIN apps a ON a.id = h.service_id
WHERE h.service_id = ?
ORDER BY h.id DESC
LIMIT 1
""",
(service_id,),
).fetchone()
except Exception:
row = None
con.close()
return dict(row) if row else None
def get_service_health_history(service_id: str, limit: int = 50):
con = get_connection()
try:
rows = con.execute(
"""
SELECT id, service_id, status, http_status, response_time_ms, error_text, checked_at
FROM service_health
WHERE service_id = ?
ORDER BY id DESC
LIMIT ?
""",
(service_id, limit),
).fetchall()
except Exception:
rows = []
con.close()
return [dict(row) for row in rows]
def get_health_summary():
latest = get_latest_service_health()
summary = {"healthy": 0, "unhealthy": 0, "unreachable": 0}
for item in latest.values():
status = item.get("status")
if status in summary:
summary[status] += 1
return summary
def get_problem_service_health():
latest = get_latest_service_health()
return [
item for item in latest.values()
if item.get("status") in {"unhealthy", "unreachable"}
]
+9
View File
@@ -1,4 +1,5 @@
from app.db.database import get_connection
from app.db.health import get_health_summary, get_problem_service_health
from app.db.migrations import run_migrations
from app.db.workers import is_worker_online
@@ -197,6 +198,8 @@ def get_operations_snapshot():
WHERE LOWER(COALESCE(status, '')) IN ('failed', 'error', 'disabled', 'deleted', 'archived')
"""
).fetchone()["count"]
health_summary = get_health_summary()
health_problems = get_problem_service_health()
jobs_summary = con.execute(
"""
@@ -296,6 +299,12 @@ def get_operations_snapshot():
"active": active_applications,
"problematic": problematic_applications,
},
"health": {
"healthy": health_summary["healthy"],
"unhealthy": health_summary["unhealthy"],
"unreachable": health_summary["unreachable"],
"problems": health_problems,
},
"workers": {
"online": workers_online,
"offline": max(0, total_workers - workers_online),
+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>
+57
View File
@@ -110,6 +110,46 @@ def render_recent_audit_events(events: list[dict]) -> str:
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()
@@ -145,12 +185,29 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
<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>
+37
View File
@@ -44,6 +44,21 @@
? '<span class="pill pill-success">online</span>'
: '<span class="pill pill-danger">offline</span>';
const healthBadge = (status) => {
const value = String(status || "");
const normalized = value.toLowerCase();
const labels = {
healthy: "zdravá",
unhealthy: "nezdravá",
unreachable: "nedostupná",
};
let className = "pill pill-muted";
if (normalized === "healthy") className = "pill pill-success";
if (normalized === "unhealthy") className = "pill pill-warning";
if (normalized === "unreachable") className = "pill pill-danger";
return `<span class="${className}">${escapeHtml(labels[normalized] || value)}</span>`;
};
const setText = (selector, value) => {
document.querySelectorAll(selector).forEach((el) => {
el.textContent = value ?? 0;
@@ -112,6 +127,24 @@
return rows || '<tr><td colspan="6">Zatím nejsou evidované žádné auditní události.</td></tr>';
};
const renderHealthProblems = (items) => {
const rows = (items || []).map((item) => {
const errorText = String(item.error_text || "");
const errorPreview = errorText.length > 120 ? `${errorText.slice(0, 117)}...` : errorText;
const serviceName = item.service_name || item.service_id || "";
return `
<tr>
<td><a href="/portal/apps/${encodeURIComponent(item.service_id || "")}">${escapeHtml(serviceName)}</a></td>
<td>${healthBadge(item.status)}</td>
<td>${escapeHtml(item.checked_at)}</td>
<td>${escapeHtml(item.response_time_ms ?? "")}</td>
<td>${escapeHtml(errorPreview)}</td>
</tr>
`;
}).join("");
return rows || '<tr><td colspan="5">Žádné služby nevyžadují pozornost.</td></tr>';
};
const renderSnapshot = (snapshot) => {
setText("[data-live-count='jobs.queued']", snapshot.jobs?.queued);
setText("[data-live-count='jobs.running']", snapshot.jobs?.running);
@@ -119,6 +152,9 @@
setText("[data-live-count='jobs.success_24h']", snapshot.jobs?.success_24h);
setText("[data-live-count='apps.active']", snapshot.apps?.active);
setText("[data-live-count='apps.problematic']", snapshot.apps?.problematic);
setText("[data-live-count='health.healthy']", snapshot.health?.healthy);
setText("[data-live-count='health.unhealthy']", snapshot.health?.unhealthy);
setText("[data-live-count='health.unreachable']", snapshot.health?.unreachable);
setText("[data-live-count='workers.online']", snapshot.workers?.online);
setText("[data-live-count='workers.offline']", snapshot.workers?.offline);
setText("[data-live-count='workers.total']", (snapshot.workers?.online || 0) + (snapshot.workers?.offline || 0));
@@ -133,6 +169,7 @@
document.querySelectorAll("[data-live-table='deployments.recent']").forEach((el) => { el.innerHTML = renderDeployments(snapshot.deployments?.recent); });
document.querySelectorAll("[data-live-table='deployments.failed']").forEach((el) => { el.innerHTML = renderDeployments(snapshot.deployments?.recent_failed); });
document.querySelectorAll("[data-live-table='audit.recent']").forEach((el) => { el.innerHTML = renderAudit(snapshot.audit?.recent); });
document.querySelectorAll("[data-live-table='health.problems']").forEach((el) => { el.innerHTML = renderHealthProblems(snapshot.health?.problems); });
(snapshot.workers?.recent || []).forEach((worker) => {
const row = Array.from(document.querySelectorAll("tr[data-worker-id]"))
+1 -1
View File
@@ -46,7 +46,7 @@ def page(title: str, body: str, user=None) -> str:
return f"""
<html>
<head>
<title>{html.escape(title)} | {APP_NAME}</title>
<title>{APP_NAME} - {html.escape(title)}</title>
<link rel="stylesheet" href="{PORTAL_PREFIX}/static/styles.css">
<script src="{PORTAL_PREFIX}/static/portal.js"></script>
</head>