Skip to content

Commit 4f9d46c

Browse files
feat: add runs (#664)
1 parent 653ce12 commit 4f9d46c

31 files changed

+1207
-175
lines changed
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { DeletePipelineAlert } from "./DeletePipelineAlert";
2+
import { usePipelinesSelectorContext } from "./PipelineSelectorContext";
3+
4+
export function PipelinesButtonGroup() {
5+
const { selectedPipelines } = usePipelinesSelectorContext();
6+
return (
7+
<div className="flex items-center divide-x divide-theme-border-moderate overflow-hidden rounded-md border border-theme-border-moderate">
8+
<div className="bg-primary-25 px-2 py-1 font-semibold text-theme-text-brand">{`${selectedPipelines?.length} Pipeline${selectedPipelines?.length > 1 ? "s" : ""} selected`}</div>
9+
<DeletePipelineAlert />
10+
</div>
11+
);
12+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import Trash from "@/assets/icons/trash.svg?react";
2+
import { DeleteAlertContent } from "@/components/DeleteAlertDialog";
3+
import { AlertDialog, AlertDialogTrigger, Button } from "@zenml-io/react-component-library";
4+
import { useState } from "react";
5+
import { usePipelinesSelectorContext } from "./PipelineSelectorContext";
6+
7+
export function DeletePipelineAlert() {
8+
const [isOpen, setIsOpen] = useState(false);
9+
const { bulkDeletePipelines, selectedPipelines } = usePipelinesSelectorContext();
10+
11+
async function handleDelete() {
12+
await bulkDeletePipelines(selectedPipelines);
13+
setIsOpen(false);
14+
}
15+
16+
return (
17+
<AlertDialog open={isOpen} onOpenChange={setIsOpen}>
18+
<AlertDialogTrigger>
19+
<Button
20+
className="rounded-sharp border-none bg-white"
21+
size="md"
22+
emphasis="subtle"
23+
intent="secondary"
24+
>
25+
<Trash className="h-5 w-5 shrink-0 gap-1 fill-neutral-400" />
26+
Delete
27+
</Button>
28+
</AlertDialogTrigger>
29+
<DeleteAlertContent
30+
title={`Delete Pipeline${selectedPipelines.length >= 2 ? "s" : ""}`}
31+
handleDelete={handleDelete}
32+
/>
33+
</AlertDialog>
34+
);
35+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import DotsIcon from "@/assets/icons/dots-horizontal.svg?react";
2+
import Trash from "@/assets/icons/trash.svg?react";
3+
import { AlertDialogItem } from "@/components/AlertDialogDropdownItem";
4+
import { DeleteAlertContent } from "@/components/DeleteAlertDialog";
5+
import {
6+
AlertDialogTrigger,
7+
DropdownMenu,
8+
DropdownMenuContent,
9+
DropdownMenuTrigger
10+
} from "@zenml-io/react-component-library";
11+
import { ElementRef, useRef, useState } from "react";
12+
import { usePipelinesSelectorContext } from "./PipelineSelectorContext";
13+
14+
type Props = {
15+
id: string;
16+
};
17+
export function PipelineDropdown({ id }: Props) {
18+
const [hasOpenDialog, setHasOpenDialog] = useState(false);
19+
const [dropdownOpen, setDropdownOpen] = useState(false);
20+
const dropdownTriggerRef = useRef<ElementRef<typeof AlertDialogTrigger> | null>(null);
21+
const focusRef = useRef<HTMLElement | null>(null);
22+
23+
const { bulkDeletePipelines } = usePipelinesSelectorContext();
24+
25+
async function handleDelete() {
26+
await bulkDeletePipelines([id]);
27+
handleDialogItemOpenChange(false);
28+
}
29+
30+
function handleDialogItemSelect() {
31+
focusRef.current = dropdownTriggerRef.current;
32+
}
33+
34+
function handleDialogItemOpenChange(open: boolean) {
35+
if (open === false) {
36+
setDropdownOpen(false);
37+
setTimeout(() => {
38+
setHasOpenDialog(open);
39+
}, 200);
40+
return;
41+
}
42+
setHasOpenDialog(open);
43+
}
44+
45+
return (
46+
<DropdownMenu onOpenChange={setDropdownOpen} open={dropdownOpen}>
47+
<DropdownMenuTrigger ref={dropdownTriggerRef}>
48+
<DotsIcon className="h-4 w-4 fill-theme-text-tertiary" />
49+
</DropdownMenuTrigger>
50+
<DropdownMenuContent
51+
hidden={hasOpenDialog}
52+
onCloseAutoFocus={(event) => {
53+
if (focusRef.current) {
54+
focusRef.current.focus();
55+
focusRef.current = null;
56+
event.preventDefault();
57+
}
58+
}}
59+
align="end"
60+
sideOffset={7}
61+
>
62+
{/* <DropdownMenuItem ></DropdownMenuItem> */}
63+
<AlertDialogItem
64+
onSelect={handleDialogItemSelect}
65+
open={hasOpenDialog}
66+
onOpenChange={handleDialogItemOpenChange}
67+
triggerChildren="Delete"
68+
icon={<Trash fill="red" />}
69+
>
70+
<DeleteAlertContent title="Delete Pipeline" handleDelete={handleDelete} />
71+
</AlertDialogItem>
72+
</DropdownMenuContent>
73+
</DropdownMenu>
74+
);
75+
}
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { pipelineQueries } from "@/data/pipelines";
2+
import { useDeletePipeline } from "@/data/pipelines/delete-pipeline";
3+
import { useQueryClient } from "@tanstack/react-query";
4+
import { useToast } from "@zenml-io/react-component-library";
5+
import { SetStateAction, createContext, useContext, useState } from "react";
6+
7+
type PipelinesSelectorContextProps = {
8+
selectedPipelines: string[];
9+
setSelectedPipelines: (actions: SetStateAction<string[]>) => void;
10+
bulkDeletePipelines: (runIds: string[]) => Promise<void>;
11+
};
12+
13+
const PipelinesSelectorContext = createContext<PipelinesSelectorContextProps | null>(null);
14+
15+
export function PipelinesSelectorProvider({ children }: { children: React.ReactNode }) {
16+
const [selectedPipelines, setSelectedPipelines] = useState<string[]>([]);
17+
const queryClient = useQueryClient();
18+
19+
const { toast } = useToast();
20+
21+
const deleteRunMutation = useDeletePipeline();
22+
23+
const bulkDeletePipelines = async (runIds: string[]) => {
24+
try {
25+
// Use mutateAsync to handle each delete operation
26+
const deletePromises = runIds.map((id) => deleteRunMutation.mutateAsync({ pipelineId: id }));
27+
28+
await Promise.all(deletePromises);
29+
toast({
30+
description: "Deleted successfully.",
31+
status: "success",
32+
emphasis: "subtle",
33+
rounded: true
34+
});
35+
await queryClient.invalidateQueries({ queryKey: pipelineQueries.all });
36+
setSelectedPipelines([]);
37+
} catch (error) {
38+
console.error("Failed to delete some pipelines:", error);
39+
}
40+
};
41+
42+
return (
43+
<PipelinesSelectorContext.Provider
44+
value={{ selectedPipelines, setSelectedPipelines, bulkDeletePipelines }}
45+
>
46+
{children}
47+
</PipelinesSelectorContext.Provider>
48+
);
49+
}
50+
51+
export function usePipelinesSelectorContext() {
52+
const context = useContext(PipelinesSelectorContext);
53+
if (!context)
54+
throw new Error("usePipelinesSelectorContext must be used within a PipelinesSelectorProvider");
55+
return context;
56+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import Refresh from "@/assets/icons/refresh.svg?react";
2+
import Pagination from "@/components/Pagination";
3+
import { SearchField } from "@/components/SearchField";
4+
import { pipelineQueries } from "@/data/pipelines";
5+
import { useQuery } from "@tanstack/react-query";
6+
import { Button, DataTable, Skeleton } from "@zenml-io/react-component-library";
7+
import { PipelinesButtonGroup } from "./ButtonGroup";
8+
import { getPipelineColumns } from "./columns";
9+
import { usePipelinesSelectorContext } from "./PipelineSelectorContext";
10+
import { usePipelineOverviewSearchParams } from "./service";
11+
12+
export function PipelinesBody() {
13+
const queryParams = usePipelineOverviewSearchParams();
14+
const { selectedPipelines } = usePipelinesSelectorContext();
15+
const { data, refetch } = useQuery({
16+
...pipelineQueries.pipelineList({ ...queryParams, sort_by: "desc:latest_run" }),
17+
throwOnError: true
18+
});
19+
20+
return (
21+
<div className="flex flex-col gap-5">
22+
<div className="flex items-center justify-between">
23+
{selectedPipelines.length ? (
24+
<PipelinesButtonGroup />
25+
) : (
26+
<SearchField searchParams={queryParams} />
27+
)}
28+
<div className="flex justify-between">
29+
<Button intent="primary" emphasis="subtle" size="md" onClick={() => refetch()}>
30+
<Refresh className="h-5 w-5 fill-theme-text-brand" />
31+
Refresh
32+
</Button>
33+
</div>
34+
</div>
35+
<div className="flex flex-col items-center gap-5">
36+
<div className="w-full">
37+
{data ? (
38+
<DataTable columns={getPipelineColumns()} data={data.items} />
39+
) : (
40+
<Skeleton className="h-[500px] w-full" />
41+
)}
42+
</div>
43+
{data ? (
44+
data.total_pages > 1 && <Pagination searchParams={queryParams} paginate={data} />
45+
) : (
46+
<Skeleton className="h-[36px] w-[300px]" />
47+
)}
48+
</div>
49+
</div>
50+
);
51+
}
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
import { Checkbox } from "@zenml-io/react-component-library";
2+
import { usePipelinesSelectorContext } from "./PipelineSelectorContext";
3+
4+
type Props = {
5+
id: string;
6+
};
7+
8+
export const PipelinesSelector = ({ id }: Props) => {
9+
const { selectedPipelines, setSelectedPipelines } = usePipelinesSelectorContext();
10+
11+
const handleCheck = (isChecked: boolean, id: string) => {
12+
setSelectedPipelines((prevSelectedItems: any) => {
13+
return isChecked
14+
? [...prevSelectedItems, id]
15+
: prevSelectedItems.filter((selectedItem: any) => selectedItem !== id);
16+
});
17+
};
18+
19+
return (
20+
<Checkbox
21+
id={id}
22+
onCheckedChange={(e: boolean) => handleCheck(e, id)}
23+
checked={selectedPipelines.includes(id)}
24+
className="h-3 w-3"
25+
/>
26+
);
27+
};
Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import PipelineIcon from "@/assets/icons/pipeline.svg?react";
2+
import RunIcon from "@/assets/icons/terminal.svg?react";
3+
import { CopyButton } from "@/components/CopyButton";
4+
import {
5+
ExecutionStatusIcon,
6+
getExecutionStatusColor,
7+
getExecutionStatusTagColor
8+
} from "@/components/ExecutionStatus";
9+
import { routes } from "@/router/routes";
10+
import { ExecutionStatus } from "@/types/pipeline-runs";
11+
import { Pipeline } from "@/types/pipelines";
12+
import { ColumnDef } from "@tanstack/react-table";
13+
import {
14+
Tag,
15+
Tooltip,
16+
TooltipContent,
17+
TooltipProvider,
18+
TooltipTrigger
19+
} from "@zenml-io/react-component-library";
20+
import { Link } from "react-router-dom";
21+
import { PipelineDropdown } from "./PipelineDropdown";
22+
import { PipelinesSelector } from "./PipelinesSelector";
23+
24+
export function getPipelineColumns(): ColumnDef<Pipeline>[] {
25+
return [
26+
{
27+
id: "check",
28+
header: "",
29+
meta: {
30+
width: "1%"
31+
},
32+
cell: ({ row }) => {
33+
return <PipelinesSelector id={row.original.id} />;
34+
}
35+
},
36+
{
37+
id: "name",
38+
header: "Pipeline",
39+
cell: ({ row }) => {
40+
return (
41+
<div className="group/copybutton flex items-center gap-2">
42+
<PipelineIcon
43+
className={`h-5 w-5 ${getExecutionStatusColor(row.original.body?.latest_run_status)}`}
44+
/>
45+
<div>
46+
<div className="flex items-center gap-1">
47+
<Link
48+
to={routes.pipelines.namespace(encodeURIComponent(row.original.name))}
49+
className="flex items-center gap-1"
50+
>
51+
<span className="text-text-md font-semibold text-theme-text-primary">
52+
{row.original.name}
53+
</span>
54+
</Link>
55+
<TooltipProvider>
56+
<Tooltip>
57+
<TooltipTrigger className="hover:text-theme-text-brand hover:underline">
58+
<ExecutionStatusIcon status={row.original.body?.latest_run_status} />
59+
</TooltipTrigger>
60+
<TooltipContent className="z-20 capitalize">
61+
{row.original.body?.latest_run_status}
62+
</TooltipContent>
63+
</Tooltip>
64+
</TooltipProvider>
65+
66+
<CopyButton copyText={row.original.name} />
67+
</div>
68+
<Link
69+
to={routes.pipelines.namespace(encodeURIComponent(row.original.name))}
70+
className="flex items-center gap-1"
71+
>
72+
<p className="text-text-xs text-theme-text-secondary">
73+
{row.original.id.split("-")[0]}
74+
</p>
75+
<CopyButton copyText={row.original.id} />
76+
</Link>
77+
</div>
78+
</div>
79+
);
80+
}
81+
},
82+
{
83+
id: "latest-run",
84+
header: "Latest Run",
85+
accessorFn: (row) => ({
86+
status: row.body?.latest_run_status,
87+
runId: row.body?.latest_run_id
88+
}),
89+
cell: ({ getValue }) => {
90+
const { runId, status } = getValue<{
91+
runId?: string;
92+
status?: ExecutionStatus;
93+
}>();
94+
95+
if (!runId || !status) return <div>No run</div>;
96+
97+
return (
98+
<Link to={routes.runs.detail(runId)}>
99+
<Tag
100+
emphasis="subtle"
101+
rounded={false}
102+
className="inline-flex items-center gap-0.5"
103+
color={getExecutionStatusTagColor(status)}
104+
>
105+
<RunIcon className={`h-3 w-3 fill-current`} />
106+
{runId?.split("-")[0]}
107+
</Tag>
108+
</Link>
109+
);
110+
}
111+
},
112+
{
113+
id: "admin_actions",
114+
header: "",
115+
meta: {
116+
width: "5%"
117+
},
118+
cell: ({ row }) => {
119+
return <PipelineDropdown id={row.original.id} />;
120+
}
121+
}
122+
];
123+
}
File renamed without changes.
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { DeleteRunAlert } from "./DeleteRunAlert";
2+
import { useRunsSelectorContext } from "./RunsSelectorContext";
3+
4+
export function RunsButtonGroup() {
5+
const { selectedRuns } = useRunsSelectorContext();
6+
return (
7+
<div className="flex items-center divide-x divide-theme-border-moderate overflow-hidden rounded-md border border-theme-border-moderate">
8+
<div className="bg-primary-25 px-2 py-1 font-semibold text-theme-text-brand">{`${selectedRuns?.length} Run${selectedRuns?.length > 1 ? "s" : ""} selected`}</div>
9+
<DeleteRunAlert />
10+
</div>
11+
);
12+
}

0 commit comments

Comments
 (0)