diff --git a/skyvern-frontend/src/api/types.ts b/skyvern-frontend/src/api/types.ts index c6ecedf1f..d9c00a614 100644 --- a/skyvern-frontend/src/api/types.ts +++ b/skyvern-frontend/src/api/types.ts @@ -192,6 +192,7 @@ export type WorkflowRunApiResponse = { webhook_callback_url: string; created_at: string; modified_at: string; + failure_reason: string | null; }; export type WorkflowRunStatusApiResponse = { diff --git a/skyvern-frontend/src/routes/tasks/list/TaskHistory.tsx b/skyvern-frontend/src/routes/tasks/list/TaskHistory.tsx index 3380ad9c7..c7cfabb95 100644 --- a/skyvern-frontend/src/routes/tasks/list/TaskHistory.tsx +++ b/skyvern-frontend/src/routes/tasks/list/TaskHistory.tsx @@ -26,6 +26,9 @@ import { useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { TaskActions } from "./TaskActions"; import { TaskListSkeletonRows } from "./TaskListSkeletonRows"; +import { Button } from "@/components/ui/button"; +import { DownloadIcon } from "@radix-ui/react-icons"; +import { downloadBlob } from "@/util/downloadBlob"; type StatusDropdownItem = { label: string; @@ -115,15 +118,47 @@ function TaskHistory() { } } + function handleExport() { + if (!tasks) { + return; // should never happen + } + const data = ["id,url,status,created,failure_reason"]; + tasks.forEach((task) => { + const row = [ + task.task_id, + task.request.url, + task.status, + task.created_at, + task.failure_reason ?? "", + ]; + data.push( + row + .map(String) // convert every value to String + .map((v) => v.replace(new RegExp('"', "g"), '""')) // escape double quotes + .map((v) => `"${v}"`) // quote it + .join(","), // comma-separated + ); + }); + const contents = data.join("\r\n"); + + downloadBlob(contents, "export.csv", "data:text/csv;charset=utf-8;"); + } + return (

Task Runs

- +
+ + +
diff --git a/skyvern-frontend/src/routes/workflows/Workflows.tsx b/skyvern-frontend/src/routes/workflows/Workflows.tsx index c39f71063..af587e961 100644 --- a/skyvern-frontend/src/routes/workflows/Workflows.tsx +++ b/skyvern-frontend/src/routes/workflows/Workflows.tsx @@ -28,6 +28,7 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter"; import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat"; import { cn } from "@/util/utils"; import { + DownloadIcon, ExclamationTriangleIcon, Pencil2Icon, PlayIcon, @@ -43,6 +44,7 @@ import { WorkflowActions } from "./WorkflowActions"; import { WorkflowTitle } from "./WorkflowTitle"; import { WorkflowApiResponse } from "./types/workflowTypes"; import { WorkflowRunApiResponse } from "@/api/types"; +import { downloadBlob } from "@/util/downloadBlob"; const emptyWorkflowRequest: WorkflowCreateYAMLRequest = { title: "New Workflow", @@ -118,6 +120,32 @@ function Workflows() { }, }); + function handleExport() { + if (!workflowRuns) { + return; // should never happen + } + const data = ["workflow_run_id,workflow_id,status,created,failure_reason"]; + workflowRuns.forEach((workflowRun) => { + const row = [ + workflowRun.workflow_run_id, + workflowRun.workflow_permanent_id, + workflowRun.status, + workflowRun.created_at, + workflowRun.failure_reason ?? "", + ]; + data.push( + row + .map(String) // convert every value to String + .map((v) => v.replace(new RegExp('"', "g"), '""')) // escape double quotes + .map((v) => `"${v}"`) // quote it + .join(","), // comma-separated + ); + }); + const contents = data.join("\r\n"); + + downloadBlob(contents, "export.csv", "data:text/csv;charset=utf-8;"); + } + function handleRowClick( event: React.MouseEvent, workflowPermanentId: string, @@ -178,258 +206,274 @@ function Workflows() { )} - -
-

Workflows

-
- - -
-
-
-
- - - ID - Title - Created At - - - - - {isLoading ? ( - - Loading... - - ) : workflows?.length === 0 ? ( +
+
+

Workflows

+
+ + +
+
+
+
+ - No workflows found + ID + Title + Created At + - ) : ( - workflows?.map((workflow) => { - return ( - - { - handleRowClick(event, workflow.workflow_permanent_id); - }} + + + {isLoading ? ( + + Loading... + + ) : workflows?.length === 0 ? ( + + No workflows found + + ) : ( + workflows?.map((workflow) => { + return ( + - {workflow.workflow_permanent_id} - - { - handleRowClick(event, workflow.workflow_permanent_id); - }} - > - {workflow.title} - - { - handleRowClick(event, workflow.workflow_permanent_id); - }} - title={basicTimeFormat(workflow.created_at)} - > - {basicLocalTimeFormat(workflow.created_at)} - - -
- - - - - - Open in Editor - - - - - - - - Create New Run - - - -
-
-
- ); - }) - )} -
-
- - - - { - if (workflowsPage === 1) { - return; - } - const params = new URLSearchParams(); - params.set( - "workflowsPage", - String(Math.max(1, workflowsPage - 1)), + { + handleRowClick(event, workflow.workflow_permanent_id); + }} + > + {workflow.workflow_permanent_id} + + { + handleRowClick(event, workflow.workflow_permanent_id); + }} + > + {workflow.title} + + { + handleRowClick(event, workflow.workflow_permanent_id); + }} + title={basicTimeFormat(workflow.created_at)} + > + {basicLocalTimeFormat(workflow.created_at)} + + +
+ + + + + + Open in Editor + + + + + + + + Create New Run + + + +
+
+ ); - setSearchParams(params, { replace: true }); - }} - /> -
- - {workflowsPage} - - - { - const params = new URLSearchParams(); - params.set("workflowsPage", String(workflowsPage + 1)); - setSearchParams(params, { replace: true }); - }} - /> - -
-
+ }) + )} + + + + + + { + if (workflowsPage === 1) { + return; + } + const params = new URLSearchParams(); + params.set( + "workflowsPage", + String(Math.max(1, workflowsPage - 1)), + ); + setSearchParams(params, { replace: true }); + }} + /> + + + {workflowsPage} + + + { + const params = new URLSearchParams(); + params.set("workflowsPage", String(workflowsPage + 1)); + setSearchParams(params, { replace: true }); + }} + /> + + + +
-
-

