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"""
{created_at} · {stream}
@@ -238,6 +299,65 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)): if not log_blocks: log_blocks = '

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)):
-

Logy

+

Live logy

+

WebSocket: Connecting

+
+
+ +
+

Logy z DB

{log_blocks}
+ {live_logs_script} """, user=user, ) diff --git a/app/static/styles.css b/app/static/styles.css index 062884f..73dff27 100644 --- a/app/static/styles.css +++ b/app/static/styles.css @@ -408,6 +408,19 @@ pre { 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 { border-color: #f3a8b7; }