feat(render+admin): stop a render job (admin, any owner)
Build backend images / build content-svc (push) Failing after 1m0s
Build backend images / build file-svc (push) Failing after 1m3s
Build backend images / build gateway (push) Failing after 1m2s
Build backend images / build identity-svc (push) Failing after 1m20s
Build backend images / build notification-svc (push) Failing after 1m13s
Build backend images / build render-svc (push) Failing after 1m5s
Build backend images / build studio-svc (push) Failing after 1m0s
Build backend images / build content-svc (push) Failing after 1m0s
Build backend images / build file-svc (push) Failing after 1m3s
Build backend images / build gateway (push) Failing after 1m2s
Build backend images / build identity-svc (push) Failing after 1m20s
Build backend images / build notification-svc (push) Failing after 1m13s
Build backend images / build render-svc (push) Failing after 1m5s
Build backend images / build studio-svc (push) Failing after 1m0s
The render-queue cancel button used the owner-scoped /cancel (WHERE user_id=…),
so an admin couldn't stop another user's job. Added:
- render-svc: POST /v1/renders/:job_id/stop (admin-gated) → store.StopJob cancels
any in-progress job regardless of owner and frees the assigned node
- admin: render-queue button now "توقف" → /api/admin/renders/{id}/stop (with confirm)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+2
-1
@@ -415,7 +415,8 @@
|
|||||||
"colCreated": "Created",
|
"colCreated": "Created",
|
||||||
"colActions": "Actions",
|
"colActions": "Actions",
|
||||||
"actionRetry": "Retry",
|
"actionRetry": "Retry",
|
||||||
"actionCancel": "Cancel"
|
"actionCancel": "Cancel",
|
||||||
|
"actionStop": "Stop"
|
||||||
},
|
},
|
||||||
"componentsAuthAuthPageContent": {
|
"componentsAuthAuthPageContent": {
|
||||||
"genericError": "Something went wrong. Please try again.",
|
"genericError": "Something went wrong. Please try again.",
|
||||||
|
|||||||
+2
-1
@@ -415,7 +415,8 @@
|
|||||||
"colCreated": "زمان ایجاد",
|
"colCreated": "زمان ایجاد",
|
||||||
"colActions": "عملیات",
|
"colActions": "عملیات",
|
||||||
"actionRetry": "تلاش مجدد",
|
"actionRetry": "تلاش مجدد",
|
||||||
"actionCancel": "لغو"
|
"actionCancel": "لغو",
|
||||||
|
"actionStop": "توقف"
|
||||||
},
|
},
|
||||||
"componentsAuthAuthPageContent": {
|
"componentsAuthAuthPageContent": {
|
||||||
"genericError": "خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
"genericError": "خطایی رخ داد. لطفاً دوباره تلاش کنید.",
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ func main() {
|
|||||||
renders.POST("", renderH.Create)
|
renders.POST("", renderH.Create)
|
||||||
renders.GET("/:job_id", renderH.Get)
|
renders.GET("/:job_id", renderH.Get)
|
||||||
renders.POST("/:job_id/cancel", renderH.Cancel)
|
renders.POST("/:job_id/cancel", renderH.Cancel)
|
||||||
|
renders.POST("/:job_id/stop", admin, renderH.Stop)
|
||||||
renders.POST("/:job_id/retry", renderH.Retry)
|
renders.POST("/:job_id/retry", renderH.Retry)
|
||||||
renders.GET("/:job_id/progress", renderH.Progress)
|
renders.GET("/:job_id/progress", renderH.Progress)
|
||||||
renders.GET("/:job_id/logs", renderH.Logs)
|
renders.GET("/:job_id/logs", renderH.Logs)
|
||||||
|
|||||||
@@ -597,6 +597,24 @@ func (s *Store) CancelJob(ctx context.Context, id, userID uuid.UUID) (bool, erro
|
|||||||
return tag.RowsAffected() > 0, err
|
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) {
|
func (s *Store) GetJobProgress(ctx context.Context, id, userID uuid.UUID) (*models.RenderJob, error) {
|
||||||
return s.GetJobByID(ctx, id, userID)
|
return s.GetJobByID(ctx, id, userID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
// POST /v1/renders/:job_id/retry
|
||||||
func (h *RenderHandler) Retry(c *gin.Context) {
|
func (h *RenderHandler) Retry(c *gin.Context) {
|
||||||
userID := middleware.GetUserID(c)
|
userID := middleware.GetUserID(c)
|
||||||
|
|||||||
@@ -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`);
|
||||||
|
}
|
||||||
@@ -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 }));
|
setLoading((p) => ({ ...p, [jobId]: true }));
|
||||||
try {
|
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();
|
router.refresh();
|
||||||
} finally {
|
} finally {
|
||||||
setLoading((p) => ({ ...p, [jobId]: false }));
|
setLoading((p) => ({ ...p, [jobId]: false }));
|
||||||
@@ -140,11 +142,11 @@ export function RenderQueueTable({ jobs }: { jobs: V2RenderJob[] }) {
|
|||||||
)}
|
)}
|
||||||
{canCancel && (
|
{canCancel && (
|
||||||
<button
|
<button
|
||||||
onClick={() => cancelJob(job.id)}
|
onClick={() => stopJob(job.id)}
|
||||||
disabled={loading[job.id]}
|
disabled={loading[job.id]}
|
||||||
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
className="rounded px-2.5 py-1 text-xs text-red-300 border border-red-500/30 hover:bg-red-500/10 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||||||
>
|
>
|
||||||
{t("actionCancel")}
|
{t("actionStop")}
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user