Workflow Runs

-
-
- - - - Workflow Run ID - Workflow ID - Workflow Title - Status - Created At - - - - {workflowRunsIsLoading ? ( +
+
+
+

Workflow Runs

+ +
+
+
+
+ - Loading... + Workflow Run ID + Workflow ID + Workflow Title + Status + Created At - ) : workflowRuns?.length === 0 ? ( - - No workflow runs found - - ) : ( - workflowRuns?.map((workflowRun) => { - return ( - { - if (event.ctrlKey || event.metaKey) { - window.open( - window.location.origin + - `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}`, - "_blank", - "noopener,noreferrer", + + + {workflowRunsIsLoading ? ( + + Loading... + + ) : workflowRuns?.length === 0 ? ( + + No workflow runs found + + ) : ( + workflowRuns?.map((workflowRun) => { + return ( + { + if (event.ctrlKey || event.metaKey) { + window.open( + window.location.origin + + `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}`, + "_blank", + "noopener,noreferrer", + ); + return; + } + navigate( + `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}`, ); - return; - } - navigate( - `/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}`, - ); - }} - className="cursor-pointer" - > - - {workflowRun.workflow_run_id} - - - {workflowRun.workflow_permanent_id} - - - - - - - - - {basicLocalTimeFormat(workflowRun.created_at)} - - - ); - }) - )} - -
- - - - { - if (workflowRunsPage === 1) { - return; - } - const params = new URLSearchParams(); - params.set( - "workflowRunsPage", - String(Math.max(1, workflowRunsPage - 1)), + + {workflowRun.workflow_run_id} + + + {workflowRun.workflow_permanent_id} + + + + + + + + + {basicLocalTimeFormat(workflowRun.created_at)} + + ); - setSearchParams(params, { replace: true }); - }} - /> - - - {workflowRunsPage} - - - { - const params = new URLSearchParams(); - params.set("workflowRunsPage", String(workflowRunsPage + 1)); - setSearchParams(params, { replace: true }); - }} - /> - - - + }) + )} + + + + + + { + if (workflowRunsPage === 1) { + return; + } + const params = new URLSearchParams(); + params.set( + "workflowRunsPage", + String(Math.max(1, workflowRunsPage - 1)), + ); + setSearchParams(params, { replace: true }); + }} + /> + + + {workflowRunsPage} + + + { + const params = new URLSearchParams(); + params.set( + "workflowRunsPage", + String(workflowRunsPage + 1), + ); + setSearchParams(params, { replace: true }); + }} + /> + + + +
); diff --git a/skyvern-frontend/src/util/downloadBlob.ts b/skyvern-frontend/src/util/downloadBlob.ts new file mode 100644 index 000000000..ab824c6dd --- /dev/null +++ b/skyvern-frontend/src/util/downloadBlob.ts @@ -0,0 +1,21 @@ +/** + * Download contents as a file + * Source: https://stackoverflow.com/questions/14964035/how-to-export-javascript-array-info-to-csv-on-client-side + */ +function downloadBlob(content: string, filename: string, contentType: string) { + // Create a blob + const blob = new Blob([content], { type: contentType }); + const url = URL.createObjectURL(blob); + + // Create a link to download it + const element = document.createElement("a"); + element.href = url; + element.setAttribute("download", filename); + element.style.display = "none"; + document.body.appendChild(element); + element.click(); + document.body.removeChild(element); + URL.revokeObjectURL(url); +} + +export { downloadBlob };