life jobs logs

This commit is contained in:
JiriUhlir
2026-05-29 11:02:43 +02:00
parent e3019bd76e
commit 0dcf9b7b05
3 changed files with 166 additions and 6 deletions
+20
View File
@@ -346,6 +346,26 @@ def get_job_logs(job_id: int, limit: int = 1000):
return [dict(row) for row in rows] return [dict(row) for row in rows]
def get_job_logs_after(job_id: int, last_log_id: int = 0, limit: int = 100):
run_migrations()
con = get_connection()
rows = con.execute(
"""
SELECT *
FROM job_logs
WHERE job_id = ?
AND id > ?
ORDER BY id
LIMIT ?
""",
(job_id, last_log_id, limit),
).fetchall()
con.close()
return [dict(row) for row in rows]
def has_active_deploy_job(target_type: str, target_id: str): def has_active_deploy_job(target_type: str, target_id: str):
run_migrations() run_migrations()
con = get_connection() con = get_connection()
+133 -6
View File
@@ -1,19 +1,21 @@
import asyncio
import html import html
import json import json
from urllib.parse import quote from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.responses import HTMLResponse, RedirectResponse
from app.auth import require_user from app.auth import current_user, require_user
from app.db.audit import log_audit_event from app.db.audit import log_audit_event
from app.db.jobs import cancel_job, get_job, get_job_logs, get_job_stats, get_jobs, retry_failed_job from app.db.jobs import cancel_job, get_job, get_job_logs, get_job_logs_after, get_job_stats, get_jobs, retry_failed_job
from app.routes.deployments import calculate_duration, render_status_pill from app.routes.deployments import calculate_duration, render_status_pill
from app.templates.layout import page from app.templates.layout import page
router = APIRouter() router = APIRouter()
LIVE_STATUSES = {"queued", "running", "cancelled_requested"} LIVE_STATUSES = {"queued", "running", "cancelled_requested"}
FINISHED_STATUSES = {"success", "failed", "cancelled"}
JOB_STATUSES = {"queued", "running", "cancelled_requested", "cancelled", "success", "failed"} JOB_STATUSES = {"queued", "running", "cancelled_requested", "cancelled", "success", "failed"}
JOB_FILTER_STATUSES = ("queued", "running", "success", "failed", "cancelled") JOB_FILTER_STATUSES = ("queued", "running", "success", "failed", "cancelled")
JOB_FILTER_TYPES = ("deploy_app", "deploy_core_service") JOB_FILTER_TYPES = ("deploy_app", "deploy_core_service")
@@ -68,6 +70,60 @@ def can_retry_job(job: dict) -> bool:
return False return False
def log_message_payload(log: dict) -> dict:
return {
"type": "log",
"id": log.get("id"),
"stream": log.get("stream") or "system",
"message": log.get("message") or "",
"created_at": log.get("created_at") or "",
}
@router.websocket("/ws/jobs/{job_id}/logs")
async def job_logs_websocket(websocket: WebSocket, job_id: int):
user = current_user(websocket)
if not user:
await websocket.close(code=1008)
return
job = get_job(job_id)
if not job:
await websocket.close(code=1008)
return
await websocket.accept()
log_audit_event(
user,
action="job.logs.live.view",
target_type="job",
target_id=job_id,
)
last_log_id = 0
try:
while True:
while True:
logs = get_job_logs_after(job_id, last_log_id)
if not logs:
break
for log in logs:
last_log_id = max(last_log_id, int(log.get("id") or 0))
await websocket.send_json(log_message_payload(log))
job = get_job(job_id)
status_value = (job.get("status") or "").lower() if job else ""
if status_value in FINISHED_STATUSES:
await websocket.send_json({"type": "job_finished", "status": status_value})
await websocket.close()
return
await asyncio.sleep(1)
except WebSocketDisconnect:
return
@router.get("/jobs", response_class=HTMLResponse) @router.get("/jobs", response_class=HTMLResponse)
def jobs_page( def jobs_page(
request: Request, request: Request,
@@ -188,7 +244,7 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
raise HTTPException(status_code=404, detail="Job not found") raise HTTPException(status_code=404, detail="Job not found")
status_value = (job.get("status") or "").lower() status_value = (job.get("status") or "").lower()
refresh = "<script>setTimeout(() => window.location.reload(), 5000);</script>" if status_value in LIVE_STATUSES else "" refresh = ""
title = f"Job #{html.escape(str(job.get('id', job_id)))}" title = f"Job #{html.escape(str(job.get('id', job_id)))}"
status = render_job_status(job.get("status")) status = render_job_status(job.get("status"))
target_type = job.get("target_type", "") or "" target_type = job.get("target_type", "") or ""
@@ -227,7 +283,12 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
stream = html.escape(log.get("stream", "") or "system") stream = html.escape(log.get("stream", "") or "system")
created_at = html.escape(log.get("created_at", "") or "") created_at = html.escape(log.get("created_at", "") or "")
message = html.escape(log.get("message", "") or "") message = html.escape(log.get("message", "") or "")
class_name = "log-stderr" if stream == "stderr" else "log-stdout" if stream == "stderr":
class_name = "log-stderr"
elif stream == "system":
class_name = "log-system"
else:
class_name = "log-stdout"
log_blocks += f""" log_blocks += f"""
<div class="job-log-entry"> <div class="job-log-entry">
<div class="muted">{created_at} · {stream}</div> <div class="muted">{created_at} · {stream}</div>
@@ -238,6 +299,65 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
if not log_blocks: if not log_blocks:
log_blocks = '<p class="muted">Zatím nejsou uložené žádné logy.</p>' log_blocks = '<p class="muted">Zatím nejsou uložené žádné logy.</p>'
live_logs_script = f"""
<script>
(() => {{
const statusEl = document.getElementById("live-log-status");
const panel = document.getElementById("live-log-panel");
if (!statusEl || !panel || !window.WebSocket) {{
if (statusEl) statusEl.textContent = "Disconnected";
return;
}}
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const socket = new WebSocket(`${{protocol}}//${{window.location.host}}/portal/ws/jobs/{html.escape(str(job_id))}/logs`);
const appendLog = (item) => {{
const entry = document.createElement("div");
entry.className = "job-log-entry";
const meta = document.createElement("div");
meta.className = "muted";
meta.textContent = `${{item.created_at || ""}} · ${{item.stream || "system"}}`;
const pre = document.createElement("pre");
const stream = item.stream || "system";
const streamClass = stream === "stderr" ? "log-stderr" : (stream === "system" ? "log-system" : "log-stdout");
pre.className = `log-viewer ${{streamClass}}`;
pre.dataset.stream = stream;
pre.textContent = item.message || "";
entry.appendChild(meta);
entry.appendChild(pre);
panel.appendChild(entry);
panel.scrollTop = panel.scrollHeight;
}};
socket.addEventListener("open", () => {{
statusEl.textContent = "Connected";
}});
socket.addEventListener("message", (event) => {{
const item = JSON.parse(event.data);
if (item.type === "log") {{
appendLog(item);
}}
if (item.type === "job_finished") {{
statusEl.textContent = `Finished: ${{item.status}}`;
socket.close();
}}
}});
socket.addEventListener("close", () => {{
if (!statusEl.textContent.startsWith("Finished:")) {{
statusEl.textContent = "Disconnected";
}}
}});
socket.addEventListener("error", () => {{
statusEl.textContent = "Disconnected";
}});
}})();
</script>
"""
return page( return page(
title, title,
f""" f"""
@@ -281,9 +401,16 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
</div> </div>
<div class="card"> <div class="card">
<h2>Logy</h2> <h2>Live logy</h2>
<p class="muted">WebSocket: <span id="live-log-status">Connecting</span></p>
<div id="live-log-panel" class="log-viewer log-stdout"></div>
</div>
<div class="card">
<h2>Logy z DB</h2>
{log_blocks} {log_blocks}
</div> </div>
{live_logs_script}
""", """,
user=user, user=user,
) )
+13
View File
@@ -408,6 +408,19 @@ pre {
border: 1px solid #f3a8b7; border: 1px solid #f3a8b7;
} }
.log-system {
background: #263846;
color: #f2f7fa;
border: 1px solid #8aa4b4;
}
#live-log-panel .log-viewer {
min-height: 0;
max-height: none;
margin: 8px 0 12px;
padding: 12px;
}
.failed-log { .failed-log {
border-color: #f3a8b7; border-color: #f3a8b7;
} }