life jobs logs
This commit is contained in:
@@ -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
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user