Add export as csv in tasks and workflow runs (#1361)

This commit is contained in:
Shuchang Zheng
2024-12-09 07:49:42 -08:00
committed by GitHub
parent 3e480ddc62
commit 83481126db
4 changed files with 351 additions and 250 deletions

View File

@@ -192,6 +192,7 @@ export type WorkflowRunApiResponse = {
webhook_callback_url: string; webhook_callback_url: string;
created_at: string; created_at: string;
modified_at: string; modified_at: string;
failure_reason: string | null;
}; };
export type WorkflowRunStatusApiResponse = { export type WorkflowRunStatusApiResponse = {

View File

@@ -26,6 +26,9 @@ import { useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom"; import { useNavigate, useSearchParams } from "react-router-dom";
import { TaskActions } from "./TaskActions"; import { TaskActions } from "./TaskActions";
import { TaskListSkeletonRows } from "./TaskListSkeletonRows"; import { TaskListSkeletonRows } from "./TaskListSkeletonRows";
import { Button } from "@/components/ui/button";
import { DownloadIcon } from "@radix-ui/react-icons";
import { downloadBlob } from "@/util/downloadBlob";
type StatusDropdownItem = { type StatusDropdownItem = {
label: string; 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 ( return (
<div className="space-y-4"> <div className="space-y-4">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<h1 className="text-2xl">Task Runs</h1> <h1 className="text-2xl">Task Runs</h1>
<StatusFilterDropdown <div className="flex gap-2">
values={statusFilters} <StatusFilterDropdown
onChange={setStatusFilters} values={statusFilters}
options={statusDropdownItems} onChange={setStatusFilters}
/> options={statusDropdownItems}
/>
<Button variant="secondary" onClick={handleExport}>
<DownloadIcon className="mr-2" />
Export CSV
</Button>
</div>
</header> </header>
<div className="rounded-md border"> <div className="rounded-md border">
<Table> <Table>

View File

@@ -28,6 +28,7 @@ import { useCredentialGetter } from "@/hooks/useCredentialGetter";
import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat"; import { basicLocalTimeFormat, basicTimeFormat } from "@/util/timeFormat";
import { cn } from "@/util/utils"; import { cn } from "@/util/utils";
import { import {
DownloadIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
Pencil2Icon, Pencil2Icon,
PlayIcon, PlayIcon,
@@ -43,6 +44,7 @@ import { WorkflowActions } from "./WorkflowActions";
import { WorkflowTitle } from "./WorkflowTitle"; import { WorkflowTitle } from "./WorkflowTitle";
import { WorkflowApiResponse } from "./types/workflowTypes"; import { WorkflowApiResponse } from "./types/workflowTypes";
import { WorkflowRunApiResponse } from "@/api/types"; import { WorkflowRunApiResponse } from "@/api/types";
import { downloadBlob } from "@/util/downloadBlob";
const emptyWorkflowRequest: WorkflowCreateYAMLRequest = { const emptyWorkflowRequest: WorkflowCreateYAMLRequest = {
title: "New Workflow", 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( function handleRowClick(
event: React.MouseEvent<HTMLTableCellElement>, event: React.MouseEvent<HTMLTableCellElement>,
workflowPermanentId: string, workflowPermanentId: string,
@@ -178,258 +206,274 @@ function Workflows() {
</AlertDescription> </AlertDescription>
</Alert> </Alert>
)} )}
<div className="space-y-4">
<header className="flex items-center justify-between"> <header className="flex items-center justify-between">
<h1 className="text-2xl font-semibold">Workflows</h1> <h1 className="text-2xl font-semibold">Workflows</h1>
<div className="flex gap-2"> <div className="flex gap-2">
<ImportWorkflowButton /> <ImportWorkflowButton />
<Button <Button
disabled={createNewWorkflowMutation.isPending} disabled={createNewWorkflowMutation.isPending}
onClick={() => { onClick={() => {
createNewWorkflowMutation.mutate(); createNewWorkflowMutation.mutate();
}} }}
> >
{createNewWorkflowMutation.isPending ? ( {createNewWorkflowMutation.isPending ? (
<ReloadIcon className="mr-2 h-4 w-4 animate-spin" /> <ReloadIcon className="mr-2 h-4 w-4 animate-spin" />
) : ( ) : (
<PlusIcon className="mr-2 h-4 w-4" /> <PlusIcon className="mr-2 h-4 w-4" />
)} )}
Create Workflow Create Workflow
</Button> </Button>
</div>
</header>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-1/3">ID</TableHead>
<TableHead className="w-1/3">Title</TableHead>
<TableHead className="w-1/3">Created At</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4}>Loading...</TableCell>
</TableRow>
) : workflows?.length === 0 ? (
<TableRow>
<TableCell colSpan={4}>No workflows found</TableCell>
</TableRow>
) : (
workflows?.map((workflow) => {
return (
<TableRow
key={workflow.workflow_permanent_id}
className="cursor-pointer"
>
<TableCell
onClick={(event) => {
handleRowClick(event, workflow.workflow_permanent_id);
}}
>
{workflow.workflow_permanent_id}
</TableCell>
<TableCell
onClick={(event) => {
handleRowClick(event, workflow.workflow_permanent_id);
}}
>
{workflow.title}
</TableCell>
<TableCell
onClick={(event) => {
handleRowClick(event, workflow.workflow_permanent_id);
}}
title={basicTimeFormat(workflow.created_at)}
>
{basicLocalTimeFormat(workflow.created_at)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={(event) => {
handleIconClick(
event,
`/workflows/${workflow.workflow_permanent_id}/edit`,
);
}}
>
<Pencil2Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Open in Editor</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={(event) => {
handleIconClick(
event,
`/workflows/${workflow.workflow_permanent_id}/run`,
);
}}
>
<PlayIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create New Run</TooltipContent>
</Tooltip>
</TooltipProvider>
<WorkflowActions workflow={workflow} />
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<Pagination className="pt-2">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
className={cn({ "cursor-not-allowed": workflowsPage === 1 })}
onClick={() => {
if (workflowsPage === 1) {
return;
}
const params = new URLSearchParams();
params.set(
"workflowsPage",
String(Math.max(1, workflowsPage - 1)),
);
setSearchParams(params, { replace: true });
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink>{workflowsPage}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={() => {
const params = new URLSearchParams();
params.set("workflowsPage", String(workflowsPage + 1));
setSearchParams(params, { replace: true });
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div> </div>
</header>
<div className="rounded-md border">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-1/3">ID</TableHead>
<TableHead className="w-1/3">Title</TableHead>
<TableHead className="w-1/3">Created At</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={4}>Loading...</TableCell>
</TableRow>
) : workflows?.length === 0 ? (
<TableRow>
<TableCell colSpan={4}>No workflows found</TableCell>
</TableRow>
) : (
workflows?.map((workflow) => {
return (
<TableRow
key={workflow.workflow_permanent_id}
className="cursor-pointer"
>
<TableCell
onClick={(event) => {
handleRowClick(event, workflow.workflow_permanent_id);
}}
>
{workflow.workflow_permanent_id}
</TableCell>
<TableCell
onClick={(event) => {
handleRowClick(event, workflow.workflow_permanent_id);
}}
>
{workflow.title}
</TableCell>
<TableCell
onClick={(event) => {
handleRowClick(event, workflow.workflow_permanent_id);
}}
title={basicTimeFormat(workflow.created_at)}
>
{basicLocalTimeFormat(workflow.created_at)}
</TableCell>
<TableCell>
<div className="flex justify-end gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={(event) => {
handleIconClick(
event,
`/workflows/${workflow.workflow_permanent_id}/edit`,
);
}}
>
<Pencil2Icon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Open in Editor</TooltipContent>
</Tooltip>
</TooltipProvider>
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
size="icon"
variant="outline"
onClick={(event) => {
handleIconClick(
event,
`/workflows/${workflow.workflow_permanent_id}/run`,
);
}}
>
<PlayIcon className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Create New Run</TooltipContent>
</Tooltip>
</TooltipProvider>
<WorkflowActions workflow={workflow} />
</div>
</TableCell>
</TableRow>
);
})
)}
</TableBody>
</Table>
<Pagination className="pt-2">
<PaginationContent>
<PaginationItem>
<PaginationPrevious
className={cn({ "cursor-not-allowed": workflowsPage === 1 })}
onClick={() => {
if (workflowsPage === 1) {
return;
}
const params = new URLSearchParams();
params.set(
"workflowsPage",
String(Math.max(1, workflowsPage - 1)),
);
setSearchParams(params, { replace: true });
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink>{workflowsPage}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={() => {
const params = new URLSearchParams();
params.set("workflowsPage", String(workflowsPage + 1));
setSearchParams(params, { replace: true });
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div> </div>
<header> <div className="space-y-4">
<h1 className="text-2xl font-semibold">Workflow Runs</h1> <header>
</header> <div className="flex justify-between">
<div className="rounded-md border"> <h1 className="text-2xl font-semibold">Workflow Runs</h1>
<Table> <Button variant="secondary" onClick={handleExport}>
<TableHeader> <DownloadIcon className="mr-2" />
<TableRow> Export CSV
<TableHead className="w-1/5">Workflow Run ID</TableHead> </Button>
<TableHead className="w-1/5">Workflow ID</TableHead> </div>
<TableHead className="w-1/5">Workflow Title</TableHead> </header>
<TableHead className="w-1/5">Status</TableHead> <div className="rounded-md border">
<TableHead className="w-1/5">Created At</TableHead> <Table>
</TableRow> <TableHeader>
</TableHeader>
<TableBody>
{workflowRunsIsLoading ? (
<TableRow> <TableRow>
<TableCell colSpan={5}>Loading...</TableCell> <TableHead className="w-1/5">Workflow Run ID</TableHead>
<TableHead className="w-1/5">Workflow ID</TableHead>
<TableHead className="w-1/5">Workflow Title</TableHead>
<TableHead className="w-1/5">Status</TableHead>
<TableHead className="w-1/5">Created At</TableHead>
</TableRow> </TableRow>
) : workflowRuns?.length === 0 ? ( </TableHeader>
<TableRow> <TableBody>
<TableCell colSpan={5}>No workflow runs found</TableCell> {workflowRunsIsLoading ? (
</TableRow> <TableRow>
) : ( <TableCell colSpan={5}>Loading...</TableCell>
workflowRuns?.map((workflowRun) => { </TableRow>
return ( ) : workflowRuns?.length === 0 ? (
<TableRow <TableRow>
key={workflowRun.workflow_run_id} <TableCell colSpan={5}>No workflow runs found</TableCell>
onClick={(event) => { </TableRow>
if (event.ctrlKey || event.metaKey) { ) : (
window.open( workflowRuns?.map((workflowRun) => {
window.location.origin + return (
`/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}`, <TableRow
"_blank", key={workflowRun.workflow_run_id}
"noopener,noreferrer", onClick={(event) => {
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; }}
} className="cursor-pointer"
navigate(
`/workflows/${workflowRun.workflow_permanent_id}/${workflowRun.workflow_run_id}`,
);
}}
className="cursor-pointer"
>
<TableCell className="w-1/5">
{workflowRun.workflow_run_id}
</TableCell>
<TableCell className="w-1/5">
{workflowRun.workflow_permanent_id}
</TableCell>
<TableCell className="w-1/5">
<WorkflowTitle
workflowPermanentId={workflowRun.workflow_permanent_id}
/>
</TableCell>
<TableCell className="w-1/5">
<StatusBadge status={workflowRun.status} />
</TableCell>
<TableCell
className="w-1/5"
title={basicTimeFormat(workflowRun.created_at)}
> >
{basicLocalTimeFormat(workflowRun.created_at)} <TableCell className="w-1/5">
</TableCell> {workflowRun.workflow_run_id}
</TableRow> </TableCell>
); <TableCell className="w-1/5">
}) {workflowRun.workflow_permanent_id}
)} </TableCell>
</TableBody> <TableCell className="w-1/5">
</Table> <WorkflowTitle
<Pagination className="pt-2"> workflowPermanentId={
<PaginationContent> workflowRun.workflow_permanent_id
<PaginationItem> }
<PaginationPrevious />
className={cn({ "cursor-not-allowed": workflowRunsPage === 1 })} </TableCell>
onClick={() => { <TableCell className="w-1/5">
if (workflowRunsPage === 1) { <StatusBadge status={workflowRun.status} />
return; </TableCell>
} <TableCell
const params = new URLSearchParams(); className="w-1/5"
params.set( title={basicTimeFormat(workflowRun.created_at)}
"workflowRunsPage", >
String(Math.max(1, workflowRunsPage - 1)), {basicLocalTimeFormat(workflowRun.created_at)}
</TableCell>
</TableRow>
); );
setSearchParams(params, { replace: true }); })
}} )}
/> </TableBody>
</PaginationItem> </Table>
<PaginationItem> <Pagination className="pt-2">
<PaginationLink>{workflowRunsPage}</PaginationLink> <PaginationContent>
</PaginationItem> <PaginationItem>
<PaginationItem> <PaginationPrevious
<PaginationNext className={cn({
onClick={() => { "cursor-not-allowed": workflowRunsPage === 1,
const params = new URLSearchParams(); })}
params.set("workflowRunsPage", String(workflowRunsPage + 1)); onClick={() => {
setSearchParams(params, { replace: true }); if (workflowRunsPage === 1) {
}} return;
/> }
</PaginationItem> const params = new URLSearchParams();
</PaginationContent> params.set(
</Pagination> "workflowRunsPage",
String(Math.max(1, workflowRunsPage - 1)),
);
setSearchParams(params, { replace: true });
}}
/>
</PaginationItem>
<PaginationItem>
<PaginationLink>{workflowRunsPage}</PaginationLink>
</PaginationItem>
<PaginationItem>
<PaginationNext
onClick={() => {
const params = new URLSearchParams();
params.set(
"workflowRunsPage",
String(workflowRunsPage + 1),
);
setSearchParams(params, { replace: true });
}}
/>
</PaginationItem>
</PaginationContent>
</Pagination>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -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 };