178 lines
6.8 KiB
Python
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,
|
|
)
|