diff --git a/messages/en.json b/messages/en.json index 266d542..e6ad1e6 100644 --- a/messages/en.json +++ b/messages/en.json @@ -415,7 +415,8 @@ "colCreated": "Created", "colActions": "Actions", "actionRetry": "Retry", - "actionCancel": "Cancel" + "actionCancel": "Cancel", + "actionStop": "Stop" }, "componentsAuthAuthPageContent": { "genericError": "Something went wrong. Please try again.", diff --git a/messages/fa.json b/messages/fa.json index 5756a98..f3231f2 100644 --- a/messages/fa.json +++ b/messages/fa.json @@ -415,7 +415,8 @@ "colCreated": "زمان ایجاد", "colActions": "عملیات", "actionRetry": "تلاش مجدد", - "actionCancel": "لغو" + "actionCancel": "لغو", + "actionStop": "توقف" }, "componentsAuthAuthPageContent": { "genericError": "خطایی رخ داد. لطفاً دوباره تلاش کنید.", diff --git a/services/render/cmd/server/main.go b/services/render/cmd/server/main.go index 75af658..f26d1cc 100644 --- a/services/render/cmd/server/main.go +++ b/services/render/cmd/server/main.go @@ -90,6 +90,7 @@ func main() { renders.POST("", renderH.Create) renders.GET("/:job_id", renderH.Get) renders.POST("/:job_id/cancel", renderH.Cancel) + renders.POST("/:job_id/stop", admin, renderH.Stop) renders.POST("/:job_id/retry", renderH.Retry) renders.GET("/:job_id/progress", renderH.Progress) renders.GET("/:job_id/logs", renderH.Logs) diff --git a/services/render/internal/db/db.go b/services/render/internal/db/db.go index f3f17ff..a1ca355 100644 --- a/services/render/internal/db/db.go +++ b/services/render/internal/db/db.go @@ -597,6 +597,24 @@ func (s *Store) CancelJob(ctx context.Context, id, userID uuid.UUID) (bool, erro return tag.RowsAffected() > 0, err } +// StopJob cancels any in-progress job regardless of owner (admin action). Also +// frees the assigned node so it can pick up new work. +func (s *Store) StopJob(ctx context.Context, id uuid.UUID) (bool, error) { + tag, err := s.pool.Exec(ctx, ` + UPDATE render.render_jobs + SET step = 'Cancelled'::render_step, completed_at = NOW(), updated_at = NOW() + WHERE id = $1 AND step NOT IN ('Done','Failed','Cancelled')`, + id) + if err != nil { + return false, err + } + // Release any node that was actively working this job. + _, _ = s.pool.Exec(ctx, + `UPDATE render.render_nodes SET status = 'Ready'::node_status, current_frame_job_id = NULL, updated_at = NOW() + WHERE current_frame_job_id IN (SELECT id FROM render.frame_jobs WHERE render_job_id = $1)`, id) + return tag.RowsAffected() > 0, err +} + func (s *Store) GetJobProgress(ctx context.Context, id, userID uuid.UUID) (*models.RenderJob, error) { return s.GetJobByID(ctx, id, userID) } diff --git a/services/render/internal/handlers/renders.go b/services/render/internal/handlers/renders.go index 5e5d934..bfe0cb8 100644 --- a/services/render/internal/handlers/renders.go +++ b/services/render/internal/handlers/renders.go @@ -131,6 +131,21 @@ func (h *RenderHandler) Cancel(c *gin.Context) { }) } +// POST /v1/renders/:job_id/stop — admin: stop any user's in-progress job +func (h *RenderHandler) Stop(c *gin.Context) { + jobID, err := uuid.Parse(c.Param("job_id")) + if err != nil { + c.JSON(http.StatusBadRequest, models.APIError{Code: "bad_request", Message: "invalid job_id"}) + return + } + stopped, err := h.store.StopJob(c.Request.Context(), jobID) + if err != nil { + c.JSON(http.StatusInternalServerError, models.APIError{Code: "internal_error", Message: err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"stopped": stopped}) +} + // POST /v1/renders/:job_id/retry func (h *RenderHandler) Retry(c *gin.Context) { userID := middleware.GetUserID(c) diff --git a/src/app/api/admin/renders/[jobId]/stop/route.ts b/src/app/api/admin/renders/[jobId]/stop/route.ts new file mode 100644 index 0000000..6db049a --- /dev/null +++ b/src/app/api/admin/renders/[jobId]/stop/route.ts @@ -0,0 +1,9 @@ +import { type NextRequest } from "next/server"; +import { adminProxy } from "@/app/api/admin/_adminProxy"; + +export const runtime = "nodejs"; +interface Ctx { params: { jobId: string } } + +export async function POST(req: NextRequest, { params }: Ctx) { + return adminProxy(req, `/v1/renders/${params.jobId}/stop`); +} diff --git a/src/components/admin/RenderQueueTable.tsx b/src/components/admin/RenderQueueTable.tsx index fb7d1bc..8db6d9b 100644 --- a/src/components/admin/RenderQueueTable.tsx +++ b/src/components/admin/RenderQueueTable.tsx @@ -48,10 +48,12 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) { } }; - const cancelJob = async (jobId: string) => { + const stopJob = async (jobId: string) => { + if (!confirm("این رندر متوقف شود؟")) return; setLoading((p) => ({ ...p, [jobId]: true })); try { - await apiFetch(`/api/admin/renders/${jobId}/cancel`, { method: "POST" }); + // Admin stop — cancels any user's job (not just the caller's) and frees the node. + await apiFetch(`/api/admin/renders/${jobId}/stop`, { method: "POST" }); router.refresh(); } finally { setLoading((p) => ({ ...p, [jobId]: false })); @@ -140,11 +142,11 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) { )} {canCancel && ( )}