diff --git a/app/db/jobs.py b/app/db/jobs.py index 3d18c18..62c8010 100644 --- a/app/db/jobs.py +++ b/app/db/jobs.py @@ -56,6 +56,121 @@ def create_job( return job_id +def retry_failed_job(job_id: int, user: dict[str, Any]): + run_migrations() + created_by_user_id = user.get("id") if user else None + created_by_username = user.get("username") if user else None + created_by_display_name = user.get("display_name") if user else None + + con = get_connection() + source_job = con.execute( + """ + SELECT id + FROM jobs + WHERE id = ? + AND status = 'failed' + """, + (job_id,), + ).fetchone() + if not source_job: + con.close() + return None + + cur = con.execute( + """ + INSERT INTO jobs ( + type, + target_type, + target_id, + payload_json, + status, + created_by_user_id, + created_by_username, + created_by_display_name, + source + ) + SELECT + type, + target_type, + target_id, + payload_json, + 'queued', + ?, + ?, + ?, + 'portal_retry' + FROM jobs + WHERE id = ? + AND status = 'failed' + """, + (created_by_user_id, created_by_username, created_by_display_name, job_id), + ) + if cur.rowcount != 1: + con.close() + return None + + new_job_id = cur.lastrowid + con.commit() + con.close() + + append_job_log(new_job_id, "system", f"Job queued as retry of #{job_id}.") + return new_job_id + + +def cancel_job(job_id: int): + run_migrations() + con = get_connection() + + row = con.execute( + """ + SELECT status + FROM jobs + WHERE id = ? + """, + (job_id,), + ).fetchone() + if not row: + con.close() + return None + + status = (row["status"] or "").lower() + if status == "queued": + new_status = "cancelled" + cur = con.execute( + """ + UPDATE jobs + SET status = 'cancelled', + finished_at = CURRENT_TIMESTAMP + WHERE id = ? + AND status = 'queued' + """, + (job_id,), + ) + elif status == "running": + new_status = "cancelled_requested" + cur = con.execute( + """ + UPDATE jobs + SET status = 'cancelled_requested' + WHERE id = ? + AND status = 'running' + """, + (job_id,), + ) + else: + con.close() + return None + + if cur.rowcount != 1: + con.close() + return None + + con.commit() + con.close() + append_job_log(job_id, "system", f"Job status changed from {status} to {new_status}.") + return {"previous_status": status, "status": new_status} + + def update_job_status( job_id: int, status: str, diff --git a/app/routes/jobs.py b/app/routes/jobs.py index 154250a..6de048e 100644 --- a/app/routes/jobs.py +++ b/app/routes/jobs.py @@ -3,20 +3,36 @@ import json from urllib.parse import quote from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, RedirectResponse from app.auth import require_user -from app.db.jobs import get_job, get_job_logs, get_jobs +from app.db.audit import log_audit_event +from app.db.jobs import cancel_job, get_job, get_job_logs, 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"} +LIVE_STATUSES = {"queued", "running", "cancelled_requested"} +JOB_STATUSES = {"queued", "running", "cancelled_requested", "cancelled", "success", "failed"} def render_job_status(status: str | None) -> str: - return render_status_pill(status) + 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: @@ -119,6 +135,25 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)): 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): @@ -146,6 +181,7 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)): ← Zpět na joby Detail targetu

+ {actions}
@@ -183,3 +219,51 @@ def job_detail_page(job_id: int, request: Request, user=Depends(require_user)): """, 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)