Files
Dorod-Sky/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx
2025-11-04 22:54:39 +08:00

341 lines
11 KiB
TypeScript

import { getClient } from "@/api/AxiosClient";
import { useState } from "react";
import {
RunEngine,
Status,
TaskApiResponse,
WorkflowRunStatusApiResponse,
} from "@/api/types";
import { StatusBadge } from "@/components/StatusBadge";
import { SwitchBarNavigation } from "@/components/SwitchBarNavigation";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogClose,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { Skeleton } from "@/components/ui/skeleton";
import { toast } from "@/components/ui/use-toast";
import { useApiCredential } from "@/hooks/useApiCredential";
import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { CodeEditor } from "@/routes/workflows/components/CodeEditor";
import { WorkflowApiResponse } from "@/routes/workflows/types/workflowTypes";
import { runsApiBaseUrl } from "@/util/env";
import { ApiWebhookActionsMenu } from "@/components/ApiWebhookActionsMenu";
import { WebhookReplayDialog } from "@/components/WebhookReplayDialog";
import { type ApiCommandOptions } from "@/util/apiCommands";
import { buildTaskRunPayload } from "@/util/taskRunPayload";
import { PlayIcon, ReloadIcon } from "@radix-ui/react-icons";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Link, Outlet, useParams } from "react-router-dom";
import { statusIsFinalized } from "../types";
import { MAX_STEPS_DEFAULT } from "../constants";
import { useTaskQuery } from "./hooks/useTaskQuery";
function createTaskRequestObject(values: TaskApiResponse) {
return {
url: values.request.url,
webhook_callback_url: values.request.webhook_callback_url,
navigation_goal: values.request.navigation_goal,
data_extraction_goal: values.request.data_extraction_goal,
proxy_location: values.request.proxy_location,
error_code_mapping: values.request.error_code_mapping,
navigation_payload: values.request.navigation_payload,
extracted_information_schema: values.request.extracted_information_schema,
};
}
function TaskDetails() {
const { taskId } = useParams();
const credentialGetter = useCredentialGetter();
const queryClient = useQueryClient();
const apiCredential = useApiCredential();
const {
data: task,
isLoading: taskIsLoading,
isError: taskIsError,
error: taskError,
} = useTaskQuery({ id: taskId });
const { data: workflowRun, isLoading: workflowRunIsLoading } =
useQuery<WorkflowRunStatusApiResponse>({
queryKey: ["workflowRun", task?.workflow_run_id],
queryFn: async () => {
const client = await getClient(credentialGetter);
return client
.get(`/workflows/runs/${task?.workflow_run_id}`)
.then((response) => response.data);
},
enabled: !!task?.workflow_run_id,
});
const { data: workflow, isLoading: workflowIsLoading } =
useQuery<WorkflowApiResponse>({
queryKey: ["workflow", workflowRun?.workflow_id],
queryFn: async () => {
const client = await getClient(credentialGetter);
return client
.get(`/workflows/${workflowRun?.workflow_id}`)
.then((response) => response.data);
},
enabled: !!workflowRun?.workflow_id,
});
const cancelTaskMutation = useMutation({
mutationFn: async () => {
const client = await getClient(credentialGetter);
return client
.post(`/tasks/${taskId}/cancel`)
.then((response) => response.data);
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: ["task", taskId],
});
queryClient.invalidateQueries({
queryKey: ["tasks"],
});
if (task?.workflow_run_id) {
queryClient.invalidateQueries({
queryKey: ["workflowRun", task.workflow_run_id],
});
queryClient.invalidateQueries({
queryKey: [
"workflowRun",
workflow?.workflow_permanent_id,
task.workflow_run_id,
],
});
}
toast({
variant: "success",
title: "Task Canceled",
description: "The task has been successfully canceled.",
});
},
onError: (error) => {
toast({
variant: "destructive",
title: "Error",
description: error.message,
});
},
});
const [replayOpen, setReplayOpen] = useState(false);
if (taskIsError) {
return <div>Error: {taskError?.message}</div>;
}
const showExtractedInformation =
task?.status === Status.Completed && task.extracted_information !== null;
const extractedInformation = showExtractedInformation ? (
<div className="space-y-1">
<Label className="text-lg">Extracted Information</Label>
<CodeEditor
language="json"
value={JSON.stringify(task.extracted_information, null, 2)}
readOnly
minHeight={"96px"}
maxHeight={"500px"}
className="w-full"
/>
</div>
) : null;
const taskIsRunningOrQueued =
task?.status === Status.Running || task?.status === Status.Queued;
const taskHasTerminalState = task && statusIsFinalized(task);
const showFailureReason =
task?.status === Status.Failed ||
task?.status === Status.Terminated ||
task?.status === Status.TimedOut;
const failureReason = showFailureReason ? (
<div className="space-y-1">
<Label className="text-lg">Failure Reason</Label>
<CodeEditor
language="json"
value={JSON.stringify(task.failure_reason, null, 2)}
readOnly
minHeight={"96px"}
maxHeight={"500px"}
className="w-full"
/>
</div>
) : null;
const webhookFailureReason = task?.webhook_failure_reason ? (
<div className="space-y-1">
<Label>Webhook Failure Reason</Label>
<div className="rounded-md border border-yellow-600 p-4 text-sm">
{task.webhook_failure_reason}
</div>
</div>
) : null;
return (
<div className="flex flex-col gap-8">
<header className="space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-5">
<span className="text-3xl">{taskId}</span>
{taskIsLoading ? (
<Skeleton className="h-8 w-32" />
) : (
task && <StatusBadge status={task.status} />
)}
</div>
<div className="flex items-center gap-2">
{/** API & Webhooks consolidated dropdown + controlled dialog */}
<ApiWebhookActionsMenu
getOptions={() => {
if (!task) {
return {
method: "GET",
url: "",
headers: {
"Content-Type": "application/json",
"x-api-key": "",
},
} satisfies ApiCommandOptions;
}
const includeOverrideHeader =
task.max_steps_per_run !== null &&
task.max_steps_per_run !== MAX_STEPS_DEFAULT;
const headers: Record<string, string> = {
"Content-Type": "application/json",
"x-api-key": apiCredential ?? "<your-api-key>",
};
if (includeOverrideHeader) {
headers["x-max-steps-override"] = String(
task.max_steps_per_run,
);
}
return {
method: "POST",
url: `${runsApiBaseUrl}/run/tasks`,
body: buildTaskRunPayload(
createTaskRequestObject(task),
RunEngine.SkyvernV1,
),
headers,
} satisfies ApiCommandOptions;
}}
webhookDisabled={taskIsLoading || !taskHasTerminalState}
onTestWebhook={() => setReplayOpen(true)}
/>
<WebhookReplayDialog
runId={task?.workflow_run_id ?? ""}
disabled={taskIsLoading || !taskHasTerminalState}
open={replayOpen}
onOpenChange={setReplayOpen}
hideTrigger
/>
{taskIsRunningOrQueued && (
<Dialog>
<DialogTrigger asChild>
<Button variant="destructive">Cancel</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Are you sure?</DialogTitle>
<DialogDescription>
Are you sure you want to cancel this task?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<DialogClose asChild>
<Button variant="secondary">Back</Button>
</DialogClose>
<Button
variant="destructive"
onClick={() => {
cancelTaskMutation.mutate();
}}
disabled={cancelTaskMutation.isPending}
>
{cancelTaskMutation.isPending && (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
)}
Cancel Task
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)}
{taskHasTerminalState && (
<Button asChild>
<Link to={`/tasks/create/retry/${task.task_id}`}>
<PlayIcon className="mr-2 h-4 w-4" />
Rerun
</Link>
</Button>
)}
</div>
</div>
<div className="text-2xl text-slate-400 underline underline-offset-4">
{workflowIsLoading || workflowRunIsLoading ? (
<Skeleton className="h-8 w-64" />
) : (
workflow &&
workflowRun && (
<Link
to={`/workflows/${workflow.workflow_permanent_id}/${workflowRun.workflow_run_id}/overview`}
>
{workflow.title}
</Link>
)
)}
</div>
</header>
{taskIsLoading ? (
<Skeleton className="h-32 w-full" />
) : (
<>
{extractedInformation}
{failureReason}
{webhookFailureReason}
</>
)}
<SwitchBarNavigation
options={[
{
label: "Actions",
to: "actions",
},
{
label: "Recording",
to: "recording",
},
{
label: "Parameters",
to: "parameters",
},
{
label: "Diagnostics",
to: "diagnostics",
},
]}
/>
<Outlet />
</div>
);
}
export { TaskDetails };