Repository: PrefectHQ/prefect
Pull request #17419: Feature: Work pools page
Author: login: znicholasbrown
Labels: name: ui-replatform
description: Related to the React UI rewrite
Created: 2025-03-07T21:06:18Z | Merged: 2025-04-03T14:47:49Z

This PR builds out the `/work-pools` page, adding all the basic functionality of searching, deleting, pausing/resuming, and navigating to the edit and create pages.

Closes: #ENG-1185

### Checklist
<!-- These boxes may be checked after opening the pull request. -->

- [x] This pull request references any related issue by including "closes `<link to issue>`"
  - If no issue exists and your change is not a small fix, please [create an issue](https://github.com/PrefectHQ/prefect/issues/new/choose) first.
- [x] If this pull request adds new functionality, it includes unit tests that cover the changes
- [ ] If this pull request removes docs files, it includes redirect settings in `mint.json`.
- [x] If this pull request adds functions or classes, it includes helpful docstrings.



Diff:
diff --git a/ui-v2/src/api/work-pools/index.ts b/ui-v2/src/api/work-pools/index.ts
index 84add3bf819f..b9dbae6da09c 100644
--- a/ui-v2/src/api/work-pools/index.ts
+++ b/ui-v2/src/api/work-pools/index.ts
@@ -5,4 +5,7 @@ export {
 	buildFilterWorkPoolsQuery,
 	buildCountWorkPoolsQuery,
 	buildWorkPoolDetailsQuery,
+	useDeleteWorkPool,
+	usePauseWorkPool,
+	useResumeWorkPool,
 } from "./work-pools";
diff --git a/ui-v2/src/api/work-pools/work-pools.test.ts b/ui-v2/src/api/work-pools/work-pools.test.ts
index 072021cdb2f6..bcf1c991e272 100644
--- a/ui-v2/src/api/work-pools/work-pools.test.ts
+++ b/ui-v2/src/api/work-pools/work-pools.test.ts
@@ -1,6 +1,6 @@
 import { createFakeWorkPool } from "@/mocks";
 import { QueryClient, useSuspenseQuery } from "@tanstack/react-query";
-import { renderHook, waitFor } from "@testing-library/react";
+import { act, renderHook, waitFor } from "@testing-library/react";
 import { buildApiUrl, createWrapper, server } from "@tests/utils";
 import { http, HttpResponse } from "msw";
 import { describe, expect, it } from "vitest";
@@ -9,6 +9,9 @@ import {
 	buildCountWorkPoolsQuery,
 	buildFilterWorkPoolsQuery,
 	buildWorkPoolDetailsQuery,
+	useDeleteWorkPool,
+	usePauseWorkPool,
+	useResumeWorkPool,
 } from "./work-pools";
 
 describe("work pools api", () => {
@@ -76,3 +79,88 @@ describe("work pools api", () => {
 		});
 	});
 });
+
+describe("work pool hooks", () => {
+	const MOCK_WORK_POOL_NAME = "test-pool";
+
+	/**
+	 * Data Management:
+	 * - Asserts pause mutation API is called
+	 * - Upon pause mutation API being called, cache is invalidated
+	 */
+	it("usePauseWorkPool() invalidates cache", async () => {
+		const queryClient = new QueryClient();
+
+		// ------------ Mock API requests
+		server.use(
+			http.patch(buildApiUrl(`/work_pools/${MOCK_WORK_POOL_NAME}`), () => {
+				return HttpResponse.json({});
+			}),
+		);
+
+		// ------------ Initialize hooks to test
+		const { result } = renderHook(usePauseWorkPool, {
+			wrapper: createWrapper({ queryClient }),
+		});
+
+		// ------------ Invoke mutation
+		act(() => result.current.pauseWorkPool(MOCK_WORK_POOL_NAME));
+
+		// ------------ Assert
+		await waitFor(() => expect(result.current.isSuccess).toBe(true));
+	});
+
+	/**
+	 * Data Management:
+	 * - Asserts resume mutation API is called
+	 * - Upon resume mutation API being called, cache is invalidated
+	 */
+	it("useResumeWorkPool() invalidates cache", async () => {
+		const queryClient = new QueryClient();
+
+		// ------------ Mock API requests
+		server.use(
+			http.patch(buildApiUrl(`/work_pools/${MOCK_WORK_POOL_NAME}`), () => {
+				return HttpResponse.json({});
+			}),
+		);
+
+		// ------------ Initialize hooks to test
+		const { result } = renderHook(useResumeWorkPool, {
+			wrapper: createWrapper({ queryClient }),
+		});
+
+		// ------------ Invoke mutation
+		act(() => result.current.resumeWorkPool(MOCK_WORK_POOL_NAME));
+
+		// ------------ Assert
+		await waitFor(() => expect(result.current.isSuccess).toBe(true));
+	});
+
+	/**
+	 * Data Management:
+	 * - Asserts delete mutation API is called
+	 * - Upon delete mutation API being called, cache is invalidated
+	 */
+	it("useDeleteWorkPool() invalidates cache", async () => {
+		const queryClient = new QueryClient();
+
+		// ------------ Mock API requests
+		server.use(
+			http.delete(buildApiUrl(`/work_pools/${MOCK_WORK_POOL_NAME}`), () => {
+				return HttpResponse.json({});
+			}),
+		);
+
+		// ------------ Initialize hooks to test
+		const { result } = renderHook(useDeleteWorkPool, {
+			wrapper: createWrapper({ queryClient }),
+		});
+
+		// ------------ Invoke mutation
+		act(() => result.current.deleteWorkPool(MOCK_WORK_POOL_NAME));
+
+		// ------------ Assert
+		await waitFor(() => expect(result.current.isSuccess).toBe(true));
+	});
+});
diff --git a/ui-v2/src/api/work-pools/work-pools.ts b/ui-v2/src/api/work-pools/work-pools.ts
index 5f30d370c570..806b3fdfa61d 100644
--- a/ui-v2/src/api/work-pools/work-pools.ts
+++ b/ui-v2/src/api/work-pools/work-pools.ts
@@ -1,6 +1,10 @@
 import type { components } from "@/api/prefect";
 import { getQueryService } from "@/api/service";
-import { queryOptions } from "@tanstack/react-query";
+import {
+	queryOptions,
+	useMutation,
+	useQueryClient,
+} from "@tanstack/react-query";
 
 export type WorkPool = components["schemas"]["WorkPool"];
 export type WorkPoolsFilter =
@@ -107,7 +111,7 @@ export const buildCountWorkPoolsQuery = (filter: WorkPoolsCountFilter = {}) =>
 				body: filter,
 			});
 
-			if (!res.data) {
+			if (!("data" in res)) {
 				throw new Error("'data' expected");
 			}
 
@@ -139,3 +143,91 @@ export const buildWorkPoolDetailsQuery = (name: string) =>
 			return res.data;
 		},
 	});
+
+/**
+ * Builds a query configuration for getting a work pool details
+ *
+ * @param name - Work pool name to get details of
+ * @returns Query configuration object for use with TanStack Query
+ */
+export const buildGetWorkPoolQuery = (name: string) =>
+	queryOptions({
+		queryKey: queryKeyFactory.detail(name),
+		queryFn: async () => {
+			const res = await getQueryService().GET("/work_pools/{name}", {
+				params: { path: { name } },
+			});
+			if (!res.data) {
+				throw new Error("'data' expected");
+			}
+			return res.data;
+		},
+	});
+
+/**
+ * Hook for pausing a work pool
+ * @returns Mutation for pausing a work pool
+ */
+export const usePauseWorkPool = () => {
+	const queryClient = useQueryClient();
+
+	const { mutate: pauseWorkPool, ...rest } = useMutation({
+		mutationFn: (name: string) =>
+			getQueryService().PATCH("/work_pools/{name}", {
+				params: { path: { name } },
+				body: {
+					is_paused: true,
+				},
+			}),
+		onSettled: () =>
+			queryClient.invalidateQueries({
+				queryKey: queryKeyFactory.all(),
+			}),
+	});
+	return { pauseWorkPool, ...rest };
+};
+
+/**
+ * Hook for resuming a work pool
+ * @returns Mutation for resuming a work pool
+ */
+export const useResumeWorkPool = () => {
+	const queryClient = useQueryClient();
+
+	const { mutate: resumeWorkPool, ...rest } = useMutation({
+		mutationFn: (name: string) =>
+			getQueryService().PATCH("/work_pools/{name}", {
+				params: { path: { name } },
+				body: {
+					is_paused: false,
+				},
+			}),
+		onSettled: () =>
+			queryClient.invalidateQueries({
+				queryKey: queryKeyFactory.all(),
+			}),
+	});
+
+	return { resumeWorkPool, ...rest };
+};
+
+/**
+ * Hook for deleting a work pool
+ * @returns Mutation for deleting a work pool
+ */
+export const useDeleteWorkPool = () => {
+	const queryClient = useQueryClient();
+
+	const { mutate: deleteWorkPool, ...rest } = useMutation({
+		mutationFn: (name: string) =>
+			getQueryService().DELETE("/work_pools/{name}", {
+				params: { path: { name } },
+			}),
+		onSettled: () =>
+			queryClient.invalidateQueries({
+				queryKey: queryKeyFactory.all(),
+			}),
+	});
+
+	return { deleteWorkPool, ...rest };
+};
diff --git a/ui-v2/src/components/ui/docs-link/constants.ts b/ui-v2/src/components/ui/docs-link/constants.ts
index acb2b156f89a..da71eeafa439 100644
--- a/ui-v2/src/components/ui/docs-link/constants.ts
+++ b/ui-v2/src/components/ui/docs-link/constants.ts
@@ -10,6 +10,8 @@ export const DOCS_LINKS = {
 	"task-concurrency-guide":
 		"https://docs.prefect.io/v3/develop/task-run-limits",
 	"variables-guide": "https://docs.prefect.io/latest/guides/variables/",
+	"work-pools-guide":
+		"https://docs.prefect.io/latest/deploy/infrastructure-concepts/work-pools",
 } as const;
 
 export type DocsID = keyof typeof DOCS_LINKS;
