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:
@@ -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"}
|
||||||
|
]
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
from app.db.database import get_connection
|
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.migrations import run_migrations
|
||||||
from app.db.workers import is_worker_online
|
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')
|
WHERE LOWER(COALESCE(status, '')) IN ('failed', 'error', 'disabled', 'deleted', 'archived')
|
||||||
"""
|
"""
|
||||||
).fetchone()["count"]
|
).fetchone()["count"]
|
||||||
|
health_summary = get_health_summary()
|
||||||
|
health_problems = get_problem_service_health()
|
||||||
|
|
||||||
jobs_summary = con.execute(
|
jobs_summary = con.execute(
|
||||||
"""
|
"""
|
||||||
@@ -296,6 +299,12 @@ def get_operations_snapshot():
|
|||||||
"active": active_applications,
|
"active": active_applications,
|
||||||
"problematic": problematic_applications,
|
"problematic": problematic_applications,
|
||||||
},
|
},
|
||||||
|
"health": {
|
||||||
|
"healthy": health_summary["healthy"],
|
||||||
|
"unhealthy": health_summary["unhealthy"],
|
||||||
|
"unreachable": health_summary["unreachable"],
|
||||||
|
"problems": health_problems,
|
||||||
|
},
|
||||||
"workers": {
|
"workers": {
|
||||||
"online": workers_online,
|
"online": workers_online,
|
||||||
"offline": max(0, total_workers - workers_online),
|
"offline": max(0, total_workers - workers_online),
|
||||||
|
|||||||
+97
-2
@@ -17,6 +17,7 @@ from ..config import (
|
|||||||
)
|
)
|
||||||
from ..db.apps import get_app, get_app_deployments, get_apps, update_app_resources
|
from ..db.apps import get_app, get_app_deployments, get_apps, update_app_resources
|
||||||
from ..db.audit import log_audit_event
|
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 ..db.jobs import create_job, get_jobs, has_active_deploy_job
|
||||||
from ..routes.deployments import render_status_pill
|
from ..routes.deployments import render_status_pill
|
||||||
from ..shell import run_command
|
from ..shell import run_command
|
||||||
@@ -26,6 +27,24 @@ router = APIRouter()
|
|||||||
DEFAULT_PAGE_SIZE = 20
|
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("/")
|
@router.get("/")
|
||||||
def portal_home(user=Depends(require_user)):
|
def portal_home(user=Depends(require_user)):
|
||||||
return RedirectResponse(url="/portal/operations", status_code=303)
|
return RedirectResponse(url="/portal/operations", status_code=303)
|
||||||
@@ -40,6 +59,7 @@ def apps_page(
|
|||||||
user=Depends(require_user),
|
user=Depends(require_user),
|
||||||
):
|
):
|
||||||
apps = get_apps()
|
apps = get_apps()
|
||||||
|
latest_health = get_latest_service_health()
|
||||||
query = q.strip()
|
query = q.strip()
|
||||||
selected_status = status.strip()
|
selected_status = status.strip()
|
||||||
if query:
|
if query:
|
||||||
@@ -50,6 +70,16 @@ def apps_page(
|
|||||||
]
|
]
|
||||||
if selected_status:
|
if selected_status:
|
||||||
apps = [item for item in apps if (item.get("status", "") or "") == 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")})
|
status_values = sorted({item.get("status", "") for item in get_apps() if item.get("status")})
|
||||||
total_apps = len(apps)
|
total_apps = len(apps)
|
||||||
total_pages = max(1, math.ceil(total_apps / DEFAULT_PAGE_SIZE))
|
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_id = html.escape(item.get("id", ""))
|
||||||
app_url_id = quote(item.get("id", ""), safe="")
|
app_url_id = quote(item.get("id", ""), safe="")
|
||||||
status = html.escape(item.get("status", ""))
|
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"))
|
docs = html.escape(item.get("docs", f"/apps/{app_id}/docs"))
|
||||||
memory = item.get("memory", "")
|
memory = item.get("memory", "")
|
||||||
cpus = item.get("cpus", "")
|
cpus = item.get("cpus", "")
|
||||||
@@ -104,6 +137,7 @@ def apps_page(
|
|||||||
<span class="muted">/apps/{app_id}</span>
|
<span class="muted">/apps/{app_id}</span>
|
||||||
</td>
|
</td>
|
||||||
<td><span class="pill">{status}</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><a href="{docs}">Swagger</a></td>
|
||||||
<td>
|
<td>
|
||||||
<form method="post" action="/portal/update-resources">
|
<form method="post" action="/portal/update-resources">
|
||||||
@@ -150,7 +184,7 @@ def apps_page(
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
if not rows:
|
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>']
|
status_options = ['<option value="">Všechny stavy</option>']
|
||||||
for value in status_values:
|
for value in status_values:
|
||||||
selected = " selected" if selected_status == value else ""
|
selected = " selected" if selected_status == value else ""
|
||||||
@@ -165,6 +199,8 @@ def apps_page(
|
|||||||
params["q"] = query
|
params["q"] = query
|
||||||
if selected_status:
|
if selected_status:
|
||||||
params["status"] = selected_status
|
params["status"] = selected_status
|
||||||
|
if sort:
|
||||||
|
params["sort"] = sort
|
||||||
return f"/portal/apps?{urlencode(params)}"
|
return f"/portal/apps?{urlencode(params)}"
|
||||||
|
|
||||||
previous_link = (
|
previous_link = (
|
||||||
@@ -216,6 +252,10 @@ def apps_page(
|
|||||||
<form method="get" action="/portal/apps" class="filter-form">
|
<form method="get" action="/portal/apps" class="filter-form">
|
||||||
<input name="q" value="{html.escape(query)}" placeholder="Název nebo ID služby">
|
<input name="q" value="{html.escape(query)}" placeholder="Název nebo ID služby">
|
||||||
<select name="status">{"".join(status_options)}</select>
|
<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">
|
<input type="hidden" name="page" value="1">
|
||||||
<button type="submit">Filtrovat</button>
|
<button type="submit">Filtrovat</button>
|
||||||
<a class="btn btn-secondary" href="/portal/apps">Reset</a>
|
<a class="btn btn-secondary" href="/portal/apps">Reset</a>
|
||||||
@@ -225,6 +265,7 @@ def apps_page(
|
|||||||
<tr>
|
<tr>
|
||||||
<th>Služba</th>
|
<th>Služba</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
|
<th>Zdraví</th>
|
||||||
<th>Dokumentace</th>
|
<th>Dokumentace</th>
|
||||||
<th>Prostředky</th>
|
<th>Prostředky</th>
|
||||||
<th>Git</th>
|
<th>Git</th>
|
||||||
@@ -245,6 +286,13 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
|
|||||||
if not app:
|
if not app:
|
||||||
raise HTTPException(status_code=404, detail="App not found")
|
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", ""))
|
escaped_app_id = html.escape(app.get("id", ""))
|
||||||
app_url_id = quote(app.get("id", ""), safe="")
|
app_url_id = quote(app.get("id", ""), safe="")
|
||||||
name = html.escape(app.get("name", "") or "")
|
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 "")
|
memory = html.escape(app.get("memory", "") or "")
|
||||||
cpus = html.escape(app.get("cpus", "") or "")
|
cpus = html.escape(app.get("cpus", "") or "")
|
||||||
updated_at = html.escape(app.get("updated_at", "") 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 = ""
|
rows = ""
|
||||||
for deployment in get_app_deployments(app.get("id", ""), limit=10):
|
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:
|
if not job_rows:
|
||||||
job_rows = '<tr><td colspan="4">Zatím nejsou evidované žádné úlohy této služby.</td></tr>'
|
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(
|
return page(
|
||||||
f"Služba {escaped_app_id}",
|
name or escaped_app_id,
|
||||||
f"""
|
f"""
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>{escaped_app_id}</h2>
|
<h2>{escaped_app_id}</h2>
|
||||||
@@ -325,6 +396,30 @@ def app_detail(app_id: str, request: Request, user=Depends(require_user)):
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</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">
|
<div class="card">
|
||||||
<h2>Historie nasazení</h2>
|
<h2>Historie nasazení</h2>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -110,6 +110,46 @@ def render_recent_audit_events(events: list[dict]) -> str:
|
|||||||
return rows
|
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)
|
@router.get("/operations", response_class=HTMLResponse)
|
||||||
def operations_dashboard(request: Request, user=Depends(require_user)):
|
def operations_dashboard(request: Request, user=Depends(require_user)):
|
||||||
snapshot = get_operations_snapshot()
|
snapshot = get_operations_snapshot()
|
||||||
@@ -145,12 +185,29 @@ def operations_dashboard(request: Request, user=Depends(require_user)):
|
|||||||
<div class="stats-grid">
|
<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-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-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-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-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-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 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>
|
||||||
|
|
||||||
|
<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">
|
<div class="card">
|
||||||
<h2>Poslední incidenty</h2>
|
<h2>Poslední incidenty</h2>
|
||||||
<table>
|
<table>
|
||||||
|
|||||||
@@ -44,6 +44,21 @@
|
|||||||
? '<span class="pill pill-success">online</span>'
|
? '<span class="pill pill-success">online</span>'
|
||||||
: '<span class="pill pill-danger">offline</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) => {
|
const setText = (selector, value) => {
|
||||||
document.querySelectorAll(selector).forEach((el) => {
|
document.querySelectorAll(selector).forEach((el) => {
|
||||||
el.textContent = value ?? 0;
|
el.textContent = value ?? 0;
|
||||||
@@ -112,6 +127,24 @@
|
|||||||
return rows || '<tr><td colspan="6">Zatím nejsou evidované žádné auditní události.</td></tr>';
|
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) => {
|
const renderSnapshot = (snapshot) => {
|
||||||
setText("[data-live-count='jobs.queued']", snapshot.jobs?.queued);
|
setText("[data-live-count='jobs.queued']", snapshot.jobs?.queued);
|
||||||
setText("[data-live-count='jobs.running']", snapshot.jobs?.running);
|
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='jobs.success_24h']", snapshot.jobs?.success_24h);
|
||||||
setText("[data-live-count='apps.active']", snapshot.apps?.active);
|
setText("[data-live-count='apps.active']", snapshot.apps?.active);
|
||||||
setText("[data-live-count='apps.problematic']", snapshot.apps?.problematic);
|
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.online']", snapshot.workers?.online);
|
||||||
setText("[data-live-count='workers.offline']", snapshot.workers?.offline);
|
setText("[data-live-count='workers.offline']", snapshot.workers?.offline);
|
||||||
setText("[data-live-count='workers.total']", (snapshot.workers?.online || 0) + (snapshot.workers?.offline || 0));
|
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.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='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='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) => {
|
(snapshot.workers?.recent || []).forEach((worker) => {
|
||||||
const row = Array.from(document.querySelectorAll("tr[data-worker-id]"))
|
const row = Array.from(document.querySelectorAll("tr[data-worker-id]"))
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ def page(title: str, body: str, user=None) -> str:
|
|||||||
return f"""
|
return f"""
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<title>{html.escape(title)} | {APP_NAME}</title>
|
<title>{APP_NAME} - {html.escape(title)}</title>
|
||||||
<link rel="stylesheet" href="{PORTAL_PREFIX}/static/styles.css">
|
<link rel="stylesheet" href="{PORTAL_PREFIX}/static/styles.css">
|
||||||
<script src="{PORTAL_PREFIX}/static/portal.js"></script>
|
<script src="{PORTAL_PREFIX}/static/portal.js"></script>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
Reference in New Issue
Block a user