Files
appfactory-portal/app/routes/logs.py
T
2026-06-17 11:39:52 +02:00

178 lines
6.8 KiB
Python

import html
import math
from urllib.parse import urlencode
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth import require_user
from app.db.jobs import count_job_logs, search_job_logs
from app.routes.jobs import render_job_status
from app.templates.layout import page
router = APIRouter()
STREAM_FILTERS = ("stdout", "stderr", "system")
PAGE_SIZE = 100
def _can_view_logs(user: dict) -> bool:
return (user.get("role") or "").lower() in ("admin", "developer")
def render_stream_pill(stream: str | None) -> str:
value = (stream or "system").lower()
if value == "stderr":
return '<span class="pill pill-danger">stderr</span>'
if value == "stdout":
return '<span class="pill pill-success">stdout</span>'
return '<span class="pill pill-muted">system</span>'
def render_stream_options(selected: str) -> str:
options = ['<option value="">Všechny streamy</option>']
for value in STREAM_FILTERS:
selected_attr = " selected" if selected == value else ""
options.append(f'<option value="{value}"{selected_attr}>{value}</option>')
return "".join(options)
@router.get("/logs", response_class=HTMLResponse)
def logs_page(
request: Request,
q: str = Query(""),
stream: str = Query(""),
job: str = Query(""),
page_number: int = Query(1, alias="page", ge=1),
user=Depends(require_user),
):
if not _can_view_logs(user):
raise HTTPException(status_code=403, detail="Logy mohou číst jen developer nebo admin")
query = q.strip()
selected_stream = stream.strip().lower()
if selected_stream not in STREAM_FILTERS:
selected_stream = ""
job_id: int | None = None
job_value = job.strip()
if job_value.isdigit():
job_id = int(job_value)
total = count_job_logs(query=query or None, stream=selected_stream or None, job_id=job_id)
total_pages = max(1, math.ceil(total / PAGE_SIZE))
if page_number > total_pages:
page_number = total_pages
offset = (page_number - 1) * PAGE_SIZE
logs = search_job_logs(
query=query or None,
stream=selected_stream or None,
job_id=job_id,
limit=PAGE_SIZE,
offset=offset,
)
rows = ""
for log in logs:
log_id = html.escape(str(log.get("id", "")))
job_id_html = html.escape(str(log.get("job_id", "")))
created_at = html.escape(log.get("created_at", "") or "")
job_type = html.escape(log.get("job_type", "") or "")
target_type = html.escape(log.get("target_type", "") or "")
target_id = html.escape(log.get("target_id", "") or "")
message = log.get("message", "") or ""
# Náhled zprávy ať tabulka nepřeteče; plný text je po prokliku na úlohu.
preview = message if len(message) <= 400 else f"{message[:397]}..."
message_html = html.escape(preview)
# Proklik míří přímo na konkrétní řádek logu v detailu úlohy (kotva #log-{id}).
deep_link = f"/portal/jobs/{job_id_html}#log-{log_id}"
rows += f"""
<tr>
<td>{created_at}</td>
<td><strong><a href="/portal/jobs/{job_id_html}">#{job_id_html}</a></strong><br><span class="muted">{job_type}</span></td>
<td>{render_stream_pill(log.get("stream"))}</td>
<td>{render_job_status(log.get("job_status"))}<br><span class="muted">{target_type}: {target_id}</span></td>
<td><pre class="log-viewer log-inline">{message_html}</pre></td>
<td class="actions-cell"><a class="icon-action" href="{deep_link}" title="Zobrazit v úloze" aria-label="Zobrazit v úloze"><i class="fa-solid fa-up-right-from-square" aria-hidden="true"></i></a></td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="6">Žádné logy neodpovídají filtru.</td></tr>'
def page_url(target_page: int) -> str:
params = {"page": target_page}
if query:
params["q"] = query
if selected_stream:
params["stream"] = selected_stream
if job_value:
params["job"] = job_value
return f"/portal/logs?{urlencode(params)}"
first_item = offset + 1 if total else 0
last_item = min(offset + len(logs), total)
previous_link = (
f'<a class="btn btn-secondary" href="{page_url(page_number - 1)}"><i class="fa-solid fa-chevron-left" aria-hidden="true"></i> Předchozí</a>'
if page_number > 1
else ""
)
next_link = (
f'<a class="btn btn-secondary" href="{page_url(page_number + 1)}">Další <i class="fa-solid fa-chevron-right" aria-hidden="true"></i></a>'
if page_number < total_pages
else ""
)
pagination = ""
if total_pages > 1:
pagination = f"""
<div class="pagination">
<span>Zobrazeno {first_item}-{last_item} z {total}</span>
<div class="pagination-actions">
{previous_link}
<span class="page-indicator">Strana {page_number} / {total_pages}</span>
{next_link}
</div>
</div>
"""
return page(
"Logy",
f"""
<div class="card">
<h2><i class="fa-solid fa-file-lines" aria-hidden="true"></i> Logy</h2>
<p class="muted">
Souhrnné čtení logů napříč všemi úlohami. Vyfiltrujte si chyby (stream <strong>stderr</strong>)
nebo hledejte text a proklikněte se přímo na problematický řádek konkrétní úlohy.
</p>
<form method="get" action="/portal/logs" class="filter-form">
<input name="q" value="{html.escape(query)}" placeholder="Hledat v textu logu">
<select name="stream">{render_stream_options(selected_stream)}</select>
<input name="job" value="{html.escape(job_value)}" placeholder="ID úlohy" inputmode="numeric">
<input type="hidden" name="page" value="1">
<button type="submit"><i class="fa-solid fa-filter" aria-hidden="true"></i> Filtrovat</button>
<a class="btn btn-secondary" href="/portal/logs?stream=stderr"><i class="fa-solid fa-triangle-exclamation" aria-hidden="true"></i> Jen chyby</a>
<a class="btn btn-secondary" href="/portal/logs"><i class="fa-solid fa-arrow-rotate-left" aria-hidden="true"></i> Reset</a>
</form>
</div>
<div class="card">
<h2>Záznamy</h2>
{pagination}
<table>
<tr>
<th>Čas</th>
<th>Úloha</th>
<th>Stream</th>
<th>Stav / cíl</th>
<th>Zpráva</th>
<th>Akce</th>
</tr>
{rows}
</table>
{pagination}
</div>
""",
user=user,
)