import asyncio import html import json import math from urllib.parse import quote, urlencode from fastapi import APIRouter, Depends, HTTPException, Query, Request, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse, RedirectResponse from app.auth import current_user, require_user from app.db.audit import log_audit_event from app.db.jobs import cancel_job, count_jobs, 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") IGNORED_RETRY_REPOSITORIES = {"appfactory-tools", "appfactory-infrastructure"} DEFAULT_PAGE_SIZE = 20 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" labels = { "queued": "čeká", "running": "běží", "cancelled_requested": "žádost o zrušení", "cancelled": "zrušeno", "success": "úspěšné", "failed": "selhalo", } return f'{html.escape(labels.get(normalized, 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) def can_retry_job(job: dict) -> bool: job_type = job.get("type") or "" target_id = job.get("target_id") or "" if job_type == "deploy_core_service": return True if job_type == "deploy_app" and target_id not in IGNORED_RETRY_REPOSITORIES: return True 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, status: str = Query(""), job_type: str = Query("", alias="type"), target: str = Query(""), page_number: int = Query(1, alias="page", ge=1), 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 = "" total_jobs = count_jobs( status=selected_status or None, job_type=selected_type or None, target=selected_target or None, ) total_pages = max(1, math.ceil(total_jobs / DEFAULT_PAGE_SIZE)) if page_number > total_pages: page_number = total_pages offset = (page_number - 1) * DEFAULT_PAGE_SIZE jobs = get_jobs( limit=DEFAULT_PAGE_SIZE, offset=offset, status=selected_status or None, job_type=selected_type or None, target=selected_target or None, ) stats = get_job_stats() refresh = "" 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é úlohy.' 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) first_item = offset + 1 if total_jobs else 0 last_item = min(offset + len(jobs), total_jobs) def page_url(page: int) -> str: params = {"page": page} if selected_status: params["status"] = selected_status if selected_type: params["type"] = selected_type if selected_target: params["target"] = selected_target return f"/portal/jobs?{urlencode(params)}" pagination = f""" """ return page( "Úlohy", f""" {refresh}

Úlohy

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

Čekající{stats.get("queued") or 0}
Běžící{stats.get("running") or 0}
Selhané (24 h){stats.get("failed_24h") or 0}
Úspěšné (24 h){stats.get("success_24h") or 0}

Filtry

Reset

Poslední úlohy

ID Status Typ Cíl Zdroj Vytvořeno

Fronta

{pagination} {rows}
ID Status Zdroj Cíl Vytvořil Vytvořeno Started at Finished at Akce
{pagination}
""", 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 = "" title = f"Úloha #{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 = "" retry_blocked_notice = "" if status_value == "failed" and can_retry_job(job): actions += f"""
""" elif status_value == "failed" and (job.get("target_id") or "") in IGNORED_RETRY_REPOSITORIES: retry_blocked_notice = '

Tento repozitář není deployovatelná služba.

' 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 "") 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}
{message}
""" if not log_blocks: log_blocks = '

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

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

{title}

← Zpět na úlohy Detail cíle

{retry_blocked_notice} {actions}

Souhrn

Typ{html.escape(job.get("type", "") or "")}
Status{status}
Zdroj{html.escape(job.get("source", "") or "")}
Cíl{html.escape(target_type)}: {target_id_html}
Vytvořil{html.escape(job.get("created_by_display_name") or job.get("created_by_username") or "")}
Vytvořeno{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}

Živé logy

WebSocket: Připojování

Logy z DB

{log_blocks}
{live_logs_script} """, 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") if not can_retry_job(job): raise HTTPException(status_code=400, detail="This job cannot 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)