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 ( +
{ + event.preventDefault(); + onChange({ status, workflowName: workflowName.trim() || undefined }); + }} + > + + Status + + value={status ?? "all"} + onValueChange={(value) => { + setStatus(value === "all" || value === null ? undefined : value); + }} + > + + + {status ? getRunStatusConfig(status).label : "All statuses"} + + + + All statuses + {RUN_FILTER_STATUSES.map((value) => ( + + {getRunStatusConfig(value).label} + + ))} + + + + + Workflow name + { + setWorkflowName(event.currentTarget.value); + }} + /> + + + +
+ ); +} 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) { -
-
-

+

+ + -
+
); })} 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, + }, + }); }} />