diff --git a/ui-v2/src/components/work-pools/empty-state.tsx b/ui-v2/src/components/work-pools/empty-state.tsx
new file mode 100644
index 000000000000..eb75bb3be194
--- /dev/null
+++ b/ui-v2/src/components/work-pools/empty-state.tsx
@@ -0,0 +1,30 @@
+import { Button } from "@/components/ui/button";
+import { DocsLink } from "@/components/ui/docs-link";
+import {
+	EmptyState,
+	EmptyStateActions,
+	EmptyStateDescription,
+	EmptyStateIcon,
+	EmptyStateTitle,
+} from "@/components/ui/empty-state";
+import { Icon } from "@/components/ui/icons";
+import { Link } from "@tanstack/react-router";
+
+export const WorkPoolsEmptyState = () => (
+	<EmptyState>
+		<EmptyStateIcon id="Cpu" />
+		<EmptyStateTitle>Add a work pool to get started</EmptyStateTitle>
+		<EmptyStateDescription>
+			Work pools allow you to prioritize and manage deployments and control the
+			infrastructure they run on.
+		</EmptyStateDescription>
+		<EmptyStateActions>
+			<Link to="/work-pools/create">
+				<Button>
+					Add Work Pool <Icon id="Plus" className="size-4 ml-2" />
+				</Button>
+			</Link>
+			<DocsLink id="work-pools-guide" />
+		</EmptyStateActions>
+	</EmptyState>
+);
diff --git a/ui-v2/src/components/work-pools/header.tsx b/ui-v2/src/components/work-pools/header.tsx
new file mode 100644
index 000000000000..0bb075195f23
--- /dev/null
+++ b/ui-v2/src/components/work-pools/header.tsx
@@ -0,0 +1,25 @@
+import {
+	Breadcrumb,
+	BreadcrumbItem,
+	BreadcrumbList,
+} from "@/components/ui/breadcrumb";
+import { Button } from "@/components/ui/button";
+import { Icon } from "@/components/ui/icons";
+import { Link } from "@tanstack/react-router";
+
+export const WorkPoolsPageHeader = () => (
+	<div className="flex items-center gap-2">
+		<Breadcrumb>
+			<BreadcrumbList>
+				<BreadcrumbItem className="text-xl font-semibold">
+					Work pools
+				</BreadcrumbItem>
+			</BreadcrumbList>
+		</Breadcrumb>
+		<Link to="/work-pools/create">
+			<Button size="icon" className="size-7" variant="outline">
+				<Icon id="Plus" className="size-4" />
+			</Button>
+		</Link>
+	</div>
+);
diff --git a/ui-v2/src/components/work-pools/work-pool-card/components/constants.ts b/ui-v2/src/components/work-pools/work-pool-card/components/constants.ts
new file mode 100644
index 000000000000..e69de29bb2d1
diff --git a/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-context-menu.test.tsx b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-context-menu.test.tsx
new file mode 100644
index 000000000000..d748adf9f54a
--- /dev/null
+++ b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-context-menu.test.tsx
@@ -0,0 +1,154 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+
+import { Toaster } from "@/components/ui/sonner";
+import { createFakeWorkPool } from "@/mocks/create-fake-work-pool";
+import { QueryClient } from "@tanstack/react-query";
+import { RouterProvider } from "@tanstack/react-router";
+import { createMemoryHistory } from "@tanstack/react-router";
+import { createRouter } from "@tanstack/react-router";
+import { createRootRoute } from "@tanstack/react-router";
+import userEvent from "@testing-library/user-event";
+import { createWrapper } from "@tests/utils";
+import { mockPointerEvents } from "@tests/utils/browser";
+import {
+	WorkPoolContextMenu,
+	WorkPoolContextMenuProps,
+} from "./work-pool-context-menu";
+
+describe("WorkPoolContextMenu", () => {
+	beforeAll(mockPointerEvents);
+
+	beforeEach(() => {
+		// Mocks away getRouteApi dependency in `useDeleteDeploymentConfirmationDialog`
+		// @ts-expect-error Ignoring error until @tanstack/react-router has better testing documentation. Ref: https://vitest.dev/api/vi.html#vi-mock
+		vi.mock(import("@tanstack/react-router"), async (importOriginal) => {
+			const mod = await importOriginal();
+			return {
+				...mod,
+				getRouteApi: () => ({
+					useNavigate: vi.fn,
+				}),
+			};
+		});
+	});
+
+	// Wraps component in test with a Tanstack router provider
+	const WorkPoolContextMenuRouter = (props: WorkPoolContextMenuProps) => {
+		const rootRoute = createRootRoute({
+			component: () => (
+				<>
+					<Toaster />
+					<WorkPoolContextMenu {...props} />
+				</>
+			),
+		});
+
+		const router = createRouter({
+			routeTree: rootRoute,
+			history: createMemoryHistory({
+				initialEntries: ["/"],
+			}),
+			context: { queryClient: new QueryClient() },
+		});
+		return <RouterProvider router={router} />;
+	};
+
+	const workPool = createFakeWorkPool({
+		id: "test-work-pool-id",
+		name: "test-work-pool",
+		status: "READY",
+	});
+
+	const mockOnDelete = vi.fn();
+
+	it("renders the dropdown menu with correct options", async () => {
+		render(
+			<WorkPoolContextMenuRouter workPool={workPool} onDelete={mockOnDelete} />,
+			{
+				wrapper: createWrapper(),
+			},
+		);
+
+		const user = userEvent.setup();
+
+		const dropdownButton = screen.getByRole("button", { name: /open menu/i });
+		await user.click(dropdownButton);
+
+		await waitFor(() => {
+			expect(screen.getByText("Actions")).toBeInTheDocument();
+			expect(screen.getByText("Copy ID")).toBeInTheDocument();
+			expect(screen.getByText("Edit")).toBeInTheDocument();
+			expect(screen.getByText("Delete")).toBeInTheDocument();
+		});
+	});
+
+	it("copies the work pool ID to clipboard when 'Copy ID' is clicked", async () => {
+		render(
+			<WorkPoolContextMenuRouter workPool={workPool} onDelete={mockOnDelete} />,
+			{
+				wrapper: createWrapper(),
+			},
+		);
+
+		const user = userEvent.setup();
+
+		const dropdownButton = screen.getByRole("button", { name: /open menu/i });
+		await user.click(dropdownButton);
+
+		const copyIdOption = await screen.findByText("Copy ID");
+		await user.click(copyIdOption);
+
+		//  Despite the mock above, typescript-eslint is complaining about the method being unbound cause of the ambiguous `this`
+		//  expect(navigator.clipboard.writeText).toHaveBeenCalledWith(workPool.id);
+
+		await waitFor(() => {
+			const toast = screen.getByText("ID copied");
+			expect(toast).toBeInTheDocument();
+		});
+	});
+
+	it("calls the onDelete function when 'Delete' is clicked", async () => {
+		render(
+			<WorkPoolContextMenuRouter workPool={workPool} onDelete={mockOnDelete} />,
+			{
+				wrapper: createWrapper(),
+			},
+		);
+
+		const user = userEvent.setup();
+
+		const dropdownButton = screen.getByRole("button", { name: /open menu/i });
+		await user.click(dropdownButton);
+
+		const deleteOption = await screen.findByText("Delete");
+		fireEvent.click(deleteOption);
+
+		expect(mockOnDelete).toHaveBeenCalledTimes(1);
+	});
+
+	it("closes the menu when an option is selected", async () => {
+		render(
+			<WorkPoolContextMenuRouter workPool={workPool} onDelete={mockOnDelete} />,
+			{
+				wrapper: createWrapper(),
+			},
+		);
+
+		const user = userEvent.setup();
+
+		const dropdownButton = screen.getByRole("button", { name: /open menu/i });
+		await user.click(dropdownButton);
+
+		await waitFor(() => {
+			expect(screen.getByText("Actions")).toBeInTheDocument();
+		});
+
+		const copyIdOption = await screen.findByText("Copy ID");
+		await user.click(copyIdOption);
+
+		await waitFor(() => {
+			expect(screen.queryByText("Actions")).toBeNull();
+		});
+	});
+});
diff --git a/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-context-menu.tsx b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-context-menu.tsx
new file mode 100644
index 000000000000..7c922d0cf069
--- /dev/null
+++ b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-context-menu.tsx
@@ -0,0 +1,52 @@
+import { WorkPool } from "@/api/work-pools";
+import { Button } from "@/components/ui/button";
+import {
+	DropdownMenuContent,
+	DropdownMenuItem,
+	DropdownMenuLabel,
+	DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu";
+
+import { DropdownMenu } from "@/components/ui/dropdown-menu";
+import { Icon } from "@/components/ui/icons";
+import { Link } from "@tanstack/react-router";
+import { toast } from "sonner";
+
+export type WorkPoolContextMenuProps = {
+	workPool: WorkPool;
+	onDelete: () => void;
+};
+
+export const WorkPoolContextMenu = ({
+	workPool,
+	onDelete,
+}: WorkPoolContextMenuProps) => {
+	const handleCopyId = (id: string) => {
+		void navigator.clipboard.writeText(id);
+		toast.success("ID copied");
+	};
+
+	return (
+		<DropdownMenu>
+			<DropdownMenuTrigger asChild>
+				<Button variant="outline" className="size-8 p-0">
+					<span className="sr-only">Open menu</span>
+					<Icon id="MoreVertical" className="size-4" />
+				</Button>
+			</DropdownMenuTrigger>
+			<DropdownMenuContent align="end">
+				<DropdownMenuLabel>Actions</DropdownMenuLabel>
+				<DropdownMenuItem onClick={() => handleCopyId(workPool.id)}>
+					Copy ID
+				</DropdownMenuItem>
+				<Link
+					to={`/work-pools/work-pool/$workPoolName/edit`}
+					params={{ workPoolName: workPool.name }}
+				>
+					<DropdownMenuItem>Edit</DropdownMenuItem>
+				</Link>
+				<DropdownMenuItem onClick={onDelete}>Delete</DropdownMenuItem>
+			</DropdownMenuContent>
+		</DropdownMenu>
+	);
+};
diff --git a/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-name.tsx b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-name.tsx
new file mode 100644
index 000000000000..32866056deb0
--- /dev/null
+++ b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-name.tsx
@@ -0,0 +1,17 @@
+import { Link } from "@tanstack/react-router";
+
+type WorkPoolNameProps = {
+	workPoolName: string;
+};
+
+export const WorkPoolName = ({ workPoolName }: WorkPoolNameProps) => {
+	return (
+		<Link
+			to="/work-pools/work-pool/$workPoolName"
+			params={{ workPoolName }}
+			className="flex items-center gap-1"
+		>
+			{workPoolName}
+		</Link>
+	);
+};
diff --git a/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-pause-resume-toggle.test.tsx b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-pause-resume-toggle.test.tsx
new file mode 100644
index 000000000000..5bf92222c385
--- /dev/null
+++ b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-pause-resume-toggle.test.tsx
@@ -0,0 +1,259 @@
+import { Toaster } from "@/components/ui/sonner";
+import { createFakeWorkPool } from "@/mocks/create-fake-work-pool";
+import { render, screen, waitFor } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { buildApiUrl, createWrapper, server } from "@tests/utils";
+import { http, HttpResponse } from "msw";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import { WorkPoolPauseResumeToggle } from "./work-pool-pause-resume-toggle";
+
+describe("WorkPoolPauseResumeToggle", () => {
+	const activeWorkPool = createFakeWorkPool({
+		name: "active-work-pool",
+		status: "READY",
+		is_paused: false,
+	});
+
+	const pausedWorkPool = createFakeWorkPool({
+		name: "paused-work-pool",
+		status: "PAUSED",
+		is_paused: true,
+	});
+
+	beforeEach(() => {
+		vi.clearAllMocks();
+	});
+
+	it("renders the active state correctly", () => {
+		render(
+			<>
+				<Toaster />
+				<WorkPoolPauseResumeToggle workPool={activeWorkPool} />
+			</>,
+			{
+				wrapper: createWrapper(),
+			},
+		);
+
+		expect(screen.getByText("Active")).toBeInTheDocument();
+		expect(screen.getByRole("switch")).toBeChecked();
+	});
+
+	it("renders the paused state correctly", () => {
+		render(
+			<>
+				<Toaster />
+				<WorkPoolPauseResumeToggle workPool={pausedWorkPool} />
+			</>,
+			{
+				wrapper: createWrapper(),
+			},
+		);
+
+		expect(screen.getByText("Paused")).toBeInTheDocument();
+		expect(screen.getByRole("switch")).not.toBeChecked();
+	});
+
+	it("disables the switch while the API request is pending", async () => {
+		server.use(
+			http.patch(
+				buildApiUrl(`/work_pools/${activeWorkPool.name}`),
+				async () => {
+					await new Promise((resolve) => setTimeout(resolve, 500));
+					return HttpResponse.json({});
+				},
+			),
+		);
+
+		render(
+			<>
+				<Toaster />
+				<WorkPoolPauseResumeToggle workPool={activeWorkPool} />
+			</>,
+			{
+				wrapper: createWrapper(),
+			},
+		);
+
+		const user = userEvent.setup();
+
+		const toggle = screen.getByRole("switch", { name: /pause work pool/i });
+
+		await user.click(toggle);
+
+		expect(toggle).toBeDisabled();
+
+		await waitFor(() => {
+			expect(
+				screen.getByText(`${activeWorkPool.name} paused`),
+			).toBeInTheDocument();
+		});
+
+		expect(toggle).toBeEnabled();
+	});
+
+	it("shows an error toast when the API call fails", async () => {
+		const errorMessage = "Failed to update work pool status";
+
+		server.use(
+			http.patch(buildApiUrl(`/work_pools/${activeWorkPool.name}`), () => {
+				return HttpResponse.json({ detail: errorMessage }, { status: 500 });
+			}),
+		);
+
+		render(
+			<>
+				<Toaster />
+				<WorkPoolPauseResumeToggle workPool={activeWorkPool} />
+			</>,
+			{
+				wrapper: createWrapper(),
+			},
+		);
+
+		const user = userEvent.setup();
+
+		await user.click(screen.getByRole("switch"));
+
+		await waitFor(() => {
+			expect(
+				screen.getByText(`Failed to pause ${activeWorkPool.name}`),
+			).toBeInTheDocument();
+		});
+	});
+
+	it("updates the UI state after successful API call to pause", async () => {
+		server.use(
+			http.patch(buildApiUrl(`/work_pools/${activeWorkPool.name}`), () => {
+				return HttpResponse.json({});
+			}),
+		);
+
+		render(
+			<>
+				<Toaster />
+				<WorkPoolPauseResumeToggle workPool={activeWorkPool} />
+			</>,
+			{
+				wrapper: createWrapper(),
+			},
+		);
+
+		const user = userEvent.setup();
+
+		// Initially should show Active
+		expect(screen.getByText("Active")).toBeInTheDocument();
+
+		// Toggle to pause
+		await user.click(screen.getByRole("switch"));
+
+		// After successful API call, should show Paused and success toast
+		await waitFor(() => {
+			expect(screen.getByText("Paused")).toBeInTheDocument();
+			expect(
+				screen.getByText(`${activeWorkPool.name} paused`),
+			).toBeInTheDocument();
+		});
+	});
+
+	it("updates the UI state after successful API call to resume", async () => {
+		server.use(
+			http.patch(buildApiUrl(`/work_pools/${pausedWorkPool.name}`), () => {
+				return HttpResponse.json({});
+			}),
+		);
+
+		render(
+			<>
+				<Toaster />
+				<WorkPoolPauseResumeToggle workPool={pausedWorkPool} />
+			</>,
+			{
+				wrapper: createWrapper(),
+			},
+		);
+
+		// Initially should show Paused
+		expect(screen.getByText("Paused")).toBeInTheDocument();
+
+		// Toggle to resume
+		const user = userEvent.setup();
+		await user.click(screen.getByRole("switch"));
+
+		// After successful API call, should show Active and success toast
+		await waitFor(() => {
+			expect(screen.getByText("Active")).toBeInTheDocument();
+			expect(
+				screen.getByText(`${pausedWorkPool.name} resumed`),
+			).toBeInTheDocument();
+		});
+	});
+
+	it("shows error toast when resume operation fails", async () => {
+		server.use(
+			http.patch(buildApiUrl(`/work_pools/${pausedWorkPool.name}`), () => {
+				return HttpResponse.json(
+					{ detail: "Failed to resume" },
+					{ status: 500 },
+				);
+			}),
+		);
+
+		render(
+			<>
+				<Toaster />
+				<WorkPoolPauseResumeToggle workPool={pausedWorkPool} />
+			</>,
+			{
+				wrapper: createWrapper(),
+			},
+		);
+
+		const user = userEvent.setup();
+
+		await user.click(screen.getByRole("switch"));
+
+		await waitFor(() => {
+			expect(
+				screen.getByText(`Failed to resume ${pausedWorkPool.name}`),
+			).toBeInTheDocument();
+		});
+	});
+
+	it("sets the correct aria-label when the work pool is active", () => {
+		expect(activeWorkPool.is_paused).toBe(false);
+
+		render(
+			<>
+				<Toaster />
+				<WorkPoolPauseResumeToggle workPool={activeWorkPool} />
+			</>,
+			{ wrapper: createWrapper() },
+		);
+
+		// When active, the aria-label should be to pause
+		expect(screen.getByRole("switch")).toHaveAttribute(
+			"aria-label",
+			"Pause work pool",
+		);
+	});
+
+	it("sets the correct aria-label when the work pool is paused", () => {
+		expect(pausedWorkPool.is_paused).toBe(true);
+
+		// Rerender with paused work pool
+		render(
+			<>
+				<Toaster />
+				<WorkPoolPauseResumeToggle workPool={pausedWorkPool} />
+			</>,
+			{ wrapper: createWrapper() },
+		);
+
+		// When paused, the aria-label should be to resume
+		expect(screen.getByRole("switch")).toHaveAttribute(
+			"aria-label",
+			"Resume work pool",
+		);
+	});
+});
diff --git a/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-pause-resume-toggle.tsx b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-pause-resume-toggle.tsx
new file mode 100644
index 000000000000..46eaeb17e294
--- /dev/null
+++ b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-pause-resume-toggle.tsx
@@ -0,0 +1,59 @@
+import { WorkPool } from "@/api/work-pools";
+import { usePauseWorkPool, useResumeWorkPool } from "@/api/work-pools";
+import { Switch } from "@/components/ui/switch";
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+export type WorkPoolPauseResumeToggleParams = {
+	workPool: WorkPool;
+};
+
+export const WorkPoolPauseResumeToggle = ({
+	workPool,
+}: WorkPoolPauseResumeToggleParams) => {
+	const [isPaused, setIsPaused] = useState(workPool.status === "PAUSED");
+
+	const { pauseWorkPool, isPending: isPausing } = usePauseWorkPool();
+	const { resumeWorkPool, isPending: isResuming } = useResumeWorkPool();
+
+	const disabled = useMemo(() => {
+		return isPausing || isResuming;
+	}, [isPausing, isResuming]);
+
+	const handleTogglePause = () => {
+		if (isPaused) {
+			resumeWorkPool(workPool.name, {
+				onSuccess: () => {
+					setIsPaused(false);
+					toast.success(`${workPool.name} resumed`);
+				},
+				onError: () => {
+					toast.error(`Failed to resume ${workPool.name}`);
+				},
+			});
+		} else {
+			pauseWorkPool(workPool.name, {
+				onSuccess: () => {
+					setIsPaused(true);
+					toast.success(`${workPool.name} paused`);
+				},
+				onError: () => {
+					toast.error(`Failed to pause ${workPool.name}`);
+				},
+			});
+		}
+	};
+
+	return (
+		<span className="flex items-center gap-2">
+			<span className="text-sm text-muted-foreground">
+				{isPaused ? "Paused" : "Active"}
+			</span>
+			<Switch
+				checked={!isPaused}
+				onCheckedChange={handleTogglePause}
+				disabled={disabled}
+				aria-label={isPaused ? "Resume work pool" : "Pause work pool"}
+			/>
+		</span>
+	);
+};
diff --git a/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-type-badge.stories.tsx b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-type-badge.stories.tsx
new file mode 100644
index 000000000000..1a8be4d02096
--- /dev/null
+++ b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-type-badge.stories.tsx
@@ -0,0 +1,75 @@
+import { Meta, StoryObj } from "@storybook/react";
+import { WorkPoolTypeBadge } from "./work-pool-type-badge";
+
+const meta: Meta<typeof WorkPoolTypeBadge> = {
+	title: "Components/WorkPools/WorkPoolTypeBadge",
+	component: WorkPoolTypeBadge,
+	argTypes: {
+		type: {
+			control: "select",
+			options: [
+				"kubernetes",
+				"process",
+				"ecs",
+				"azure-container-instance",
+				"docker",
+				"cloud-run",
+				"cloud-run-v2",
+				"vertex-ai",
+				"kubernetes",
+			],
+			description: "The type of work pool to display a badge for",
+		},
+	},
+};
+
+export default meta;
+type Story = StoryObj<typeof WorkPoolTypeBadge>;
+
+export const Kubernetes: Story = {
+	args: {
+		type: "kubernetes",
+	},
+};
+
+export const Process: Story = {
+	args: {
+		type: "process",
+	},
+};
+
+export const Docker: Story = {
+	args: {
+		type: "docker",
+	},
+};
+
+export const VertexAI: Story = {
+	args: {
+		type: "vertex-ai",
+	},
+};
+
+export const ECS: Story = {
+	args: {
+		type: "ecs",
+	},
+};
+
+export const AzureContainerInstance: Story = {
+	args: {
+		type: "azure-container-instance",
+	},
+};
+
+export const CloudRun: Story = {
+	args: {
+		type: "cloud-run",
+	},
+};
+
+export const CloudRunV2: Story = {
+	args: {
+		type: "cloud-run-v2",
+	},
+};
diff --git a/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-type-badge.tsx b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-type-badge.tsx
new file mode 100644
index 000000000000..8cb70c558811
--- /dev/null
+++ b/ui-v2/src/components/work-pools/work-pool-card/components/work-pool-type-badge.tsx
@@ -0,0 +1,46 @@
+import { WorkPool } from "@/api/work-pools";
+import { Badge } from "@/components/ui/badge";
+import { Icon, IconId } from "@/components/ui/icons";
+
+type WorkPoolTypeBadgeProps = {
+	type: WorkPool["type"];
+};
+
+const WORK_POOL_TYPE_LABELS: Record<WorkPool["type"], string> = {
+	process: "Process",
+	ecs: "ECS",
+	"azure-container-instance": "Azure Container Instance",
+	docker: "Docker",
+	"cloud-run": "Cloud Run",
+	"cloud-run-v2": "Cloud Run v2",
+	"vertex-ai": "Vertex AI",
+	kubernetes: "Kubernetes",
+} as const;
+
+const WORK_POOL_TYPE_ICONS: Record<WorkPool["type"], IconId> = {
+	process: "Cpu",
+	ecs: "Cpu",
+	"azure-container-instance": "Cpu",
+	docker: "Cpu",
+	"cloud-run": "Cpu",
+	"cloud-run-v2": "Cpu",
+	"vertex-ai": "Cpu",
+	kubernetes: "Cpu",
+} as const;
+
+const getWorkPoolTypeLabel = (type: WorkPool["type"]) => {
+	return WORK_POOL_TYPE_LABELS[type] ?? type;
+};
+
+const getWorkPoolTypeIcon = (type: WorkPool["type"]) => {
+	return WORK_POOL_TYPE_ICONS[type] ?? "Cpu";
+};
+
+export const WorkPoolTypeBadge = ({ type }: WorkPoolTypeBadgeProps) => {
+	return (
+		<Badge>
+			<Icon id={getWorkPoolTypeIcon(type)} />
+			{getWorkPoolTypeLabel(type)}
+		</Badge>
+	);
+};
diff --git a/ui-v2/src/components/work-pools/work-pool-card/work-pool-card.stories.tsx b/ui-v2/src/components/work-pools/work-pool-card/work-pool-card.stories.tsx
new file mode 100644
index 000000000000..17e18a81ef07
--- /dev/null
+++ b/ui-v2/src/components/work-pools/work-pool-card/work-pool-card.stories.tsx
@@ -0,0 +1,101 @@
+import { createFakeWorkPool } from "@/mocks/create-fake-work-pool";
+import { reactQueryDecorator, routerDecorator } from "@/storybook/utils";
+import type { Meta, StoryObj } from "@storybook/react";
+import { WorkPoolCard } from "./work-pool-card";
+
+const meta: Meta<typeof WorkPoolCard> = {
+	title: "Components/WorkPools/Card",
+	component: WorkPoolCard,
+	decorators: [routerDecorator, reactQueryDecorator],
+	parameters: {},
+};
+
+export default meta;
+type Story = StoryObj<typeof WorkPoolCard>;
+
+export const Default: Story = {
+	args: {
+		workPool: createFakeWorkPool(),
+	},
+};
+
+export const UnlimitedConcurrency: Story = {
+	args: {
+		workPool: {
+			...createFakeWorkPool(),
+			concurrency_limit: null,
+		},
+	},
+};
+
+export const Kubernetes: Story = {
+	args: {
+		workPool: {
+			...createFakeWorkPool(),
+			type: "kubernetes",
+		},
+	},
+};
+
+export const Process: Story = {
+	args: {
+		workPool: {
+			...createFakeWorkPool(),
+			type: "process",
+		},
+	},
+};
+
+export const Docker: Story = {
+	args: {
+		workPool: {
+			...createFakeWorkPool(),
+			type: "docker",
+		},
+	},
+};
+
+export const VertexAI: Story = {
+	args: {
+		workPool: {
+			...createFakeWorkPool(),
+			type: "vertex-ai",
+		},
+	},
+};
+
+export const ECS: Story = {
+	args: {
+		workPool: {
+			...createFakeWorkPool(),
+			type: "ecs",
+		},
+	},
+};
+
+export const AzureContainerInstance: Story = {
+	args: {
+		workPool: {
+			...createFakeWorkPool(),
+			type: "azure-container-instance",
+		},
+	},
+};
+
+export const CloudRun: Story = {
+	args: {
+		workPool: {
+			...createFakeWorkPool(),
+			type: "cloud-run",
+		},
+	},
+};
+
+export const CloudRunV2: Story = {
+	args: {
+		workPool: {
+			...createFakeWorkPool(),
+			type: "cloud-run-v2",
+		},
+	},
+};
diff --git a/ui-v2/src/components/work-pools/work-pool-card/work-pool-card.tsx b/ui-v2/src/components/work-pools/work-pool-card/work-pool-card.tsx
new file mode 100644
index 000000000000..73f358e721db
--- /dev/null
+++ b/ui-v2/src/components/work-pools/work-pool-card/work-pool-card.tsx
@@ -0,0 +1,57 @@
+import { WorkPool } from "@/api/work-pools";
+import { useDeleteWorkPool } from "@/api/work-pools";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { StatusBadge } from "@/components/ui/status-badge";
+import { toast } from "sonner";
+import { WorkPoolContextMenu } from "./components/work-pool-context-menu";
+import { WorkPoolName } from "./components/work-pool-name";
+import { WorkPoolPauseResumeToggle } from "./components/work-pool-pause-resume-toggle";
+import { WorkPoolTypeBadge } from "./components/work-pool-type-badge";
+
+type WorkPoolCardProps = {
+	workPool: WorkPool;
+};
+
+export const WorkPoolCard = ({ workPool }: WorkPoolCardProps) => {
+	const { deleteWorkPool } = useDeleteWorkPool();
+
+	const handleDelete = () => {
+		deleteWorkPool(workPool.name, {
+			onSuccess: () => {
+				toast.success(`${workPool.name} deleted`);
+			},
+			onError: (error) => {
+				toast.error(
+					`Failed to delete work pool: ${error instanceof Error ? error.message : "Unknown error"}`,
+				);
+			},
+		});
+	};
+	return (
+		<Card className="gap-2">
+			<CardHeader className="flex flex-row items-center justify-between">
+				<CardTitle className="flex items-center gap-2">
+					<WorkPoolName workPoolName={workPool.name} />
+					{workPool.status && <StatusBadge status={workPool.status} />}
+				</CardTitle>
+				<div className="flex items-center gap-2">
+					<WorkPoolPauseResumeToggle workPool={workPool} />
+					<WorkPoolContextMenu workPool={workPool} onDelete={handleDelete} />
+				</div>
+			</CardHeader>
+			<CardContent className="flex flex-col gap-1">
+				<div className="flex items-center gap-1">
+					<WorkPoolTypeBadge type={workPool.type} />
+				</div>
+				<div className="text-sm text-muted-foreground">
+					Concurrency:{" "}
+					<span className="text-foreground">
+						{workPool.concurrency_limit
+							? workPool.concurrency_limit
+							: "Unlimited"}
+					</span>
+				</div>
+			</CardContent>
+		</Card>
+	);
+};
diff --git a/ui-v2/src/mocks/create-fake-work-pool-type.ts b/ui-v2/src/mocks/create-fake-work-pool-type.ts
new file mode 100644
index 000000000000..efb5992b03a8
--- /dev/null
+++ b/ui-v2/src/mocks/create-fake-work-pool-type.ts
@@ -0,0 +1,16 @@
+import { rand } from "@ngneat/falso";
+
+const WORK_POOL_TYPES = [
+	"kubernetes",
+	"process",
+	"docker",
+	"vertex-ai",
+	"ecs",
+	"azure-container-instance",
+	"cloud-run",
+	"cloud-run-v2",
+] as const;
+
+export const createFakeWorkPoolType = () => {
+	return rand(WORK_POOL_TYPES);
+};
diff --git a/ui-v2/src/mocks/create-fake-work-pool.ts b/ui-v2/src/mocks/create-fake-work-pool.ts
index 83aa4466c3ed..0e05a519c125 100644
--- a/ui-v2/src/mocks/create-fake-work-pool.ts
+++ b/ui-v2/src/mocks/create-fake-work-pool.ts
@@ -8,6 +8,7 @@ import {
 	randProductName,
 	randUuid,
 } from "@ngneat/falso";
+import { createFakeWorkPoolType } from "./create-fake-work-pool-type";
 const STATUS_TYPE_VALUES = ["READY", "NOT_READY", "PAUSED", null] as const;
 
 export const createFakeWorkPool = (
@@ -24,7 +25,7 @@ export const createFakeWorkPool = (
 		default_queue_id: randUuid(),
 		is_paused: randBoolean(),
 		status: rand(STATUS_TYPE_VALUES),
-		type: "process",
+		type: createFakeWorkPoolType(),
 		...overrides,
 	};
 };
diff --git a/ui-v2/src/routeTree.gen.ts b/ui-v2/src/routeTree.gen.ts
index de0b0a13bdfd..7bb3c196a4b9 100644
--- a/ui-v2/src/routeTree.gen.ts
+++ b/ui-v2/src/routeTree.gen.ts
@@ -24,6 +24,7 @@ import { Route as ConcurrencyLimitsIndexImport } from './routes/concurrency-limi
 import { Route as BlocksIndexImport } from './routes/blocks/index'
 import { Route as AutomationsIndexImport } from './routes/automations/index'
 import { Route as ArtifactsIndexImport } from './routes/artifacts/index'
+import { Route as WorkPoolsCreateImport } from './routes/work-pools/create'
 import { Route as BlocksCatalogImport } from './routes/blocks/catalog'
 import { Route as AutomationsCreateImport } from './routes/automations/create'
 import { Route as WorkPoolsWorkPoolWorkPoolNameImport } from './routes/work-pools/work-pool.$workPoolName'
@@ -37,6 +38,7 @@ import { Route as BlocksBlockIdImport } from './routes/blocks/block.$id'
 import { Route as AutomationsAutomationIdImport } from './routes/automations/automation.$id'
 import { Route as ArtifactsKeyKeyImport } from './routes/artifacts/key.$key'
 import { Route as ArtifactsArtifactIdImport } from './routes/artifacts/artifact.$id'
+import { Route as WorkPoolsWorkPoolWorkPoolNameEditImport } from './routes/work-pools/work-pool_.$workPoolName.edit'
 import { Route as DeploymentsDeploymentIdRunImport } from './routes/deployments/deployment_.$id.run'
 import { Route as DeploymentsDeploymentIdEditImport } from './routes/deployments/deployment_.$id.edit'
 import { Route as DeploymentsDeploymentIdDuplicateImport } from './routes/deployments/deployment_.$id.duplicate'
@@ -123,6 +125,12 @@ const ArtifactsIndexRoute = ArtifactsIndexImport.update({
   getParentRoute: () => rootRoute,
 } as any)
 
+const WorkPoolsCreateRoute = WorkPoolsCreateImport.update({
+  id: '/work-pools/create',
+  path: '/work-pools/create',
+  getParentRoute: () => rootRoute,
+} as any)
+
 const BlocksCatalogRoute = BlocksCatalogImport.update({
   id: '/blocks/catalog',
   path: '/blocks/catalog',
@@ -203,6 +211,13 @@ const ArtifactsArtifactIdRoute = ArtifactsArtifactIdImport.update({
   getParentRoute: () => rootRoute,
 } as any)
 
+const WorkPoolsWorkPoolWorkPoolNameEditRoute =
+  WorkPoolsWorkPoolWorkPoolNameEditImport.update({
+    id: '/work-pools/work-pool_/$workPoolName/edit',
+    path: '/work-pools/work-pool/$workPoolName/edit',
+    getParentRoute: () => rootRoute,
+  } as any)
+
 const DeploymentsDeploymentIdRunRoute = DeploymentsDeploymentIdRunImport.update(
   {
     id: '/deployments/deployment_/$id/run',
@@ -292,6 +307,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof BlocksCatalogImport
       parentRoute: typeof rootRoute
     }
+    '/work-pools/create': {
+      id: '/work-pools/create'
+      path: '/work-pools/create'
+      fullPath: '/work-pools/create'
+      preLoaderRoute: typeof WorkPoolsCreateImport
+      parentRoute: typeof rootRoute
+    }
     '/artifacts/': {
       id: '/artifacts/'
       path: '/artifacts'
@@ -453,6 +475,13 @@ declare module '@tanstack/react-router' {
       preLoaderRoute: typeof DeploymentsDeploymentIdRunImport
       parentRoute: typeof rootRoute
     }
+    '/work-pools/work-pool_/$workPoolName/edit': {
+      id: '/work-pools/work-pool_/$workPoolName/edit'
+      path: '/work-pools/work-pool/$workPoolName/edit'
+      fullPath: '/work-pools/work-pool/$workPoolName/edit'
+      preLoaderRoute: typeof WorkPoolsWorkPoolWorkPoolNameEditImport
+      parentRoute: typeof rootRoute
+    }
     '/work-pools/work-pool/$workPoolName/queue/$workQueueName': {
       id: '/work-pools/work-pool/$workPoolName/queue/$workQueueName'
       path: '/queue/$workQueueName'
@@ -514,6 +543,7 @@ export interface FileRoutesByFullPath {
   '/variables': typeof VariablesRoute
   '/automations/create': typeof AutomationsCreateRoute
   '/blocks/catalog': typeof BlocksCatalogRouteWithChildren
+  '/work-pools/create': typeof WorkPoolsCreateRoute
   '/artifacts': typeof ArtifactsIndexRoute
   '/automations': typeof AutomationsIndexRoute
   '/blocks': typeof BlocksIndexRoute
@@ -537,6 +567,7 @@ export interface FileRoutesByFullPath {
   '/deployments/deployment/$id/duplicate': typeof DeploymentsDeploymentIdDuplicateRoute
   '/deployments/deployment/$id/edit': typeof DeploymentsDeploymentIdEditRoute
   '/deployments/deployment/$id/run': typeof DeploymentsDeploymentIdRunRoute
+  '/work-pools/work-pool/$workPoolName/edit': typeof WorkPoolsWorkPoolWorkPoolNameEditRoute
   '/work-pools/work-pool/$workPoolName/queue/$workQueueName': typeof WorkPoolsWorkPoolWorkPoolNameQueueWorkQueueNameRoute
 }
 
@@ -548,6 +579,7 @@ export interface FileRoutesByTo {
   '/variables': typeof VariablesRoute
   '/automations/create': typeof AutomationsCreateRoute
   '/blocks/catalog': typeof BlocksCatalogRouteWithChildren
+  '/work-pools/create': typeof WorkPoolsCreateRoute
   '/artifacts': typeof ArtifactsIndexRoute
   '/automations': typeof AutomationsIndexRoute
   '/blocks': typeof BlocksIndexRoute
@@ -571,6 +603,7 @@ export interface FileRoutesByTo {
   '/deployments/deployment/$id/duplicate': typeof DeploymentsDeploymentIdDuplicateRoute
   '/deployments/deployment/$id/edit': typeof DeploymentsDeploymentIdEditRoute
   '/deployments/deployment/$id/run': typeof DeploymentsDeploymentIdRunRoute
+  '/work-pools/work-pool/$workPoolName/edit': typeof WorkPoolsWorkPoolWorkPoolNameEditRoute
   '/work-pools/work-pool/$workPoolName/queue/$workQueueName': typeof WorkPoolsWorkPoolWorkPoolNameQueueWorkQueueNameRoute
 }
 
@@ -583,6 +616,7 @@ export interface FileRoutesById {
   '/variables': typeof VariablesRoute
   '/automations/create': typeof AutomationsCreateRoute
   '/blocks/catalog': typeof BlocksCatalogRouteWithChildren
+  '/work-pools/create': typeof WorkPoolsCreateRoute
   '/artifacts/': typeof ArtifactsIndexRoute
   '/automations/': typeof AutomationsIndexRoute
   '/blocks/': typeof BlocksIndexRoute
@@ -606,6 +640,7 @@ export interface FileRoutesById {
   '/deployments/deployment_/$id/duplicate': typeof DeploymentsDeploymentIdDuplicateRoute
   '/deployments/deployment_/$id/edit': typeof DeploymentsDeploymentIdEditRoute
   '/deployments/deployment_/$id/run': typeof DeploymentsDeploymentIdRunRoute
+  '/work-pools/work-pool_/$workPoolName/edit': typeof WorkPoolsWorkPoolWorkPoolNameEditRoute
   '/work-pools/work-pool/$workPoolName/queue/$workQueueName': typeof WorkPoolsWorkPoolWorkPoolNameQueueWorkQueueNameRoute
 }
 
@@ -619,6 +654,7 @@ export interface FileRouteTypes {
     | '/variables'
     | '/automations/create'
     | '/blocks/catalog'
+    | '/work-pools/create'
     | '/artifacts'
     | '/automations'
     | '/blocks'
@@ -642,6 +678,7 @@ export interface FileRouteTypes {
     | '/deployments/deployment/$id/duplicate'
     | '/deployments/deployment/$id/edit'
     | '/deployments/deployment/$id/run'
+    | '/work-pools/work-pool/$workPoolName/edit'
     | '/work-pools/work-pool/$workPoolName/queue/$workQueueName'
   fileRoutesByTo: FileRoutesByTo
   to:
@@ -652,6 +689,7 @@ export interface FileRouteTypes {
     | '/variables'
     | '/automations/create'
     | '/blocks/catalog'
+    | '/work-pools/create'
     | '/artifacts'
     | '/automations'
     | '/blocks'
@@ -675,6 +713,7 @@ export interface FileRouteTypes {
     | '/deployments/deployment/$id/duplicate'
     | '/deployments/deployment/$id/edit'
     | '/deployments/deployment/$id/run'
+    | '/work-pools/work-pool/$workPoolName/edit'
     | '/work-pools/work-pool/$workPoolName/queue/$workQueueName'
   id:
     | '__root__'
@@ -685,6 +724,7 @@ export interface FileRouteTypes {
     | '/variables'
     | '/automations/create'
     | '/blocks/catalog'
+    | '/work-pools/create'
     | '/artifacts/'
     | '/automations/'
     | '/blocks/'
@@ -708,6 +748,7 @@ export interface FileRouteTypes {
     | '/deployments/deployment_/$id/duplicate'
     | '/deployments/deployment_/$id/edit'
     | '/deployments/deployment_/$id/run'
+    | '/work-pools/work-pool_/$workPoolName/edit'
     | '/work-pools/work-pool/$workPoolName/queue/$workQueueName'
   fileRoutesById: FileRoutesById
 }
@@ -720,6 +761,7 @@ export interface RootRouteChildren {
   VariablesRoute: typeof VariablesRoute
   AutomationsCreateRoute: typeof AutomationsCreateRoute
   BlocksCatalogRoute: typeof BlocksCatalogRouteWithChildren
+  WorkPoolsCreateRoute: typeof WorkPoolsCreateRoute
   ArtifactsIndexRoute: typeof ArtifactsIndexRoute
   AutomationsIndexRoute: typeof AutomationsIndexRoute
   BlocksIndexRoute: typeof BlocksIndexRoute
@@ -741,6 +783,7 @@ export interface RootRouteChildren {
   DeploymentsDeploymentIdDuplicateRoute: typeof DeploymentsDeploymentIdDuplicateRoute
   DeploymentsDeploymentIdEditRoute: typeof DeploymentsDeploymentIdEditRoute
   DeploymentsDeploymentIdRunRoute: typeof DeploymentsDeploymentIdRunRoute
+  WorkPoolsWorkPoolWorkPoolNameEditRoute: typeof WorkPoolsWorkPoolWorkPoolNameEditRoute
 }
 
 const rootRouteChildren: RootRouteChildren = {
@@ -751,6 +794,7 @@ const rootRouteChildren: RootRouteChildren = {
   VariablesRoute: VariablesRoute,
   AutomationsCreateRoute: AutomationsCreateRoute,
   BlocksCatalogRoute: BlocksCatalogRouteWithChildren,
+  WorkPoolsCreateRoute: WorkPoolsCreateRoute,
   ArtifactsIndexRoute: ArtifactsIndexRoute,
   AutomationsIndexRoute: AutomationsIndexRoute,
   BlocksIndexRoute: BlocksIndexRoute,
@@ -774,6 +818,8 @@ const rootRouteChildren: RootRouteChildren = {
   DeploymentsDeploymentIdDuplicateRoute: DeploymentsDeploymentIdDuplicateRoute,
   DeploymentsDeploymentIdEditRoute: DeploymentsDeploymentIdEditRoute,
   DeploymentsDeploymentIdRunRoute: DeploymentsDeploymentIdRunRoute,
+  WorkPoolsWorkPoolWorkPoolNameEditRoute:
+    WorkPoolsWorkPoolWorkPoolNameEditRoute,
 }
 
 export const routeTree = rootRoute
@@ -793,6 +839,7 @@ export const routeTree = rootRoute
         "/variables",
         "/automations/create",
         "/blocks/catalog",
+        "/work-pools/create",
         "/artifacts/",
         "/automations/",
         "/blocks/",
@@ -813,7 +860,8 @@ export const routeTree = rootRoute
         "/work-pools/work-pool/$workPoolName",
         "/deployments/deployment_/$id/duplicate",
         "/deployments/deployment_/$id/edit",
-        "/deployments/deployment_/$id/run"
+        "/deployments/deployment_/$id/run",
+        "/work-pools/work-pool_/$workPoolName/edit"
       ]
     },
     "/": {
@@ -840,6 +888,9 @@ export const routeTree = rootRoute
         "/blocks/catalog/$slug"
       ]
     },
+    "/work-pools/create": {
+      "filePath": "work-pools/create.tsx"
+    },
     "/artifacts/": {
       "filePath": "artifacts/index.tsx"
     },
@@ -917,6 +968,9 @@ export const routeTree = rootRoute
     "/deployments/deployment_/$id/run": {
       "filePath": "deployments/deployment_.$id.run.tsx"
     },
+    "/work-pools/work-pool_/$workPoolName/edit": {
+      "filePath": "work-pools/work-pool_.$workPoolName.edit.tsx"
+    },
     "/work-pools/work-pool/$workPoolName/queue/$workQueueName": {
       "filePath": "work-pools/work-pool.$workPoolName.queue.$workQueueName.tsx",
       "parent": "/work-pools/work-pool/$workPoolName"
diff --git a/ui-v2/src/routes/work-pools/create.tsx b/ui-v2/src/routes/work-pools/create.tsx
new file mode 100644
index 000000000000..788c19a4f8cc
--- /dev/null
+++ b/ui-v2/src/routes/work-pools/create.tsx
@@ -0,0 +1,13 @@
+import { createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute("/work-pools/create")({
+	component: RouteComponent,
+});
+
+function RouteComponent() {
+	return (
+		<div>
+			<code>/work-pools/create</code>
+		</div>
+	);
+}
diff --git a/ui-v2/src/routes/work-pools/index.tsx b/ui-v2/src/routes/work-pools/index.tsx
index c55be8c33e83..235ae1ddfcf2 100644
--- a/ui-v2/src/routes/work-pools/index.tsx
+++ b/ui-v2/src/routes/work-pools/index.tsx
@@ -1,9 +1,91 @@
+import {
+	buildCountWorkPoolsQuery,
+	buildFilterWorkPoolsQuery,
+} from "@/api/work-pools";
+import { SearchInput } from "@/components/ui/input";
+import { WorkPoolsEmptyState } from "@/components/work-pools/empty-state";
+import { WorkPoolsPageHeader } from "@/components/work-pools/header";
+import { WorkPoolCard } from "@/components/work-pools/work-pool-card/work-pool-card";
+import { pluralize } from "@/utils";
+import { useSuspenseQuery } from "@tanstack/react-query";
 import { createFileRoute } from "@tanstack/react-router";
+import { useMemo, useState } from "react";
 
 export const Route = createFileRoute("/work-pools/")({
 	component: RouteComponent,
+	loader: ({ context }) => {
+		void context.queryClient.ensureQueryData(buildCountWorkPoolsQuery());
+		void context.queryClient.ensureQueryData(
+			buildFilterWorkPoolsQuery({
+				limit: 200,
+				offset: 0,
+			}),
+		);
+	},
+	wrapInSuspense: true,
 });
 
 function RouteComponent() {
-	return "🚧🚧 Pardon our dust! 🚧🚧";
+	const [searchTerm, setSearchTerm] = useState("");
+
+	const { data: workPoolCount = 0 } = useSuspenseQuery(
+		buildCountWorkPoolsQuery(),
+	);
+
+	const { data: workPools = [] } = useSuspenseQuery(
+		buildFilterWorkPoolsQuery({
+			limit: 200,
+			offset: 0,
+		}),
+	);
+
+	const filteredWorkPools = useMemo(() => {
+		return workPools.filter((workPool) =>
+			[
+				workPool.name,
+				workPool.description,
+				workPool.type,
+				JSON.stringify(workPool.base_job_template),
+				workPool.id,
+				workPool.default_queue_id,
+				workPool.status,
+			]
+				.join(" ")
+				.toLowerCase()
+				.includes(searchTerm.toLowerCase()),
+		);
+	}, [workPools, searchTerm]);
+
+	const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+		setSearchTerm(event.target.value);
+	};
+
+	return (
+		<div className="flex flex-col gap-4">
+			<WorkPoolsPageHeader />
+			{workPoolCount < 1 ? (
+				<WorkPoolsEmptyState />
+			) : (
+				<>
+					<div className="flex items-end justify-between">
+						<div className="text-sm text-muted-foreground">
+							{workPoolCount} {pluralize(workPoolCount, "work pool")}
+						</div>
+						<div className="flex gap-2">
+							<SearchInput
+								placeholder="Search work pools..."
+								value={searchTerm}
+								onChange={handleSearchChange}
+							/>
+						</div>
+					</div>
+					<div className="flex flex-col gap-4">
+						{filteredWorkPools.map((workPool) => (
+							<WorkPoolCard key={workPool.id} workPool={workPool} />
+						))}
+					</div>
+				</>
+			)}
+		</div>
+	);
 }
diff --git a/ui-v2/src/routes/work-pools/work-pool.$workPoolName.tsx b/ui-v2/src/routes/work-pools/work-pool.$workPoolName.tsx
index c3aa1765263b..2124585f83e9 100644
--- a/ui-v2/src/routes/work-pools/work-pool.$workPoolName.tsx
+++ b/ui-v2/src/routes/work-pools/work-pool.$workPoolName.tsx
@@ -1,9 +1,23 @@
+import { buildGetWorkPoolQuery } from "@/api/work-pools/work-pools";
+import { useSuspenseQuery } from "@tanstack/react-query";
 import { createFileRoute } from "@tanstack/react-router";
 
 export const Route = createFileRoute("/work-pools/work-pool/$workPoolName")({
 	component: RouteComponent,
+	loader: async ({ context, params }) => {
+		const { workPoolName } = params;
+		return context.queryClient.ensureQueryData(
+			buildGetWorkPoolQuery(workPoolName),
+		);
+	},
+	wrapInSuspense: true,
 });
 
 function RouteComponent() {
-	return "🚧🚧 Pardon our dust! 🚧🚧";
+	const { workPoolName } = Route.useParams();
+	const { data: workPool } = useSuspenseQuery(
+		buildGetWorkPoolQuery(workPoolName),
+	);
+
+	return <div>Work pool: {workPool.name}</div>;
 }
diff --git a/ui-v2/src/routes/work-pools/work-pool_.$workPoolName.edit.tsx b/ui-v2/src/routes/work-pools/work-pool_.$workPoolName.edit.tsx
new file mode 100644
index 000000000000..fea71d3f889c
--- /dev/null
+++ b/ui-v2/src/routes/work-pools/work-pool_.$workPoolName.edit.tsx
@@ -0,0 +1,25 @@
+import { buildGetWorkPoolQuery } from "@/api/work-pools/work-pools";
+import { useSuspenseQuery } from "@tanstack/react-query";
+import { createFileRoute } from "@tanstack/react-router";
+
+export const Route = createFileRoute(
+	"/work-pools/work-pool_/$workPoolName/edit",
+)({
+	component: RouteComponent,
+	loader: async ({ context, params }) => {
+		const { workPoolName } = params;
+		return context.queryClient.ensureQueryData(
+			buildGetWorkPoolQuery(workPoolName),
+		);
+	},
+	wrapInSuspense: true,
+});
+
+function RouteComponent() {
+	const { workPoolName } = Route.useParams();
+	const { data: workPool } = useSuspenseQuery(
+		buildGetWorkPoolQuery(workPoolName),
+	);
+
+	return <div>Editing work pool: {workPool.name}</div>;
+}


Reviews:
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:47:42Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:48:55Z
user: login: znicholasbrown
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:49:46Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:50:39Z
user: login: znicholasbrown
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:50:40Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:51:57Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:54:27Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:55:24Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:57:45Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T16:58:44Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-17T17:00:01Z
user: login: znicholasbrown
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T18:40:13Z
user: login: znicholasbrown
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T18:45:55Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T20:22:53Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T20:26:34Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T20:28:52Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T20:29:33Z
user: login: znicholasbrown
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T20:39:05Z
user: login: znicholasbrown
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T20:41:06Z
user: login: devinvillarosa
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T20:42:09Z
user: login: znicholasbrown
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T20:46:18Z
user: login: devinvillarosa
body: 🚀 
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-02T21:32:42Z

Review comments:
diff_hunk: @@ -139,3 +139,86 @@ export const buildWorkPoolDetailsQuery = (name: string) =>
 			return res.data;
 		},
 	});
+
+/**
+ * Pauses a work pool
+ *
+ * @param name - Work pool name to pause
+ * @returns Query configuration object for use with TanStack Query
+ */
+export const pauseWorkPool = (name: string) =>
+	queryOptions({
+		queryKey: queryKeyFactory.detail(name),
+		queryFn: async () => {
+			const res = await getQueryService().PATCH("/work_pools/{name}", {
+				params: { path: { name } },
+				body: {
+					is_paused: true,
+				},
+			});
+			if (!res.data) {
+				throw new Error("'data' expected");
+			}
+		},
+	});
+
+/**
+ * Resumes a work pool
+ *
+ * @param name - Work pool name to resume
+ * @returns Query configuration object for use with TanStack Query
+ */
+export const resumeWorkPool = (name: string) =>
+	queryOptions({
+		queryKey: queryKeyFactory.detail(name),
+		queryFn: async () => {
+			const res = await getQueryService().PATCH("/work_pools/{name}", {
+				params: { path: { name } },
+				body: {
+					is_paused: false,
+				},
+			});
+			if (!res.data) {
+				throw new Error("'data' expected");
+			}
+		},
+	});
+
+/**
+ * Deletes a work pool
+ *
+ * @param name - Work pool name to delete
+ * @returns Query configuration object for use with TanStack Query
+ */
+export const deleteWorkPool = (name: string) =>
path: ui-v2/src/api/work-pools/work-pools.ts
user: login: devinvillarosa
body: You'll have to use `useMutation` for these type of calls.
Think of `queryOptions` to be used with `useQuery` and `useSuspenseQuery` that handles data fetching based on the queryKeys.

And `useMutation` calls for mutating that data, and invalidates the `queryKeys` above

created_at: 2025-03-17T16:47:42Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,171 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { toast } from "sonner";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createFakeWorkPool } from "@/mocks/create-fake-work-pool";
+import { createWrapper } from "@tests/utils";
+import { WorkPoolContextMenu } from "./work-pool-context-menu";
+
+vi.mock("sonner", () => ({
+	toast: {
+		success: vi.fn(),
+		error: vi.fn(),
+	},
+}));
path: ui-v2/src/components/work-pools/work-pool-card/components/work-pool-context-menu.test.tsx
user: login: devinvillarosa
body: Besides mocking, you can render the `Toast` component in the `render` function.
created_at: 2025-03-17T16:48:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -139,3 +139,86 @@ export const buildWorkPoolDetailsQuery = (name: string) =>
 			return res.data;
 		},
 	});
+
+/**
+ * Pauses a work pool
+ *
+ * @param name - Work pool name to pause
+ * @returns Query configuration object for use with TanStack Query
+ */
+export const pauseWorkPool = (name: string) =>
+	queryOptions({
+		queryKey: queryKeyFactory.detail(name),
+		queryFn: async () => {
+			const res = await getQueryService().PATCH("/work_pools/{name}", {
+				params: { path: { name } },
+				body: {
+					is_paused: true,
+				},
+			});
+			if (!res.data) {
+				throw new Error("'data' expected");
+			}
+		},
+	});
+
+/**
+ * Resumes a work pool
+ *
+ * @param name - Work pool name to resume
+ * @returns Query configuration object for use with TanStack Query
+ */
+export const resumeWorkPool = (name: string) =>
+	queryOptions({
+		queryKey: queryKeyFactory.detail(name),
+		queryFn: async () => {
+			const res = await getQueryService().PATCH("/work_pools/{name}", {
+				params: { path: { name } },
+				body: {
+					is_paused: false,
+				},
+			});
+			if (!res.data) {
+				throw new Error("'data' expected");
+			}
+		},
+	});
+
+/**
+ * Deletes a work pool
+ *
+ * @param name - Work pool name to delete
+ * @returns Query configuration object for use with TanStack Query
+ */
+export const deleteWorkPool = (name: string) =>
path: ui-v2/src/api/work-pools/work-pools.ts
user: login: znicholasbrown
body: ah that makes total sense, thank you! will update asap
created_at: 2025-03-17T16:49:46Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+import { WorkPool } from "@/api/work-pools";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { StatusBadge } from "@/components/ui/status-badge";
+import { useDeleteWorkPool } from "@/hooks/work-pools";
+import { WorkPoolContextMenu } from "./components/work-pool-context-menu";
+import { WorkPoolName } from "./components/work-pool-name";
+import { WorkPoolPauseResumeToggle } from "./components/work-pool-pause-resume-toggle";
+import { WorkPoolTypeBadge } from "./components/work-pool-type-badge";
+
+type WorkPoolCardProps = {
+	workPool: WorkPool;
+};
+
+export const WorkPoolCard = ({ workPool }: WorkPoolCardProps) => {
+	const { mutate: deleteWorkPool } = useDeleteWorkPool();
+
+	return (
+		<Card className="gap-2">
+			<CardHeader className="flex flex-row items-center justify-between">
+				<CardTitle className="flex items-center gap-2">
+					<WorkPoolName workPoolName={workPool.name} />
+					{workPool.status && <StatusBadge status={workPool.status} />}
+				</CardTitle>
+				<div className="flex items-center gap-2">
+					<WorkPoolPauseResumeToggle workPool={workPool} />
+					<WorkPoolContextMenu
+						workPool={workPool}
+						onDelete={() => deleteWorkPool(workPool.name)}
path: ui-v2/src/components/work-pools/work-pool-card/work-pool-card.tsx
user: login: devinvillarosa
body: imo, you should create a `handleDeleteWorkPool` wrapper that will handle the `onSuccess` and `onError` callbacks that uses a `toast` component
created_at: 2025-03-17T16:50:38Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,171 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { toast } from "sonner";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createFakeWorkPool } from "@/mocks/create-fake-work-pool";
+import { createWrapper } from "@tests/utils";
+import { WorkPoolContextMenu } from "./work-pool-context-menu";
+
+vi.mock("sonner", () => ({
+	toast: {
+		success: vi.fn(),
+		error: vi.fn(),
+	},
+}));
path: ui-v2/src/components/work-pools/work-pool-card/components/work-pool-context-menu.test.tsx
user: login: znicholasbrown
body: do you have a preference on that? i tend to prefer to mock in cases like this to avoid the tests becoming too interwoven but toasts do feel like a bit of a special case
created_at: 2025-03-17T16:50:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,91 @@
+import { getQueryService } from "@/api/service";
+import { queryKeyFactory } from "@/api/work-pools/work-pools";
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { toast } from "sonner";
+
+/**
+ * Hook for pausing a work pool
+ * @returns Mutation for pausing a work pool
+ */
+export const usePauseWorkPool = () => {
+	const queryClient = useQueryClient();
+
+	return useMutation({
+		mutationFn: async (name: string) => {
+			await getQueryService().PATCH("/work_pools/{name}", {
+				params: { path: { name } },
+				body: {
+					is_paused: true,
+				},
+			});
+		},
+		onSuccess: (_, name) => {
+			void queryClient.invalidateQueries({ queryKey: queryKeyFactory.all() });
+			void queryClient.invalidateQueries({
+				queryKey: queryKeyFactory.detail(name),
+			});
+			toast.success(`${name} paused`);
+		},
+		onError: (error) => {
+			toast.error(
+				`Failed to pause work pool: ${error instanceof Error ? error.message : "Unknown error"}`,
+			);
+		},
path: ui-v2/src/hooks/work-pools.ts
user: login: devinvillarosa
body: Toast and other UI updates _should_ be handled when calling this hook in the react component.
ref: https://tkdodo.eu/blog/mastering-mutations-in-react-query#some-callbacks-might-not-fire
created_at: 2025-03-17T16:51:57Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,9 +1,91 @@
+import {
+	buildCountWorkPoolsQuery,
+	buildFilterWorkPoolsQuery,
+} from "@/api/work-pools";
+import { SearchInput } from "@/components/ui/input";
+import { WorkPoolsEmptyState } from "@/components/work-pools/empty-state";
+import { WorkPoolsPageHeader } from "@/components/work-pools/header";
+import { WorkPoolCard } from "@/components/work-pools/work-pool-card/work-pool-card";
+import { pluralize } from "@/utils";
+import { useSuspenseQuery } from "@tanstack/react-query";
 import { createFileRoute } from "@tanstack/react-router";
+import { useMemo, useState } from "react";
 
 export const Route = createFileRoute("/work-pools/")({
 	component: RouteComponent,
+	loader: ({ context }) => {
+		void context.queryClient.ensureQueryData(buildCountWorkPoolsQuery());
+		void context.queryClient.ensureQueryData(
+			buildFilterWorkPoolsQuery({
+				limit: 200,
+				offset: 0,
+			}),
+		);
+	},
+	wrapInSuspense: true,
 });
 
 function RouteComponent() {
-	return "🚧🚧 Pardon our dust! 🚧🚧";
+	const [searchTerm, setSearchTerm] = useState("");
+
+	const { data: workPoolCount = 0 } = useSuspenseQuery(
+		buildCountWorkPoolsQuery(),
+	);
+
+	const { data: workPools = [] } = useSuspenseQuery(
+		buildFilterWorkPoolsQuery({
+			limit: 200,
+			offset: 0,
+		}),
+	);
+
+	const filteredWorkPools = useMemo(() => {
+		return workPools.filter((workPool) =>
+			[
+				workPool.name,
+				workPool.description,
+				workPool.type,
+				JSON.stringify(workPool.base_job_template),
+				workPool.id,
+				workPool.default_queue_id,
+				workPool.status,
+			]
+				.join(" ")
+				.toLowerCase()
+				.includes(searchTerm.toLowerCase()),
+		);
+	}, [workPools, searchTerm]);
+
+	const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+		setSearchTerm(event.target.value);
+	};
+
+	return (
+		// TODO: Should we just standardize a layout for all pages?
path: ui-v2/src/routes/work-pools/index.tsx
user: login: devinvillarosa
body: Maybe we can revisit this near the end of the project.
I think for OSS, we want to reduce the amount of UI layers (at the cost of redundancy), so that its not too hard to contribute
created_at: 2025-03-17T16:54:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,9 +1,91 @@
+import {
+	buildCountWorkPoolsQuery,
+	buildFilterWorkPoolsQuery,
+} from "@/api/work-pools";
+import { SearchInput } from "@/components/ui/input";
+import { WorkPoolsEmptyState } from "@/components/work-pools/empty-state";
+import { WorkPoolsPageHeader } from "@/components/work-pools/header";
+import { WorkPoolCard } from "@/components/work-pools/work-pool-card/work-pool-card";
+import { pluralize } from "@/utils";
+import { useSuspenseQuery } from "@tanstack/react-query";
 import { createFileRoute } from "@tanstack/react-router";
+import { useMemo, useState } from "react";
 
 export const Route = createFileRoute("/work-pools/")({
 	component: RouteComponent,
+	loader: ({ context }) => {
+		void context.queryClient.ensureQueryData(buildCountWorkPoolsQuery());
+		void context.queryClient.ensureQueryData(
+			buildFilterWorkPoolsQuery({
+				limit: 200,
+				offset: 0,
+			}),
+		);
+	},
+	wrapInSuspense: true,
 });
 
 function RouteComponent() {
-	return "🚧🚧 Pardon our dust! 🚧🚧";
+	const [searchTerm, setSearchTerm] = useState("");
+
+	const { data: workPoolCount = 0 } = useSuspenseQuery(
+		buildCountWorkPoolsQuery(),
+	);
+
+	const { data: workPools = [] } = useSuspenseQuery(
+		buildFilterWorkPoolsQuery({
+			limit: 200,
+			offset: 0,
+		}),
+	);
+
+	const filteredWorkPools = useMemo(() => {
+		return workPools.filter((workPool) =>
+			[
+				workPool.name,
+				workPool.description,
+				workPool.type,
+				JSON.stringify(workPool.base_job_template),
+				workPool.id,
+				workPool.default_queue_id,
+				workPool.status,
+			]
+				.join(" ")
+				.toLowerCase()
+				.includes(searchTerm.toLowerCase()),
+		);
+	}, [workPools, searchTerm]);
+
+	const handleSearchChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+		setSearchTerm(event.target.value);
+	};
+
+	return (
+		// TODO: Should we just standardize a layout for all pages?
+		<div className="flex flex-col gap-4">
+			<WorkPoolsPageHeader />
+			{workPoolCount === 0 && <WorkPoolsEmptyState />}
+			{workPoolCount > 0 && (
+				<>
+					<div className="flex items-end justify-between">
+						<div className="text-sm text-muted-foreground">
+							{workPoolCount} {pluralize(workPoolCount, "work pool")}
+						</div>
+						<div className="flex gap-2">
+							<SearchInput
+								placeholder="Search work pools..."
+								value={searchTerm}
+								onChange={handleSearchChange}
+							/>
+						</div>
+					</div>
+					<div className="flex flex-col gap-4">
+						{filteredWorkPools.map((workPool) => (
+							<WorkPoolCard key={workPool.id} workPool={workPool} />
+						))}
+					</div>
+				</>
+			)}
path: ui-v2/src/routes/work-pools/index.tsx
user: login: devinvillarosa
body: suggestion:
You can probably do something like
```
workPools < 1 ?  <WorkPoolsEmptyState /> : <> ...</>
```
created_at: 2025-03-17T16:55:24Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,9 +1,22 @@
+import { buildGetWorkPoolQuery } from "@/api/work-pools/work-pools";
 import { createFileRoute } from "@tanstack/react-router";
 
 export const Route = createFileRoute("/work-pools/work-pool/$workPoolName")({
 	component: RouteComponent,
+	loader: async ({ context, params }) => {
+		const { workPoolName } = params;
+
+		const workPool = await context.queryClient.ensureQueryData(
+			buildGetWorkPoolQuery(workPoolName),
+		);
+
+		return { workPool };
+	},
+	wrapInSuspense: true,
path: ui-v2/src/routes/work-pools/work-pool.$workPoolName.tsx
user: login: devinvillarosa
body: ```suggestion
	loader: async ({ context, params }) => {
		const { workPoolName } = params;

		 return context.queryClient.ensureQueryData(buildGetWorkPoolQuery(workPoolName));
	},
	wrapInSuspense: true,
```
And below, you would subscribe to the fetched data using `useSuspenseQuery(buildGetWorkPoolQuery(workPoolName))`  (be sure to fetch workPoolName from the params again)
created_at: 2025-03-17T16:57:45Z
author_association: CONTRIBUTOR
path: ui-v2/src/routes/work-pools/work-pool_.$workPoolName.edit.tsx
user: login: devinvillarosa
body: Same comment as above.
Because we're using `react-query` to subscribe to data, we just need to `ensureQueryData` in the loader.
Then call `useSuspenseQuery` in the `RouteComponent`
created_at: 2025-03-17T16:58:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,171 @@
+import { fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { toast } from "sonner";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+import { createFakeWorkPool } from "@/mocks/create-fake-work-pool";
+import { createWrapper } from "@tests/utils";
+import { WorkPoolContextMenu } from "./work-pool-context-menu";
+
+vi.mock("sonner", () => ({
+	toast: {
+		success: vi.fn(),
+		error: vi.fn(),
+	},
+}));
path: ui-v2/src/components/work-pools/work-pool-card/components/work-pool-context-menu.test.tsx
user: login: devinvillarosa
body: IMO for this case, I'd just use Toast, so that we can do UX validations with actual components.
created_at: 2025-03-17T17:00:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+import { WorkPool } from "@/api/work-pools";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { StatusBadge } from "@/components/ui/status-badge";
+import { useDeleteWorkPool } from "@/hooks/work-pools";
+import { WorkPoolContextMenu } from "./components/work-pool-context-menu";
+import { WorkPoolName } from "./components/work-pool-name";
+import { WorkPoolPauseResumeToggle } from "./components/work-pool-pause-resume-toggle";
+import { WorkPoolTypeBadge } from "./components/work-pool-type-badge";
+
+type WorkPoolCardProps = {
+	workPool: WorkPool;
+};
+
+export const WorkPoolCard = ({ workPool }: WorkPoolCardProps) => {
+	const { mutate: deleteWorkPool } = useDeleteWorkPool();
+
+	return (
+		<Card className="gap-2">
+			<CardHeader className="flex flex-row items-center justify-between">
+				<CardTitle className="flex items-center gap-2">
+					<WorkPoolName workPoolName={workPool.name} />
+					{workPool.status && <StatusBadge status={workPool.status} />}
+				</CardTitle>
+				<div className="flex items-center gap-2">
+					<WorkPoolPauseResumeToggle workPool={workPool} />
+					<WorkPoolContextMenu
+						workPool={workPool}
+						onDelete={() => deleteWorkPool(workPool.name)}
path: ui-v2/src/components/work-pools/work-pool-card/work-pool-card.tsx
user: login: znicholasbrown
body: Can you explain what you're looking for here? That's already what `useDeleteWorkPool` is doing under the hood
created_at: 2025-04-02T18:40:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,47 @@
+import { WorkPool } from "@/api/work-pools";
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { StatusBadge } from "@/components/ui/status-badge";
+import { useDeleteWorkPool } from "@/hooks/work-pools";
+import { WorkPoolContextMenu } from "./components/work-pool-context-menu";
+import { WorkPoolName } from "./components/work-pool-name";
+import { WorkPoolPauseResumeToggle } from "./components/work-pool-pause-resume-toggle";
+import { WorkPoolTypeBadge } from "./components/work-pool-type-badge";
+
+type WorkPoolCardProps = {
+	workPool: WorkPool;
+};
+
+export const WorkPoolCard = ({ workPool }: WorkPoolCardProps) => {
+	const { mutate: deleteWorkPool } = useDeleteWorkPool();
+
+	return (
+		<Card className="gap-2">
+			<CardHeader className="flex flex-row items-center justify-between">
+				<CardTitle className="flex items-center gap-2">
+					<WorkPoolName workPoolName={workPool.name} />
+					{workPool.status && <StatusBadge status={workPool.status} />}
+				</CardTitle>
+				<div className="flex items-center gap-2">
+					<WorkPoolPauseResumeToggle workPool={workPool} />
+					<WorkPoolContextMenu
+						workPool={workPool}
+						onDelete={() => deleteWorkPool(workPool.name)}
path: ui-v2/src/components/work-pools/work-pool-card/work-pool-card.tsx
user: login: znicholasbrown
body: Ah I see, you're looking for something more like the concurrency form?
created_at: 2025-04-02T18:45:55Z
author_association: CONTRIBUTOR
path: ui-v2/src/components/work-pools/work-pool-card/components/constants.ts
user: login: devinvillarosa
body: can probably delete this file
created_at: 2025-04-02T20:22:53Z
author_association: CONTRIBUTOR
path: ui-v2/src/hooks/work-pools.ts
user: login: devinvillarosa
body: suggestion: move hooks should be moved into `@/api/work-pools`. We should try containing all data store mutations inside the associated `api` module
created_at: 2025-04-02T20:26:34Z
author_association: CONTRIBUTOR
path: ui-v2/src/routes/work-pools/index.tsx
user: login: devinvillarosa
body: looks good for the first round.
We should consider moving the state of these filters into the url params next round
created_at: 2025-04-02T20:28:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,9 +1,91 @@
+import {
+	buildCountWorkPoolsQuery,
+	buildFilterWorkPoolsQuery,
+} from "@/api/work-pools";
+import { SearchInput } from "@/components/ui/input";
+import { WorkPoolsEmptyState } from "@/components/work-pools/empty-state";
+import { WorkPoolsPageHeader } from "@/components/work-pools/header";
+import { WorkPoolCard } from "@/components/work-pools/work-pool-card/work-pool-card";
+import { pluralize } from "@/utils";
+import { useSuspenseQuery } from "@tanstack/react-query";
 import { createFileRoute } from "@tanstack/react-router";
+import { useMemo, useState } from "react";
 
 export const Route = createFileRoute("/work-pools/")({
 	component: RouteComponent,
+	loader: ({ context }) => {
+		void context.queryClient.ensureQueryData(buildCountWorkPoolsQuery());
+		void context.queryClient.ensureQueryData(
+			buildFilterWorkPoolsQuery({
+				limit: 200,
+				offset: 0,
+			}),
+		);
+	},
+	wrapInSuspense: true,
 });
 
 function RouteComponent() {
-	return "🚧🚧 Pardon our dust! 🚧🚧";
+	const [searchTerm, setSearchTerm] = useState("");
+
+	const { data: workPoolCount = 0 } = useSuspenseQuery(
+		buildCountWorkPoolsQuery(),
+	);
+
+	const { data: workPools = [] } = useSuspenseQuery(
+		buildFilterWorkPoolsQuery({
+			limit: 200,
+			offset: 0,
+		}),
+	);
+
+	const filteredWorkPools = useMemo(() => {
path: ui-v2/src/routes/work-pools/index.tsx
user: login: devinvillarosa
body: question: does the API not provide filtering?
created_at: 2025-04-02T20:29:33Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,9 +1,91 @@
+import {
+	buildCountWorkPoolsQuery,
+	buildFilterWorkPoolsQuery,
+} from "@/api/work-pools";
+import { SearchInput } from "@/components/ui/input";
+import { WorkPoolsEmptyState } from "@/components/work-pools/empty-state";
+import { WorkPoolsPageHeader } from "@/components/work-pools/header";
+import { WorkPoolCard } from "@/components/work-pools/work-pool-card/work-pool-card";
+import { pluralize } from "@/utils";
+import { useSuspenseQuery } from "@tanstack/react-query";
 import { createFileRoute } from "@tanstack/react-router";
+import { useMemo, useState } from "react";
 
 export const Route = createFileRoute("/work-pools/")({
 	component: RouteComponent,
+	loader: ({ context }) => {
+		void context.queryClient.ensureQueryData(buildCountWorkPoolsQuery());
+		void context.queryClient.ensureQueryData(
+			buildFilterWorkPoolsQuery({
+				limit: 200,
+				offset: 0,
+			}),
+		);
+	},
+	wrapInSuspense: true,
 });
 
 function RouteComponent() {
-	return "🚧🚧 Pardon our dust! 🚧🚧";
+	const [searchTerm, setSearchTerm] = useState("");
+
+	const { data: workPoolCount = 0 } = useSuspenseQuery(
+		buildCountWorkPoolsQuery(),
+	);
+
+	const { data: workPools = [] } = useSuspenseQuery(
+		buildFilterWorkPoolsQuery({
+			limit: 200,
+			offset: 0,
+		}),
+	);
+
+	const filteredWorkPools = useMemo(() => {
path: ui-v2/src/routes/work-pools/index.tsx
user: login: znicholasbrown
body: not really 😒 
created_at: 2025-04-02T20:39:05Z
author_association: CONTRIBUTOR
path: ui-v2/src/hooks/work-pools.ts
user: login: znicholasbrown
body: was following the pattern of the `variables` hooks - are those no longer correct?
created_at: 2025-04-02T20:41:06Z
author_association: CONTRIBUTOR
path: ui-v2/src/hooks/work-pools.ts
user: login: devinvillarosa
body: ya, `variables` needs to be moved over eventually
created_at: 2025-04-02T20:42:09Z
author_association: CONTRIBUTOR
path: ui-v2/src/hooks/work-pools.ts
user: login: znicholasbrown
body: ok heard - updated that
created_at: 2025-04-02T20:46:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: znicholasbrown
created_at: 2025-03-07T21:14:28Z
body: Working on some tests for that context menu component, will be done shortly
author_association: CONTRIBUTOR
user: login: znicholasbrown
created_at: 2025-03-07T23:35:59Z
body: Ah I wrote some tests for functionality I forgot to include - will fix Monday if I have some time, this is pretty close though.
author_association: CONTRIBUTOR
user: login: github-actions[bot]
created_at: 2025-03-31T17:01:16Z
body: This pull request is stale because it has been open 14 days with no activity. To keep this pull request open remove stale label or comment.
author_association: CONTRIBUTOR
user: login: znicholasbrown
created_at: 2025-03-31T17:02:25Z
body: gonna pick this back up this week

author_association: CONTRIBUTOR
user: login: znicholasbrown
created_at: 2025-04-02T20:20:53Z
body: ok this should be g2g now @devinvillarosa 
author_association: CONTRIBUTOR
user: login: znicholasbrown
created_at: 2025-04-02T22:08:21Z
body: Hm something's up with this - it's failing the app-level test. Will investigate tomorrow
author_association: CONTRIBUTOR