diff --git a/packages/dashboard/src/components/run-cancel-action.tsx b/packages/dashboard/src/components/run-cancel-action.tsx new file mode 100644 index 00000000..77b94021 --- /dev/null +++ b/packages/dashboard/src/components/run-cancel-action.tsx @@ -0,0 +1,111 @@ +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Button } from "@/components/ui/button"; +import { cancelWorkflowRunServerFn } from "@/lib/api"; +import { isRunCancelableStatus } from "@/lib/status"; +import type { WorkflowRunStatus } from "openworkflow/internal"; +import { useState } from "react"; + +interface RunCancelActionProps { + runId: string; + status: WorkflowRunStatus; + onCanceled?: (() => Promise) | (() => void); +} + +function getErrorMessage(error: unknown): string { + if (error instanceof Error && error.message) { + return error.message; + } + + return "Unable to cancel workflow run"; +} + +export function RunCancelAction({ + runId, + status, + onCanceled, +}: RunCancelActionProps) { + const [isOpen, setIsOpen] = useState(false); + const [isCanceling, setIsCanceling] = useState(false); + const [error, setError] = useState(null); + + if (!isRunCancelableStatus(status)) { + return null; + } + + async function cancelRun() { + setIsCanceling(true); + setError(null); + + try { + await cancelWorkflowRunServerFn({ + data: { + workflowRunId: runId, + }, + }); + await onCanceled?.(); + setIsOpen(false); + } catch (caughtError) { + setError(getErrorMessage(caughtError)); + } finally { + setIsCanceling(false); + } + } + + return ( + { + setIsOpen(nextOpen); + if (!nextOpen) { + setError(null); + } + }} + > + + + + + Cancel this run? + + This will stop any future progress for this workflow run. + + + + {error &&

{error}

} + + + + Keep Running + + { + void cancelRun(); + }} + disabled={isCanceling} + > + {isCanceling ? "Canceling..." : "Cancel Run"} + + +
+
+ ); +} diff --git a/packages/dashboard/src/lib/api.ts b/packages/dashboard/src/lib/api.ts index 88799394..b26d88d1 100644 --- a/packages/dashboard/src/lib/api.ts +++ b/packages/dashboard/src/lib/api.ts @@ -69,6 +69,16 @@ export const getWorkflowRunServerFn = createServerFn({ method: "GET" }) return run; }); +/** + * Cancel a workflow run by ID. + */ +export const cancelWorkflowRunServerFn = createServerFn({ method: "POST" }) + .inputValidator(z.object({ workflowRunId: z.string() })) + .handler(async ({ data }): Promise => { + const backend = await getBackend(); + return backend.cancelWorkflowRun({ workflowRunId: data.workflowRunId }); + }); + /** * List step attempts for a workflow run. */ diff --git a/packages/dashboard/src/lib/status.ts b/packages/dashboard/src/lib/status.ts index 03a7a21d..b9cefedd 100644 --- a/packages/dashboard/src/lib/status.ts +++ b/packages/dashboard/src/lib/status.ts @@ -95,6 +95,13 @@ export const TERMINAL_RUN_STATUSES: ReadonlySet = new Set([ "canceled", ]); +/** Run statuses that can be canceled from the dashboard. */ +const CANCELABLE_RUN_STATUSES: ReadonlySet = new Set([ + "pending", + "running", + "sleeping", +]); + const fallbackStatusColor = "text-warning"; const fallbackStatusBadgeClass = "bg-warning/10 border-warning/20 text-warning"; @@ -113,3 +120,7 @@ export function getStatusColor(status: string): string { export function getStatusBadgeClass(status: string): string { return getStatusConfig(status)?.badgeClass ?? fallbackStatusBadgeClass; } + +export function isRunCancelableStatus(status: string): boolean { + return CANCELABLE_RUN_STATUSES.has(status as WorkflowRunStatus); +} diff --git a/packages/dashboard/src/routes/runs/$runId.tsx b/packages/dashboard/src/routes/runs/$runId.tsx index 5cbd551f..cb2e5325 100644 --- a/packages/dashboard/src/routes/runs/$runId.tsx +++ b/packages/dashboard/src/routes/runs/$runId.tsx @@ -1,4 +1,5 @@ import { AppLayout } from "@/components/app-layout"; +import { RunCancelAction } from "@/components/run-cancel-action"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; @@ -17,7 +18,7 @@ import { CaretDownIcon, ListDashesIcon, } from "@phosphor-icons/react"; -import { createFileRoute, Link } from "@tanstack/react-router"; +import { createFileRoute, Link, useRouter } from "@tanstack/react-router"; import type { StepAttempt } from "openworkflow/internal"; import { useState } from "react"; @@ -34,6 +35,7 @@ export const Route = createFileRoute("/runs/$runId")({ function RunDetailsPage() { const { run, steps } = Route.useLoaderData(); + const router = useRouter(); const [expandedSteps, setExpandedSteps] = useState>(new Set()); usePolling({ enabled: !!run && !TERMINAL_RUN_STATUSES.has(run.status), @@ -76,7 +78,7 @@ function RunDetailsPage() { return (
-
+
+ { + await router.invalidate(); + }} + />