diff --git a/skyvern-frontend/src/router.tsx b/skyvern-frontend/src/router.tsx index b426edbf..03076618 100644 --- a/skyvern-frontend/src/router.tsx +++ b/skyvern-frontend/src/router.tsx @@ -17,6 +17,10 @@ import { WorkflowRunParameters } from "./routes/workflows/WorkflowRunParameters" import { Workflows } from "./routes/workflows/Workflows"; import { WorkflowsPageLayout } from "./routes/workflows/WorkflowsPageLayout"; import { WorkflowEditor } from "./routes/workflows/editor/WorkflowEditor"; +import { WorkflowRunBlocks } from "./routes/workflows/workflowRun/WorkflowRunBlocks"; +import { WorkflowRunOutput } from "./routes/workflows/workflowRun/WorkflowRunOutput"; +import { WorkflowPostRunParameters } from "./routes/workflows/workflowRun/WorkflowPostRunParameters"; +import { WorkflowRunRecording } from "./routes/workflows/workflowRun/WorkflowRunRecording"; const router = createBrowserRouter([ { @@ -108,6 +112,29 @@ const router = createBrowserRouter([ { path: ":workflowRunId", element: , + children: [ + { + index: true, + element: , + }, + { + path: "blocks", + element: , + }, + { + path: "output", + element: , + }, + { + path: "parameters", + element: , + }, + + { + path: "recording", + element: , + }, + ], }, ], }, diff --git a/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx index 148b154f..bba4a2e9 100644 --- a/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx +++ b/skyvern-frontend/src/routes/tasks/detail/TaskDetails.tsx @@ -257,7 +257,7 @@ function TaskDetails() { workflow && workflowRun && ( {workflow.title} diff --git a/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx index deddadee..4bea16ad 100644 --- a/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx +++ b/skyvern-frontend/src/routes/workflows/RunWorkflowForm.tsx @@ -135,7 +135,7 @@ function RunWorkflowForm({ - - - )} - - - )} -
-
-

- {workflowRunIsRunningOrQueued ? "Previous Blocks" : "Blocks"} -

-
-
- - - - - - Task Title - - ID - URL - Status - - Created At - - - - - {workflowTasksIsLoading ? ( - skeleton - ) : workflowTasks?.length === 0 ? ( - - Could not find any tasks - - ) : ( - workflowTasks - ?.filter( - (task) => task.task_id !== currentRunningTask?.task_id, - ) - .map((task) => { - return ( - - ); - }) - )} - -
- - - - { - if (page === 1) { - return; - } - const params = new URLSearchParams(); - params.set("page", String(Math.max(1, page - 1))); - setSearchParams(params, { replace: true }); - }} - /> - - - {page} - - - { - const params = new URLSearchParams(); - params.set("page", String(page + 1)); - setSearchParams(params, { replace: true }); - }} - /> - - - -
-
- {workflowRunIsFinalized && ( -
-
-

Block Outputs

-
- -
- )} - {workflowRunIsFinalized && ( -
-
-

Downloaded Files

-
-
- {fileUrls.length > 0 ? ( - fileUrls.map((url, index) => { - return ( - - ); - }) - ) : ( -
No files downloaded
- )} -
-
- )} - {Object.entries(parameters).length > 0 && ( -
-
-

Input Parameter Values

-
- {Object.entries(parameters).length === 0 && ( -
This workflow doesn't have any input parameters.
- )} - {Object.entries(parameters).map(([key, value]) => { - return ( -
- - {typeof value === "string" || - typeof value === "number" || - typeof value === "boolean" ? ( - - ) : ( - - )} -
+
+ { + return cn( + "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", + { + "bg-slate-700": isActive, + }, ); - })} -
- )} + }} + > + Blocks + + { + return cn( + "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", + { + "bg-slate-700": isActive, + }, + ); + }} + > + Output + + { + return cn( + "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", + { + "bg-slate-700": isActive, + }, + ); + }} + > + Parameters + + { + return cn( + "cursor-pointer rounded-sm px-3 py-2 hover:bg-slate-700", + { + "bg-slate-700": isActive, + }, + ); + }} + > + Recording + +
+ ); } diff --git a/skyvern-frontend/src/routes/workflows/Workflows.tsx b/skyvern-frontend/src/routes/workflows/Workflows.tsx index af587e96..141423d1 100644 --- a/skyvern-frontend/src/routes/workflows/Workflows.tsx +++ b/skyvern-frontend/src/routes/workflows/Workflows.tsx @@ -396,14 +396,14 @@ function Workflows() { if (event.ctrlKey || event.metaKey) { window.open( window.location.origin + - `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}`, + `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}/blocks`, "_blank", "noopener,noreferrer", ); return; } navigate( - `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}`, + `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}/blocks`, ); }} className="cursor-pointer" diff --git a/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunQuery.ts new file mode 100644 index 00000000..36754127 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useWorkflowRunQuery.ts @@ -0,0 +1,48 @@ +import { getClient } from "@/api/AxiosClient"; +import { WorkflowRunStatusApiResponse } from "@/api/types"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { + statusIsNotFinalized, + statusIsRunningOrQueued, +} from "@/routes/tasks/types"; +import { keepPreviousData, useQuery } from "@tanstack/react-query"; +import { useParams } from "react-router-dom"; + +function useWorkflowRunQuery() { + const { workflowRunId, workflowPermanentId } = useParams(); + const credentialGetter = useCredentialGetter(); + + return useQuery({ + queryKey: ["workflowRun", workflowPermanentId, workflowRunId], + queryFn: async () => { + const client = await getClient(credentialGetter); + return client + .get(`/workflows/${workflowPermanentId}/runs/${workflowRunId}`) + .then((response) => response.data); + }, + refetchInterval: (query) => { + if (!query.state.data) { + return false; + } + if (statusIsNotFinalized(query.state.data)) { + return 5000; + } + return false; + }, + placeholderData: keepPreviousData, + refetchOnMount: (query) => { + if (!query.state.data) { + return false; + } + return statusIsRunningOrQueued(query.state.data) ? "always" : false; + }, + refetchOnWindowFocus: (query) => { + if (!query.state.data) { + return false; + } + return statusIsRunningOrQueued(query.state.data); + }, + }); +} + +export { useWorkflowRunQuery }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowPostRunParameters.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowPostRunParameters.tsx new file mode 100644 index 00000000..d90feec2 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowPostRunParameters.tsx @@ -0,0 +1,43 @@ +import { Label } from "@/components/ui/label"; +import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; +import { CodeEditor } from "../components/CodeEditor"; +import { AutoResizingTextarea } from "@/components/AutoResizingTextarea/AutoResizingTextarea"; + +function WorkflowPostRunParameters() { + const { data: workflowRun } = useWorkflowRunQuery(); + const parameters = workflowRun?.parameters ?? {}; + + return Object.entries(parameters).length > 0 ? ( +
+
+

Input Parameter Values

+
+ {Object.entries(parameters).map(([key, value]) => { + return ( +
+ + {typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" ? ( + + ) : ( + + )} +
+ ); + })} +
+ ) : ( + Object.entries(parameters).length === 0 && ( +
This workflow doesn't have any input parameters.
+ ) + ); +} + +export { WorkflowPostRunParameters }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunBlocks.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunBlocks.tsx new file mode 100644 index 00000000..bb896549 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunBlocks.tsx @@ -0,0 +1,411 @@ +import { getClient } from "@/api/AxiosClient"; +import { Status, TaskApiResponse } from "@/api/types"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { + keepPreviousData, + useQuery, + useQueryClient, +} from "@tanstack/react-query"; +import { + Link, + useNavigate, + useParams, + useSearchParams, +} from "react-router-dom"; +import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { Skeleton } from "@/components/ui/skeleton"; +import { WorkflowBlockCollapsibleContent } from "../WorkflowBlockCollapsibleContent"; +import { + Pagination, + PaginationContent, + PaginationItem, + PaginationLink, + PaginationNext, + PaginationPrevious, +} from "@/components/ui/pagination"; +import { cn } from "@/util/utils"; +import { + statusIsNotFinalized, + statusIsRunningOrQueued, +} from "@/routes/tasks/types"; +import { useEffect, useState } from "react"; +import { envCredential } from "@/util/env"; +import { toast } from "@/components/ui/use-toast"; +import { ZoomableImage } from "@/components/ZoomableImage"; +import { AspectRatio } from "@/components/ui/aspect-ratio"; +import { Label } from "@/components/ui/label"; +import { StatusBadge } from "@/components/StatusBadge"; +import { + localTimeFormatWithShortDate, + timeFormatWithShortDate, +} from "@/util/timeFormat"; +import { Button } from "@/components/ui/button"; +import { ReaderIcon } from "@radix-ui/react-icons"; + +type StreamMessage = { + task_id: string; + status: string; + screenshot?: string; +}; + +let socket: WebSocket | null = null; + +const wssBaseUrl = import.meta.env.VITE_WSS_BASE_URL; + +function WorkflowRunBlocks() { + const navigate = useNavigate(); + const [searchParams, setSearchParams] = useSearchParams(); + const [streamImgSrc, setStreamImgSrc] = useState(""); + const { workflowRunId, workflowPermanentId } = useParams(); + const page = searchParams.get("page") ? Number(searchParams.get("page")) : 1; + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + const { data: workflowRun, isLoading: workflowRunIsLoading } = + useWorkflowRunQuery(); + + const workflowRunIsRunningOrQueued = + workflowRun && statusIsRunningOrQueued(workflowRun); + + const showStream = workflowRun && statusIsNotFinalized(workflowRun); + + useEffect(() => { + if (!workflowRunIsRunningOrQueued) { + return; + } + + async function run() { + // Create WebSocket connection. + let credential = null; + if (credentialGetter) { + const token = await credentialGetter(); + credential = `?token=Bearer ${token}`; + } else { + credential = `?apikey=${envCredential}`; + } + if (socket) { + socket.close(); + } + socket = new WebSocket( + `${wssBaseUrl}/stream/workflow_runs/${workflowRunId}${credential}`, + ); + // Listen for messages + socket.addEventListener("message", (event) => { + try { + const message: StreamMessage = JSON.parse(event.data); + if (message.screenshot) { + setStreamImgSrc(message.screenshot); + } + if ( + message.status === "completed" || + message.status === "failed" || + message.status === "terminated" + ) { + socket?.close(); + queryClient.invalidateQueries({ + queryKey: ["workflowRuns"], + }); + queryClient.invalidateQueries({ + queryKey: ["workflowRun", workflowPermanentId, workflowRunId], + }); + queryClient.invalidateQueries({ + queryKey: ["workflowTasks", workflowRunId], + }); + if ( + message.status === "failed" || + message.status === "terminated" + ) { + toast({ + title: "Run Failed", + description: "The workflow run has failed.", + variant: "destructive", + }); + } else if (message.status === "completed") { + toast({ + title: "Run Completed", + description: "The workflow run has been completed.", + variant: "success", + }); + } + } + } catch (e) { + console.error("Failed to parse message", e); + } + }); + + socket.addEventListener("close", () => { + socket = null; + }); + } + run(); + + return () => { + if (socket) { + socket.close(); + socket = null; + } + }; + }, [ + credentialGetter, + workflowRunId, + workflowRunIsRunningOrQueued, + queryClient, + workflowPermanentId, + ]); + + function getStream() { + if (workflowRun?.status === Status.Created) { + return ( +
+ Workflow has been created. + Stream will start when the workflow is running. +
+ ); + } + if (workflowRun?.status === Status.Queued) { + return ( +
+ Your workflow run is queued. + Stream will start when the workflow is running. +
+ ); + } + + if (workflowRun?.status === Status.Running && streamImgSrc.length === 0) { + return ( +
+ Starting the stream... +
+ ); + } + + if (workflowRun?.status === Status.Running && streamImgSrc.length > 0) { + return ( +
+ +
+ ); + } + return null; + } + + const { data: workflowTasks, isLoading: workflowTasksIsLoading } = useQuery< + Array + >({ + queryKey: ["workflowTasks", workflowRunId, page], + queryFn: async () => { + const client = await getClient(credentialGetter); + const params = new URLSearchParams(); + params.append("page", String(page)); + params.append("page_size", "20"); + return client + .get(`/tasks?workflow_run_id=${workflowRunId}`, { params }) + .then((response) => response.data); + }, + refetchInterval: () => { + if (workflowRun?.status === Status.Running) { + return 5000; + } + return false; + }, + placeholderData: keepPreviousData, + refetchOnMount: workflowRun?.status === Status.Running ? "always" : false, + refetchOnWindowFocus: + workflowRun?.status === Status.Running ? "always" : false, + }); + + const skeleton = ( + + + + + + + + + + + + + + + + + + + + + ); + + const currentRunningTask = workflowTasks?.find( + (task) => task.status === Status.Running, + ); + + function handleNavigate(event: React.MouseEvent, id: string) { + if (event.ctrlKey || event.metaKey) { + window.open( + window.location.origin + `/tasks/${id}/actions`, + "_blank", + "noopener,noreferrer", + ); + } else { + navigate(`/tasks/${id}/actions`); + } + } + + return ( +
+ {showStream && ( +
+
+

Live Stream

+
+
+
+ {getStream()} +
+
+
Current Task
+ {workflowRunIsLoading || !currentRunningTask ? ( +
Waiting for a task to start...
+ ) : ( +
+
+ + + {currentRunningTask.task_id} + +
+
+ + + {currentRunningTask.request.url} + +
+
+ + + + +
+
+ + + {currentRunningTask && + localTimeFormatWithShortDate( + currentRunningTask.created_at, + )} + +
+
+ +
+
+ )} +
+
+
+ )} +
+
+

Workflow Blocks

+
+
+ + + + + + Task Title + + ID + URL + Status + + Created At + + + + + {workflowTasksIsLoading ? ( + skeleton + ) : workflowTasks?.length === 0 ? ( + + Could not find any tasks + + ) : ( + workflowTasks + ?.filter( + (task) => task.task_id !== currentRunningTask?.task_id, + ) + .map((task) => { + return ( + + ); + }) + )} + +
+ + + + { + if (page === 1) { + return; + } + const params = new URLSearchParams(); + params.set("page", String(Math.max(1, page - 1))); + setSearchParams(params, { replace: true }); + }} + /> + + + {page} + + + { + const params = new URLSearchParams(); + params.set("page", String(page + 1)); + setSearchParams(params, { replace: true }); + }} + /> + + + +
+
+
+ ); +} + +export { WorkflowRunBlocks }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOutput.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOutput.tsx new file mode 100644 index 00000000..2ebc41dc --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunOutput.tsx @@ -0,0 +1,48 @@ +import { FileIcon } from "@radix-ui/react-icons"; +import { CodeEditor } from "../components/CodeEditor"; +import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; + +function WorkflowRunOutput() { + const { data: workflowRun } = useWorkflowRunQuery(); + const outputs = workflowRun?.outputs; + const fileUrls = workflowRun?.downloaded_file_urls ?? []; + return ( +
+
+

Workflow Run Output

+
+ +
+
+

Downloaded Files

+
+
+ {fileUrls.length > 0 ? ( + fileUrls.map((url, index) => { + return ( + + ); + }) + ) : ( +
No files downloaded
+ )} +
+
+
+ ); +} + +export { WorkflowRunOutput }; diff --git a/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunRecording.tsx b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunRecording.tsx new file mode 100644 index 00000000..1b63f055 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/workflowRun/WorkflowRunRecording.tsx @@ -0,0 +1,13 @@ +import { useWorkflowRunQuery } from "../hooks/useWorkflowRunQuery"; + +function WorkflowRunRecording() { + const { data: workflowRun } = useWorkflowRunQuery(); + const recordingURL = workflowRun?.recording_url; + return recordingURL ? ( +