Implementována portálová část SQLite job queue.

Přidán DB helper pro jobs a job_logs bez ORM.
Přidána idempotentní migrace pro job tabulky včetně created_by_display_name.
Přidány stránky /portal/jobs a /portal/jobs/{id}.
Jobs page zobrazuje status, type, target, source, created_by, created_at, started_at, finished_at.
Job detail zobrazuje payload, result/error a job_logs.
Queued/running joby mají auto-refresh po 5 s.
Navigace portálu obsahuje odkaz na Joby.
App redeploy už nespouští deploy script přímo; vytváří queued job deploy_app s prázdným payloadem.
Po redeploy se uživatel přesměruje na detail jobu.
Přidán audit event app.redeploy.queued s metadata={"job_id": job_id}.
Přidána ochrana proti duplicitnímu aktivnímu deploy jobu pro stejnou appku.
This commit is contained in:
JiriUhlir
2026-05-28 14:02:49 +02:00
parent 17a4d4db1b
commit 88601451dc
6 changed files with 474 additions and 17 deletions
+185
View File
@@ -0,0 +1,185 @@
import html
import json
from urllib.parse import quote
from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import HTMLResponse
from app.auth import require_user
from app.db.jobs import get_job, get_job_logs, get_jobs
from app.routes.deployments import calculate_duration, render_status_pill
from app.templates.layout import page
router = APIRouter()
LIVE_STATUSES = {"queued", "running"}
def render_job_status(status: str | None) -> str:
return render_status_pill(status)
def pretty_json(value: str | None) -> str:
if not value:
return "{}"
try:
return json.dumps(json.loads(value), ensure_ascii=False, indent=2, sort_keys=True)
except (TypeError, ValueError):
return value
@router.get("/jobs", response_class=HTMLResponse)
def jobs_page(request: Request, user=Depends(require_user)):
jobs = get_jobs()
refresh = "<script>setTimeout(() => window.location.reload(), 5000);</script>" if any(
(job.get("status") or "").lower() in LIVE_STATUSES for job in jobs
) else ""
rows = ""
for job in jobs:
job_id = html.escape(str(job.get("id", "")))
status = render_job_status(job.get("status"))
source = html.escape(job.get("source", "") or "")
target_type = html.escape(job.get("target_type", "") or "")
target_id = html.escape(job.get("target_id", "") or "")
job_type = html.escape(job.get("type", "") or "")
created_by = html.escape(job.get("created_by_display_name") or job.get("created_by_username") or "")
created_at = html.escape(job.get("created_at", "") or "")
started_at = html.escape(job.get("started_at", "") or "")
finished_at = html.escape(job.get("finished_at", "") or "")
row_class = ' class="running-row"' if (job.get("status") or "").lower() == "running" else ""
rows += f"""
<tr{row_class}>
<td>
<strong><a href="/portal/jobs/{job_id}">#{job_id}</a></strong><br>
<span class="muted">{job_type}</span>
</td>
<td>{status}</td>
<td>{source}</td>
<td>{target_type}: <strong>{target_id}</strong></td>
<td>{created_by}</td>
<td>{created_at}</td>
<td>{started_at}</td>
<td>{finished_at}</td>
<td class="actions-cell"><a class="btn" href="/portal/jobs/{job_id}">Detail</a></td>
</tr>
"""
if not rows:
rows = '<tr><td colspan="9">Zatím nejsou evidované žádné joby.</td></tr>'
return page(
"Joby",
f"""
{refresh}
<div class="card">
<h2>Joby</h2>
<p class="muted">Fronta portálových a webhook úloh připravená pro centrální worker.</p>
</div>
<div class="card">
<h2>Fronta</h2>
<table>
<tr>
<th>ID</th>
<th>Status</th>
<th>Source</th>
<th>Target</th>
<th>Created by</th>
<th>Created at</th>
<th>Started at</th>
<th>Finished at</th>
<th>Akce</th>
</tr>
{rows}
</table>
</div>
""",
user=user,
)
@router.get("/jobs/{job_id}", response_class=HTMLResponse)
def job_detail_page(job_id: int, request: Request, user=Depends(require_user)):
job = get_job(job_id)
if not job:
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 ""
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 ""
target_id = job.get("target_id", "") or ""
target_id_html = html.escape(target_id)
target_url = f"/portal/apps/{quote(target_id, safe='')}" if target_type == "app" else "/portal/jobs"
duration = html.escape(calculate_duration(job.get("started_at"), job.get("finished_at")))
payload = html.escape(pretty_json(job.get("payload_json")))
result = html.escape(pretty_json(job.get("result_json")))
error_text = html.escape(job.get("error_text", "") or "")
log_blocks = ""
for log in get_job_logs(job_id):
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"
log_blocks += f"""
<div class="job-log-entry">
<div class="muted">{created_at} · {stream}</div>
<pre class="log-viewer {class_name}">{message}</pre>
</div>
"""
if not log_blocks:
log_blocks = '<p class="muted">Zatím nejsou uložené žádné logy.</p>'
return page(
title,
f"""
{refresh}
<div class="card">
<h2>{title}</h2>
<p>
<a class="btn" href="/portal/jobs">&larr; Zpět na joby</a>
<a class="btn btn-secondary" href="{target_url}">Detail targetu</a>
</p>
</div>
<div class="card">
<h2>Souhrn</h2>
<table>
<tr><th>Typ</th><td>{html.escape(job.get("type", "") or "")}</td></tr>
<tr><th>Status</th><td>{status}</td></tr>
<tr><th>Source</th><td>{html.escape(job.get("source", "") or "")}</td></tr>
<tr><th>Target</th><td>{html.escape(target_type)}: {target_id_html}</td></tr>
<tr><th>Created by</th><td>{html.escape(job.get("created_by_display_name") or job.get("created_by_username") or "")}</td></tr>
<tr><th>Created at</th><td>{html.escape(job.get("created_at", "") or "")}</td></tr>
<tr><th>Started at</th><td>{html.escape(job.get("started_at", "") or "")}</td></tr>
<tr><th>Finished at</th><td>{html.escape(job.get("finished_at", "") or "")}</td></tr>
<tr><th>Duration</th><td>{duration}</td></tr>
<tr><th>Worker</th><td>{html.escape(job.get("worker_id", "") or "")}</td></tr>
</table>
</div>
<div class="grid">
<div class="card">
<h2>Payload</h2>
<pre class="log-viewer log-stdout">{payload}</pre>
</div>
<div class="card">
<h2>Result / error</h2>
<pre class="log-viewer log-stdout">{result}</pre>
<pre class="log-viewer log-stderr">{error_text}</pre>
</div>
</div>
<div class="card">
<h2>Logy</h2>
{log_blocks}
</div>
""",
user=user,
)