From 0dcf9b7b05d581a4851dec24668c63654d36f02d Mon Sep 17 00:00:00 2001 From: JiriUhlir <149317995+JiriUhlir@users.noreply.github.com> Date: Fri, 29 May 2026 11:02:43 +0200 Subject: [PATCH] life jobs logs --- app/db/jobs.py | 20 ++++++ app/routes/jobs.py | 139 ++++++++++++++++++++++++++++++++++++++++-- app/static/styles.css | 13 ++++ 3 files changed, 166 insertions(+), 6 deletions(-) diff --git a/app/db/jobs.py b/app/db/jobs.py index be50f7b..f3d43bc 100644 --- a/app/db/jobs.py +++ b/app/db/jobs.py @@ -346,6 +346,26 @@ def get_job_logs(job_id: int, limit: int = 1000): 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): run_migrations() con = get_connection() diff --git a/app/routes/jobs.py b/app/routes/jobs.py index beb5e4b..95ecef9 100644 --- a/app/routes/jobs.py +++ b/app/routes/jobs.py @@ -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 = "" 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"""
Zatím nejsou uložené žádné logy.
' + live_logs_script = f""" + + """ + return page( title, f""" @@ -281,9 +401,16 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):WebSocket: Connecting
+ +