import html import json from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.auth import 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.routes.deployments import calculate_duration, render_status_pill from app.templates.layout import page router = APIRouter() LIVE_STATUSES = {"queued", "running", "cancelled_requested"} 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") def render_job_status(status: str | None) -> str: status_value = status or "" normalized = status_value.lower() if normalized not in JOB_STATUSES: return render_status_pill(status) if normalized == "success": class_name = "pill pill-success" elif normalized in {"queued", "running", "cancelled_requested"}: class_name = "pill pill-warning" elif normalized in {"failed", "cancelled"}: class_name = "pill pill-danger" else: class_name = "pill pill-muted" return f'{html.escape(status_value)}' 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 def render_options(values: tuple[str, ...], selected: str, empty_label: str) -> str: options = [f''] for value in values: selected_attr = " selected" if selected == value else "" escaped_value = html.escape(value) options.append(f'') return "".join(options) @router.get("/jobs", response_class=HTMLResponse) def jobs_page( request: Request, status: str = Query(""), job_type: str = Query("", alias="type"), target: str = Query(""), user=Depends(require_user), ): selected_status = status.strip().lower() selected_type = job_type.strip() selected_target = target.strip() if selected_status not in JOB_FILTER_STATUSES: selected_status = "" if selected_type not in JOB_FILTER_TYPES: selected_type = "" jobs = get_jobs( status=selected_status or None, job_type=selected_type or None, target=selected_target or None, ) stats = get_job_stats() refresh = "" 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""" #{job_id}
{job_type} {status} {source} {target_type}: {target_id} {created_by} {created_at} {started_at} {finished_at} Detail """ if not rows: rows = 'Zatím nejsou evidované žádné joby.' status_options = render_options(JOB_FILTER_STATUSES, selected_status, "Všechny statusy") type_options = render_options(JOB_FILTER_TYPES, selected_type, "Všechny typy") target_value = html.escape(selected_target) return page( "Joby", f""" {refresh}

Joby

Fronta portálových a webhook úloh připravená pro centrální worker.

Queued{stats.get("queued") or 0}
Running{stats.get("running") or 0}
Failed{stats.get("failed") or 0}
Success{stats.get("success") or 0}

Filtry

Reset

Fronta

{rows}
ID Status Source Target Created by Created at Started at Finished at Akce
""", 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 = "" 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 "") actions = "" if status_value == "failed": actions += f"""
""" if status_value in {"queued", "running"}: actions += f"""
""" if actions: actions = f"""
{actions}
""" 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"""
{created_at} · {stream}
{message}
""" if not log_blocks: log_blocks = '

Zatím nejsou uložené žádné logy.

' return page( title, f""" {refresh}

{title}

← Zpět na joby Detail targetu

{actions}

Souhrn

Typ{html.escape(job.get("type", "") or "")}
Status{status}
Source{html.escape(job.get("source", "") or "")}
Target{html.escape(target_type)}: {target_id_html}
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 "")}
Duration{duration}
Worker{html.escape(job.get("worker_id", "") or "")}

Payload

{payload}

Result / error

{result}
{error_text}

Logy

{log_blocks}
""", user=user, ) @router.post("/jobs/{job_id}/retry") def retry_job_action(job_id: int, user=Depends(require_user)): job = get_job(job_id) if not job: raise HTTPException(status_code=404, detail="Job not found") if (job.get("status") or "").lower() != "failed": raise HTTPException(status_code=400, detail="Only failed jobs can be retried") new_job_id = retry_failed_job(job_id, user) if not new_job_id: raise HTTPException(status_code=409, detail="Job can no longer be retried") log_audit_event( user, action="job.retry", target_type="job", target_id=job_id, metadata={"new_job_id": new_job_id}, ) return RedirectResponse(url=f"/portal/jobs/{new_job_id}", status_code=303) @router.post("/jobs/{job_id}/cancel") def cancel_job_action(job_id: int, 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() if status_value not in {"queued", "running"}: raise HTTPException(status_code=400, detail="Only queued or running jobs can be cancelled") result = cancel_job(job_id) if not result: raise HTTPException(status_code=409, detail="Job can no longer be cancelled") log_audit_event( user, action="job.cancel", target_type="job", target_id=job_id, metadata=result, ) return RedirectResponse(url=f"/portal/jobs/{job_id}", status_code=303)