docker logy
This commit is contained in:
@@ -0,0 +1,234 @@
|
|||||||
|
"""Diagnostické čtení Docker logů kontejneru služby.
|
||||||
|
|
||||||
|
Portál si logy nikam nekopíruje ani nezakládá vlastní úložiště - jen čte to, co už
|
||||||
|
Docker drží pro daný kontejner (`docker logs`). Slouží k dohledání příčiny HTTP 500
|
||||||
|
bez SSH přístupu na server.
|
||||||
|
|
||||||
|
Bezpečnost:
|
||||||
|
* docker se volá vždy přes argument array (subprocess bez ``shell=True``), takže se
|
||||||
|
uživatelský vstup nemůže dostat do příkazové řádky,
|
||||||
|
* název kontejneru se odvozuje z ``app_id`` služby existující v databázi, nikdy se
|
||||||
|
nebere přímo z URL,
|
||||||
|
* core služby AppFactory jsou z tohoto čtení vyloučené (CORE_CONTAINERS),
|
||||||
|
* ``since`` a ``tail`` mají allowlist, cokoli jiného spadne na bezpečný default.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import re
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
# Docker odpovídá lokálně přes socket; delší čekání by jen blokovalo request portálu.
|
||||||
|
DOCKER_TIMEOUT_SECONDS = 10
|
||||||
|
|
||||||
|
# Povolené hodnoty parametru --since. Klíč je hodnota z URL, hodnota jde do dockeru.
|
||||||
|
ALLOWED_LOG_PERIODS = {
|
||||||
|
"15m": "15m",
|
||||||
|
"30m": "30m",
|
||||||
|
"1h": "1h",
|
||||||
|
"6h": "6h",
|
||||||
|
"24h": "24h",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Popisky pro UI v pořadí, v jakém se mají nabídnout.
|
||||||
|
LOG_PERIOD_LABELS = (
|
||||||
|
("15m", "posledních 15 minut"),
|
||||||
|
("30m", "posledních 30 minut"),
|
||||||
|
("1h", "poslední hodina"),
|
||||||
|
("6h", "posledních 6 hodin"),
|
||||||
|
("24h", "posledních 24 hodin"),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Povolené hodnoty parametru --tail.
|
||||||
|
ALLOWED_LOG_TAILS = (100, 300, 500, 1000)
|
||||||
|
|
||||||
|
DEFAULT_LOG_PERIOD = "30m"
|
||||||
|
DEFAULT_LOG_TAIL = 500
|
||||||
|
|
||||||
|
# Core služby AppFactory. Diagnostika uživatelských aplikací k nim nemá přístup - pro
|
||||||
|
# jejich logy by muselo vzniknout samostatné administrační oprávnění.
|
||||||
|
CORE_CONTAINERS = frozenset(
|
||||||
|
{
|
||||||
|
"appfactory-gitea",
|
||||||
|
"appfactory-portal",
|
||||||
|
"appfactory-worker",
|
||||||
|
"appfactory-monitor",
|
||||||
|
"appfactory-webhook",
|
||||||
|
"appfactory-caddy",
|
||||||
|
"appfactory-registry",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Docker povoluje v názvu kontejneru [a-zA-Z0-9][a-zA-Z0-9_.-]*. Držíme se toho i tady,
|
||||||
|
# ať se do argumentu nikdy nedostane nic jiného (obrana navíc, název stejně vzniká z app_id).
|
||||||
|
CONTAINER_NAME_RE = re.compile(r"^[a-zA-Z0-9][a-zA-Z0-9_.-]{0,127}$")
|
||||||
|
|
||||||
|
# Typické chybové indikátory - slouží jen ke zvýraznění řádku, obsah logu se nemění.
|
||||||
|
ERROR_LINE_RE = re.compile(r"error|fail|exception|fatal|\b50[0234]\b", re.IGNORECASE)
|
||||||
|
|
||||||
|
# Stavy kontejneru, jak je vrací `docker inspect --format {{.State.Status}}`.
|
||||||
|
CONTAINER_STATE_LABELS = {
|
||||||
|
"created": "Created",
|
||||||
|
"restarting": "Restarting",
|
||||||
|
"running": "Running",
|
||||||
|
"removing": "Removing",
|
||||||
|
"paused": "Paused",
|
||||||
|
"exited": "Exited",
|
||||||
|
"dead": "Dead",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class DockerLogsError(Exception):
|
||||||
|
"""Chyba čtení Docker logů. Text je určený k zobrazení uživateli."""
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_period(value: str | None) -> str:
|
||||||
|
"""Vrátí povolenou hodnotu --since, jinak bezpečný default."""
|
||||||
|
return ALLOWED_LOG_PERIODS.get((value or "").strip(), DEFAULT_LOG_PERIOD)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_tail(value) -> int:
|
||||||
|
"""Vrátí povolený počet řádků --tail, jinak bezpečný default."""
|
||||||
|
try:
|
||||||
|
number = int(str(value).strip())
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return DEFAULT_LOG_TAIL
|
||||||
|
return number if number in ALLOWED_LOG_TAILS else DEFAULT_LOG_TAIL
|
||||||
|
|
||||||
|
|
||||||
|
def state_label(status: str | None) -> str:
|
||||||
|
if not status:
|
||||||
|
return ""
|
||||||
|
return CONTAINER_STATE_LABELS.get(status, status)
|
||||||
|
|
||||||
|
|
||||||
|
def container_candidates(app_id: str) -> list[str]:
|
||||||
|
"""Názvy kontejnerů, pod kterými může služba běžet (stejné schéma jako u restartu .env).
|
||||||
|
|
||||||
|
Core kontejnery a syntakticky nemožné názvy se z kandidátů rovnou vyhazují.
|
||||||
|
"""
|
||||||
|
base = (app_id or "").strip()
|
||||||
|
normalized = base.replace("_", "-")
|
||||||
|
candidates: list[str] = []
|
||||||
|
for candidate in (base, normalized, f"appfactory-{normalized}"):
|
||||||
|
if not candidate or candidate in candidates:
|
||||||
|
continue
|
||||||
|
if candidate in CORE_CONTAINERS:
|
||||||
|
continue
|
||||||
|
if not CONTAINER_NAME_RE.match(candidate):
|
||||||
|
continue
|
||||||
|
candidates.append(candidate)
|
||||||
|
return candidates
|
||||||
|
|
||||||
|
|
||||||
|
def _ensure_container_allowed(container_name: str) -> str:
|
||||||
|
name = (container_name or "").strip()
|
||||||
|
if not CONTAINER_NAME_RE.match(name):
|
||||||
|
raise DockerLogsError("Neplatný název kontejneru.")
|
||||||
|
if name in CORE_CONTAINERS:
|
||||||
|
raise DockerLogsError("Logy core služeb AppFactory nejsou přes tuto stránku dostupné.")
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _run_docker(args: list[str], merge_stderr: bool = False) -> subprocess.CompletedProcess:
|
||||||
|
"""Spustí docker přes argument array. Nikdy shell=True, nikdy skládání příkazu z textu."""
|
||||||
|
try:
|
||||||
|
return subprocess.run(
|
||||||
|
["docker", *args],
|
||||||
|
stdout=subprocess.PIPE,
|
||||||
|
# U `docker logs` teče stdout kontejneru do stdout a stderr do stderr. Sloučením
|
||||||
|
# do jednoho proudu zůstane zachované původní pořadí řádků, což je pro hledání
|
||||||
|
# příčiny chyby podstatné.
|
||||||
|
stderr=subprocess.STDOUT if merge_stderr else subprocess.PIPE,
|
||||||
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
|
timeout=DOCKER_TIMEOUT_SECONDS,
|
||||||
|
)
|
||||||
|
except FileNotFoundError as exc:
|
||||||
|
raise DockerLogsError("Docker klient není v portálu dostupný.") from exc
|
||||||
|
except subprocess.TimeoutExpired as exc:
|
||||||
|
raise DockerLogsError(f"Docker neodpověděl do {DOCKER_TIMEOUT_SECONDS} sekund (timeout).") from exc
|
||||||
|
except OSError as exc:
|
||||||
|
raise DockerLogsError(f"Docker se nepodařilo spustit: {exc}") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def docker_daemon_available() -> bool:
|
||||||
|
"""True, pokud portál dosáhne na Docker daemon (volá se jen při chybě, kvůli hlášce)."""
|
||||||
|
try:
|
||||||
|
result = _run_docker(["version", "--format", "{{.Server.Version}}"])
|
||||||
|
except DockerLogsError:
|
||||||
|
return False
|
||||||
|
return result.returncode == 0
|
||||||
|
|
||||||
|
|
||||||
|
def inspect_container(container_name: str) -> str | None:
|
||||||
|
"""Stav kontejneru (running/exited/paused/...), nebo None když v Dockeru není."""
|
||||||
|
name = _ensure_container_allowed(container_name)
|
||||||
|
result = _run_docker(["inspect", "--format", "{{.State.Status}}", name])
|
||||||
|
if result.returncode != 0:
|
||||||
|
return None
|
||||||
|
return (result.stdout or "").strip().lower() or None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_app_container(app_id: str) -> tuple[str, str | None]:
|
||||||
|
"""Najde kontejner služby. Vrací (název, stav); stav je None, když kontejner neexistuje."""
|
||||||
|
candidates = container_candidates(app_id)
|
||||||
|
if not candidates:
|
||||||
|
raise DockerLogsError("Ze služby nelze odvodit platný název kontejneru.")
|
||||||
|
|
||||||
|
for candidate in candidates:
|
||||||
|
status = inspect_container(candidate)
|
||||||
|
if status:
|
||||||
|
return candidate, status
|
||||||
|
|
||||||
|
return candidates[0], None
|
||||||
|
|
||||||
|
|
||||||
|
def get_container_logs(
|
||||||
|
container_name: str,
|
||||||
|
since: str = DEFAULT_LOG_PERIOD,
|
||||||
|
tail: int = DEFAULT_LOG_TAIL,
|
||||||
|
) -> str:
|
||||||
|
"""Vrátí `docker logs --since <since> --tail <tail> <container>` jako text (stdout i stderr)."""
|
||||||
|
name = _ensure_container_allowed(container_name)
|
||||||
|
since_value = normalize_period(since)
|
||||||
|
tail_value = normalize_tail(tail)
|
||||||
|
|
||||||
|
result = _run_docker(
|
||||||
|
["logs", "--since", since_value, "--tail", str(tail_value), name],
|
||||||
|
merge_stderr=True,
|
||||||
|
)
|
||||||
|
output = result.stdout or ""
|
||||||
|
if result.returncode != 0:
|
||||||
|
detail = short_detail(output)
|
||||||
|
raise DockerLogsError(detail or f"Příkaz docker logs skončil s návratovým kódem {result.returncode}.")
|
||||||
|
return output
|
||||||
|
|
||||||
|
|
||||||
|
def short_detail(value: str) -> str:
|
||||||
|
"""Zkrácený technický detail chyby pro zobrazení (escapuje se až v šabloně)."""
|
||||||
|
text = " ".join((value or "").split())
|
||||||
|
if len(text) > 500:
|
||||||
|
return f"{text[:497]}..."
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def split_log_lines(text: str) -> list[str]:
|
||||||
|
if not text:
|
||||||
|
return []
|
||||||
|
normalized = text.replace("\r\n", "\n").replace("\r", "\n")
|
||||||
|
lines = normalized.split("\n")
|
||||||
|
while lines and not lines[-1]:
|
||||||
|
lines.pop()
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def filter_log_lines(lines: list[str], query: str) -> list[str]:
|
||||||
|
"""Filtruje už načtené řádky v Pythonu - žádný grep, žádný shell."""
|
||||||
|
needle = (query or "").strip().lower()
|
||||||
|
if not needle:
|
||||||
|
return list(lines)
|
||||||
|
return [line for line in lines if needle in line.lower()]
|
||||||
|
|
||||||
|
|
||||||
|
def is_error_line(line: str) -> bool:
|
||||||
|
return bool(ERROR_LINE_RE.search(line or ""))
|
||||||
+2
-1
@@ -6,7 +6,7 @@ from starlette.middleware.sessions import SessionMiddleware
|
|||||||
|
|
||||||
from .config import read_env_bool, read_env_value
|
from .config import read_env_bool, read_env_value
|
||||||
from .logging_config import configure_logging
|
from .logging_config import configure_logging
|
||||||
from .routes import alerting, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, ip_access, jobs, logs, operations, runtime, scheduled_scripts, users, workers
|
from .routes import alerting, app_logs, apps, audit, auth, backups, catalog, deployments, developers, environment, health, incidents, ip_access, jobs, logs, operations, runtime, scheduled_scripts, users, workers
|
||||||
|
|
||||||
|
|
||||||
def create_app() -> FastAPI:
|
def create_app() -> FastAPI:
|
||||||
@@ -28,6 +28,7 @@ def create_app() -> FastAPI:
|
|||||||
app.include_router(catalog.router)
|
app.include_router(catalog.router)
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(apps.router)
|
app.include_router(apps.router)
|
||||||
|
app.include_router(app_logs.router)
|
||||||
app.include_router(developers.router)
|
app.include_router(developers.router)
|
||||||
app.include_router(backups.router)
|
app.include_router(backups.router)
|
||||||
app.include_router(deployments.router)
|
app.include_router(deployments.router)
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"""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,
|
||||||
|
)
|
||||||
+4
-1
@@ -838,11 +838,14 @@ def app_detail(app_id: str, request: Request, message: str = "", error: str = ""
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Odkazy jen otevřou a najedou na sekci - všechny sekce jsou defaultně sbalené.
|
# Odkazy jen otevřou a najedou na sekci - všechny sekce jsou defaultně sbalené.
|
||||||
|
# Logy kontejneru mají vlastní stránku (čtou se živě z Dockeru), ostatní záložky
|
||||||
|
# jen otevřou sbalenou sekci na tomto detailu.
|
||||||
manage_tabs = (
|
manage_tabs = (
|
||||||
"""
|
f"""
|
||||||
<a class="btn btn-secondary" href="#metadata"><i class="fa-solid fa-table-list" aria-hidden="true"></i> Metadata</a>
|
<a class="btn btn-secondary" href="#metadata"><i class="fa-solid fa-table-list" aria-hidden="true"></i> Metadata</a>
|
||||||
<a class="btn btn-secondary" href="#promenne"><i class="fa-solid fa-sliders" aria-hidden="true"></i> Proměnné</a>
|
<a class="btn btn-secondary" href="#promenne"><i class="fa-solid fa-sliders" aria-hidden="true"></i> Proměnné</a>
|
||||||
<a class="btn btn-secondary" href="#ip-access"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access</a>
|
<a class="btn btn-secondary" href="#ip-access"><i class="fa-solid fa-shield-halved" aria-hidden="true"></i> Security / IP Access</a>
|
||||||
|
<a class="btn btn-secondary" href="/portal/apps/{app_url_id}/logs"><i class="fa-solid fa-terminal" aria-hidden="true"></i> Logy</a>
|
||||||
"""
|
"""
|
||||||
if can_manage
|
if can_manage
|
||||||
else ""
|
else ""
|
||||||
|
|||||||
@@ -1205,6 +1205,44 @@ pre {
|
|||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Diagnostické Docker logy kontejneru služby (detail služby -> Logy).
|
||||||
|
white-space: pre + vodorovný posuv: dlouhý řádek stack trace se nezalamuje ani
|
||||||
|
nerozbíjí layout stránky, jen se v bloku odscrolluje. */
|
||||||
|
.docker-log {
|
||||||
|
max-height: 640px;
|
||||||
|
min-height: 200px;
|
||||||
|
margin: 0;
|
||||||
|
padding: 14px 12px;
|
||||||
|
white-space: pre;
|
||||||
|
overflow: auto;
|
||||||
|
font-family: Consolas, "Liberation Mono", Menlo, monospace;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-log .log-line {
|
||||||
|
display: block;
|
||||||
|
min-width: max-content;
|
||||||
|
padding: 0 4px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docker-log .log-line-error {
|
||||||
|
background: rgba(184, 34, 62, 0.45);
|
||||||
|
color: #ffe5ea;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Popisek nad ovládacím prvkem filtru (Období / Počet řádků / Hledat). */
|
||||||
|
.filter-field {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
/* Zvýraznění řádku, na který se uživatel proklikl z globálního prohlížeče logů. */
|
/* Zvýraznění řádku, na který se uživatel proklikl z globálního prohlížeče logů. */
|
||||||
.job-log-entry:target {
|
.job-log-entry:target {
|
||||||
outline: 3px solid var(--primary);
|
outline: 3px solid var(--primary);
|
||||||
|
|||||||
Reference in New Issue
Block a user