diff --git a/apps/dashboard/src/components/run-filters-form.tsx b/apps/dashboard/src/components/run-filters-form.tsx
new file mode 100644
index 00000000..782492bc
--- /dev/null
+++ b/apps/dashboard/src/components/run-filters-form.tsx
@@ -0,0 +1,86 @@
+import { Button } from "@/components/ui/button";
+import { Field, FieldLabel } from "@/components/ui/field";
+import { Input } from "@/components/ui/input";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select";
+import {
+ RUN_FILTER_STATUSES,
+ type RunFilters,
+ type RunFilterStatus,
+} from "@/lib/run-filters";
+import { getRunStatusConfig } from "@/lib/status";
+import { useState } from "react";
+
+interface RunFiltersFormProps {
+ filters: RunFilters;
+ onChange: (filters: RunFilters) => void;
+}
+
+export function RunFiltersForm({ filters, onChange }: RunFiltersFormProps) {
+ const [status, setStatus] = useState(filters.status);
+ const [workflowName, setWorkflowName] = useState(filters.workflowName ?? "");
+
+ return (
+
+ );
+}
diff --git a/apps/dashboard/src/components/run-list.tsx b/apps/dashboard/src/components/run-list.tsx
index 7b657f48..d7915781 100644
--- a/apps/dashboard/src/components/run-list.tsx
+++ b/apps/dashboard/src/components/run-list.tsx
@@ -18,6 +18,7 @@ export interface RunListProps {
title?: string;
showHeader?: boolean;
showCount?: boolean;
+ emptyMessage?: string;
}
export function RunList({
@@ -26,6 +27,7 @@ export function RunList({
title = "Workflow Runs",
showHeader = true,
showCount = true,
+ emptyMessage = "No workflow runs have been created yet.",
}: RunListProps) {
if (runs.length === 0) {
return (
@@ -39,9 +41,7 @@ export function RunList({
)}
-
- No workflow runs have been created yet.
-
+ {emptyMessage}
);
diff --git a/apps/dashboard/src/components/workflow-stats.tsx b/apps/dashboard/src/components/workflow-stats.tsx
index 1e355622..abfcbae6 100644
--- a/apps/dashboard/src/components/workflow-stats.tsx
+++ b/apps/dashboard/src/components/workflow-stats.tsx
@@ -1,4 +1,5 @@
import { Card } from "@/components/ui/card";
+import { RUN_FILTER_STATUSES, type RunFilterStatus } from "@/lib/run-filters";
import {
getRunStatusConfig,
getStatusStatCardClass,
@@ -9,18 +10,16 @@ import type { WorkflowRunCounts } from "openworkflow/internal";
export interface WorkflowStatsProps {
workflowRunCounts: WorkflowRunCounts;
+ selectedStatus: RunFilterStatus | undefined;
+ onStatusChange: (status: RunFilterStatus | undefined) => void;
}
-const STATS_STATUS_ORDER = [
- "pending",
- "running",
- "completed",
- "failed",
- "canceled",
-] as const satisfies readonly (keyof WorkflowRunCounts)[];
-
-export function WorkflowStats({ workflowRunCounts }: WorkflowStatsProps) {
- const stats = STATS_STATUS_ORDER.map((status) => {
+export function WorkflowStats({
+ workflowRunCounts,
+ selectedStatus,
+ onStatusChange,
+}: WorkflowStatsProps) {
+ const stats = RUN_FILTER_STATUSES.map((status) => {
const config = getRunStatusConfig(status);
return {
@@ -41,21 +40,32 @@ export function WorkflowStats({ workflowRunCounts }: WorkflowStatsProps) {
-
-
-
+
-
+
+
{stat.value}
-
-
+
+
-
+
);
})}
diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts
index 6ad1832f..4da99322 100644
--- a/apps/dashboard/src/lib/api.ts
+++ b/apps/dashboard/src/lib/api.ts
@@ -1,9 +1,11 @@
import { getBackend } from "./backend";
import { parseWorkflowInput } from "./input";
+import { runFiltersSchema } from "./run-filters";
import { saveWorkflowSchedule, scheduleInputSchema } from "./schedules.server";
import { createServerFn } from "@tanstack/react-start";
import { rerunWorkflowRun } from "openworkflow/internal";
import type {
+ ListWorkflowRunsParams,
PaginatedResponse,
PaginationOptions,
StepAttempt,
@@ -50,13 +52,17 @@ function parseOptionalDate(
}
/**
- * List workflow runs from the backend with optional pagination.
+ * List workflow runs from the backend with optional filters and pagination.
*/
export const listWorkflowRunsServerFn = createServerFn({ method: "GET" })
- .validator(z.object(paginationInputFields))
+ .validator(z.object({ ...paginationInputFields, ...runFiltersSchema.shape }))
.handler(async ({ data }): Promise> => {
const backend = await getBackend();
- const result = await backend.listWorkflowRuns(getPaginationOptions(data));
+ const params: ListWorkflowRunsParams = getPaginationOptions(data);
+ if (data.status !== undefined) params.status = data.status;
+ if (data.workflowName !== undefined)
+ params.workflowName = data.workflowName;
+ const result = await backend.listWorkflowRuns(params);
return result;
});
diff --git a/apps/dashboard/src/lib/run-filters.ts b/apps/dashboard/src/lib/run-filters.ts
new file mode 100644
index 00000000..8aeec3b9
--- /dev/null
+++ b/apps/dashboard/src/lib/run-filters.ts
@@ -0,0 +1,37 @@
+import {
+ validateRunsPaginationSearch,
+ type RunsPaginationSearch,
+} from "./runs-page-pagination";
+import type { WorkflowRunCounts } from "openworkflow/internal";
+import * as z from "zod";
+
+export const RUN_FILTER_STATUSES = [
+ "pending",
+ "running",
+ "completed",
+ "failed",
+ "canceled",
+] as const satisfies readonly (keyof WorkflowRunCounts)[];
+
+export const runFiltersSchema = z.object({
+ status: z.enum(RUN_FILTER_STATUSES).optional(),
+ workflowName: z
+ .string()
+ .trim()
+ .transform((value) => value || undefined)
+ .optional(),
+});
+
+export type RunFilters = z.infer;
+export type RunFilterStatus = (typeof RUN_FILTER_STATUSES)[number];
+export type RunsPageSearch = RunsPaginationSearch & RunFilters;
+
+export function validateRunsSearch(
+ // oxlint-disable-next-line anti-slop/no-unknown-parameters -- URL search values are untrusted until validated
+ search: unknown,
+): RunsPageSearch {
+ const pagination = validateRunsPaginationSearch(search);
+ const filters = runFiltersSchema.safeParse(search);
+ if (!filters.success) return pagination;
+ return { ...pagination, ...filters.data };
+}
diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx
index 83f6d70e..b7e3e8c3 100644
--- a/apps/dashboard/src/routes/index.tsx
+++ b/apps/dashboard/src/routes/index.tsx
@@ -1,6 +1,7 @@
import { AppLayout } from "@/components/app-layout";
import { CreateRunForm } from "@/components/create-run-form";
import { ListPagination } from "@/components/list-pagination";
+import { RunFiltersForm } from "@/components/run-filters-form";
import { RunList, type ChildRunRelation } from "@/components/run-list";
import { Button } from "@/components/ui/button";
import {
@@ -17,10 +18,8 @@ import {
getWorkflowRunServerFn,
listWorkflowRunsServerFn,
} from "@/lib/api";
-import {
- resolveRunsPageSize,
- validateRunsPaginationSearch,
-} from "@/lib/runs-page-pagination";
+import { validateRunsSearch, type RunFilters } from "@/lib/run-filters";
+import { resolveRunsPageSize } from "@/lib/runs-page-pagination";
import { usePolling } from "@/lib/use-polling";
import { PlusIcon } from "@phosphor-icons/react";
import { createFileRoute } from "@tanstack/react-router";
@@ -28,7 +27,7 @@ import type { StepAttempt, WorkflowRun } from "openworkflow/internal";
import { useState } from "react";
export const Route = createFileRoute("/")({
- validateSearch: validateRunsPaginationSearch,
+ validateSearch: validateRunsSearch,
loaderDeps: ({ search }) => search,
component: HomePage,
loader: async ({ deps }) => {
@@ -40,6 +39,8 @@ export const Route = createFileRoute("/")({
limit,
after: deps.after,
before: deps.before,
+ status: deps.status,
+ workflowName: deps.workflowName,
},
}),
getWorkflowRunCountsServerFn(),
@@ -108,6 +109,18 @@ function HomePage() {
const [isCreateRunOpen, setIsCreateRunOpen] = useState(false);
const runs = data;
+ let emptyMessage = "No workflow runs have been created yet.";
+ if (search.after ?? search.before) {
+ emptyMessage =
+ "No runs on this page. Return to the first page to view runs.";
+ } else if (search.status || search.workflowName) {
+ emptyMessage =
+ "No runs match these filters. Try changing or clearing them.";
+ }
+
+ function updateFilters(filters: RunFilters) {
+ void navigate({ search: { limit: search.limit, ...filters } });
+ }
usePolling();
@@ -133,12 +146,29 @@ function HomePage() {
-
+
+
+ All runs in this namespace
+
+
{
+ updateFilters({ status, workflowName: search.workflowName });
+ }}
+ />
+
+
{
- void navigate({ search: next });
+ void navigate({
+ search: {
+ ...next,
+ status: search.status,
+ workflowName: search.workflowName,
+ },
+ });
}}
/>