191 lines
7.2 KiB
Python
191 lines
7.2 KiB
Python
"""Stránka "Logy" v detailu služby - diagnostické čtení Docker logů kontejneru.
|
|
|
|
Účel: administrátor uvidí konkrétní exception (Kestrel / Uvicorn / Node.js) bez SSH
|
|
přístupu na server. Portál logy nikam neukládá, jen je přečte z Dockeru a zobrazí.
|
|
|
|
Container se odvozuje z ``app_id`` služby načtené z databáze AppFactory - z URL se bere
|
|
pouze ID služby, nikdy název kontejneru. Vlastní volání dockeru řeší ``app.docker_logs``.
|
|
"""
|
|
|
|
import html
|
|
from urllib.parse import quote
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
|
from fastapi.responses import HTMLResponse
|
|
|
|
from ..auth import require_developer
|
|
from ..db.apps import get_app
|
|
from ..db.audit import log_audit_event
|
|
from ..docker_logs import (
|
|
ALLOWED_LOG_TAILS,
|
|
LOG_PERIOD_LABELS,
|
|
DockerLogsError,
|
|
docker_daemon_available,
|
|
filter_log_lines,
|
|
get_container_logs,
|
|
is_error_line,
|
|
normalize_period,
|
|
normalize_tail,
|
|
resolve_app_container,
|
|
split_log_lines,
|
|
state_label,
|
|
)
|
|
from ..templates.layout import page
|
|
|
|
router = APIRouter()
|
|
|
|
# Stav kontejneru -> třída pilulky (stejné barvy jako u ostatních stavů v portálu).
|
|
STATE_PILL_CLASSES = {
|
|
"running": "pill pill-success",
|
|
"restarting": "pill pill-warning",
|
|
"paused": "pill pill-warning",
|
|
"created": "pill pill-muted",
|
|
"removing": "pill pill-warning",
|
|
"exited": "pill pill-danger",
|
|
"dead": "pill pill-danger",
|
|
}
|
|
|
|
|
|
def render_state_pill(status: str | None) -> str:
|
|
if not status:
|
|
return '<span class="pill pill-muted">Neexistuje</span>'
|
|
css = STATE_PILL_CLASSES.get(status, "pill pill-muted")
|
|
return f'<span class="{css}">{html.escape(state_label(status))}</span>'
|
|
|
|
|
|
def render_period_options(selected: str) -> str:
|
|
options = ""
|
|
for value, label in LOG_PERIOD_LABELS:
|
|
selected_attr = " selected" if value == selected else ""
|
|
options += f'<option value="{value}"{selected_attr}>{label}</option>'
|
|
return options
|
|
|
|
|
|
def render_tail_options(selected: int) -> str:
|
|
options = ""
|
|
for value in ALLOWED_LOG_TAILS:
|
|
selected_attr = " selected" if value == selected else ""
|
|
options += f'<option value="{value}"{selected_attr}>{value}</option>'
|
|
return options
|
|
|
|
|
|
def render_log_lines(lines: list[str]) -> str:
|
|
"""Vykreslí řádky logu. Obsah se nemění, chybové řádky se jen vizuálně zvýrazní."""
|
|
if not lines:
|
|
return '<p class="muted">Ve zvoleném období nejsou žádné řádky logu.</p>'
|
|
|
|
rendered = []
|
|
for line in lines:
|
|
css = "log-line log-line-error" if is_error_line(line) else "log-line"
|
|
# Prázdný řádek by se bez pevné mezery smrskl na nulovou výšku.
|
|
rendered.append(f'<span class="{css}">{html.escape(line) or " "}</span>')
|
|
return f'<pre class="docker-log log-stdout">{"".join(rendered)}</pre>'
|
|
|
|
|
|
def render_docker_error(container_name: str, detail: str) -> str:
|
|
daemon = "dostupný" if docker_daemon_available() else "nedostupný"
|
|
return f"""
|
|
<p class="alert alert-danger">Logy se nepodařilo načíst.</p>
|
|
<table>
|
|
<tr><th>Docker daemon</th><td>{daemon}</td></tr>
|
|
<tr><th>Container</th><td>{html.escape(container_name) or "neznámý"}</td></tr>
|
|
<tr><th>Detail</th><td>{html.escape(detail)}</td></tr>
|
|
</table>
|
|
"""
|
|
|
|
|
|
@router.get("/apps/{app_id}/logs", response_class=HTMLResponse)
|
|
def app_logs_page(
|
|
app_id: str,
|
|
since: str = Query(""),
|
|
tail: str = Query(""),
|
|
q: str = Query(""),
|
|
user=Depends(require_developer),
|
|
):
|
|
app = get_app(app_id)
|
|
if not app:
|
|
raise HTTPException(status_code=404, detail="App not found")
|
|
|
|
real_app_id = (app.get("id") or "").strip()
|
|
app_url_id = quote(real_app_id, safe="")
|
|
period = normalize_period(since)
|
|
tail_value = normalize_tail(tail)
|
|
query = (q or "").strip()
|
|
|
|
log_audit_event(
|
|
user,
|
|
action="service.logs.view",
|
|
target_type="service",
|
|
target_id=real_app_id,
|
|
metadata={"since": period, "tail": tail_value},
|
|
)
|
|
|
|
container_name = ""
|
|
container_state: str | None = None
|
|
error_detail = ""
|
|
all_lines: list[str] = []
|
|
|
|
try:
|
|
container_name, container_state = resolve_app_container(real_app_id)
|
|
if container_state:
|
|
all_lines = split_log_lines(get_container_logs(container_name, since=period, tail=tail_value))
|
|
except DockerLogsError as exc:
|
|
error_detail = str(exc)
|
|
|
|
visible_lines = filter_log_lines(all_lines, query)
|
|
|
|
if error_detail:
|
|
log_body = render_docker_error(container_name, error_detail)
|
|
elif not container_state:
|
|
log_body = (
|
|
'<p class="alert alert-danger">Container není spuštěný nebo neexistuje.</p>'
|
|
'<p class="muted">Logy lze číst jen dokud kontejner v Dockeru existuje. '
|
|
"Po smazání kontejneru (například při redeployi) Docker jeho logy zahodí.</p>"
|
|
)
|
|
else:
|
|
summary = f"Načteno {len(all_lines)} řádků."
|
|
if query:
|
|
summary = f"Zobrazeno {len(visible_lines)} z {len(all_lines)} načtených řádků (filtr: {html.escape(query)})."
|
|
log_body = f'<p class="muted">{summary}</p>{render_log_lines(visible_lines)}'
|
|
|
|
return page(
|
|
"Logy služby",
|
|
f"""
|
|
<div class="card">
|
|
<h2><i class="fa-solid fa-terminal" aria-hidden="true"></i> Logy kontejneru</h2>
|
|
<p class="muted">
|
|
Diagnostické čtení Docker logů běžící služby (stdout i stderr dohromady).
|
|
Portál logy neukládá, jen je čte přímo z Dockeru.
|
|
</p>
|
|
<p>
|
|
<a class="btn" href="/portal/apps/{app_url_id}">← Zpět na detail služby</a>
|
|
<a class="btn btn-secondary" href="/portal/jobs?target={app_url_id}"><i class="fa-solid fa-list-check" aria-hidden="true"></i> Úlohy služby</a>
|
|
</p>
|
|
<table>
|
|
<tr><th>Aplikace</th><td>{html.escape(real_app_id)}</td></tr>
|
|
<tr><th>Container</th><td>{html.escape(container_name) or "neurčeno"}</td></tr>
|
|
<tr><th>Stav</th><td>{render_state_pill(container_state)}</td></tr>
|
|
</table>
|
|
</div>
|
|
|
|
<div class="card">
|
|
<h2><i class="fa-solid fa-filter" aria-hidden="true"></i> Výpis</h2>
|
|
<form method="get" action="/portal/apps/{app_url_id}/logs" class="filter-form">
|
|
<label class="filter-field">Období
|
|
<select name="since">{render_period_options(period)}</select>
|
|
</label>
|
|
<label class="filter-field">Počet řádků
|
|
<select name="tail">{render_tail_options(tail_value)}</select>
|
|
</label>
|
|
<label class="filter-field">Hledat
|
|
<input name="q" value="{html.escape(query, quote=True)}" placeholder="např. NullReferenceException">
|
|
</label>
|
|
<button type="submit"><i class="fa-solid fa-arrow-rotate-right" aria-hidden="true"></i> Obnovit</button>
|
|
<a class="btn btn-secondary" href="/portal/apps/{app_url_id}/logs"><i class="fa-solid fa-arrow-rotate-left" aria-hidden="true"></i> Reset</a>
|
|
</form>
|
|
{log_body}
|
|
</div>
|
|
""",
|
|
user=user,
|
|
)
|