life jobs logs
This commit is contained in:
+133
-6
@@ -1,19 +1,21 @@
|
||||
import asyncio
|
||||
import html
|
||||
import json
|
||||
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 app.auth import require_user
|
||||
from app.auth import current_user, require_user
|
||||
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.templates.layout import page
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
LIVE_STATUSES = {"queued", "running", "cancelled_requested"}
|
||||
FINISHED_STATUSES = {"success", "failed", "cancelled"}
|
||||
JOB_STATUSES = {"queued", "running", "cancelled_requested", "cancelled", "success", "failed"}
|
||||
JOB_FILTER_STATUSES = ("queued", "running", "success", "failed", "cancelled")
|
||||
JOB_FILTER_TYPES = ("deploy_app", "deploy_core_service")
|
||||
@@ -68,6 +70,60 @@ def can_retry_job(job: dict) -> bool:
|
||||
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)
|
||||
def jobs_page(
|
||||
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")
|
||||
|
||||
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)))}"
|
||||
status = render_job_status(job.get("status"))
|
||||
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")
|
||||
created_at = html.escape(log.get("created_at", "") 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"""
|
||||
<div class="job-log-entry">
|
||||
<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:
|
||||
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(
|
||||
title,
|
||||
f"""
|
||||
@@ -281,9 +401,16 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
|
||||
</div>
|
||||
|
||||
<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}
|
||||
</div>
|
||||
{live_logs_script}
|
||||
""",
|
||||
user=user,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user