Repository navigation
feat(dashboard): run filters #900
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 ( | ||
| <form | ||
| aria-label="Filter workflow runs" | ||
| className="flex flex-wrap items-end gap-3" | ||
| onSubmit={(event) => { | ||
| event.preventDefault(); | ||
| onChange({ status, workflowName: workflowName.trim() || undefined }); | ||
| }} | ||
| > | ||
| <Field className="w-full sm:w-44"> | ||
| <FieldLabel htmlFor="run-filter-status">Status</FieldLabel> | ||
| <Select<RunFilterStatus | "all"> | ||
| value={status ?? "all"} | ||
| onValueChange={(value) => { | ||
| setStatus(value === "all" || value === null ? undefined : value); | ||
| }} | ||
| > | ||
| <SelectTrigger id="run-filter-status" className="w-full"> | ||
| <SelectValue> | ||
| {status ? getRunStatusConfig(status).label : "All statuses"} | ||
| </SelectValue> | ||
| </SelectTrigger> | ||
| <SelectContent> | ||
| <SelectItem value="all">All statuses</SelectItem> | ||
| {RUN_FILTER_STATUSES.map((value) => ( | ||
| <SelectItem key={value} value={value}> | ||
| {getRunStatusConfig(value).label} | ||
| </SelectItem> | ||
| ))} | ||
| </SelectContent> | ||
| </Select> | ||
| </Field> | ||
| <Field className="w-full sm:w-64"> | ||
| <FieldLabel htmlFor="run-filter-workflow">Workflow name</FieldLabel> | ||
| <Input | ||
| id="run-filter-workflow" | ||
| name="workflowName" | ||
| placeholder="Exact workflow name" | ||
| value={workflowName} | ||
| onChange={(event) => { | ||
| setWorkflowName(event.currentTarget.value); | ||
| }} | ||
| /> | ||
| </Field> | ||
| <Button type="submit">Apply filters</Button> | ||
| <Button | ||
| type="button" | ||
| variant="ghost" | ||
| onClick={() => { | ||
| setStatus(undefined); | ||
| setWorkflowName(""); | ||
| onChange({}); | ||
| }} | ||
| > | ||
| Clear filters | ||
| </Button> | ||
| </form> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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() | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. A workflow created through |
||
| .transform((value) => value || undefined) | ||
| .optional(), | ||
| }); | ||
|
|
||
| export type RunFilters = z.infer<typeof runFiltersSchema>; | ||
| 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 }; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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,18 +18,16 @@ 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"; | ||
| 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() { | |
| </Button> | ||
| </div> | ||
|
|
||
| <WorkflowStats workflowRunCounts={workflowRunCounts} /> | ||
| <div className="space-y-3"> | ||
| <p className="text-muted-foreground text-xs"> | ||
| All runs in this namespace | ||
| </p> | ||
| <WorkflowStats | ||
| workflowRunCounts={workflowRunCounts} | ||
| selectedStatus={search.status} | ||
| onStatusChange={(status) => { | ||
| updateFilters({ status, workflowName: search.workflowName }); | ||
| }} | ||
| /> | ||
| </div> | ||
| <div className="space-y-4"> | ||
| <RunFiltersForm | ||
| key={`${search.status ?? "all"}:${search.workflowName ?? ""}`} | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If a user types a workflow name and then clicks a status card before applying it, their text disappears. The card changes |
||
| filters={search} | ||
| onChange={updateFilters} | ||
| /> | ||
| <RunList | ||
| runs={runs} | ||
| childRunRelationsByRunId={childRunRelationsByRunId} | ||
| showHeader={false} | ||
| emptyMessage={emptyMessage} | ||
| /> | ||
|
|
||
| <ListPagination | ||
|
|
@@ -147,7 +177,13 @@ function HomePage() { | |
| itemCount={runs.length} | ||
| itemName="run" | ||
| onChange={(next) => { | ||
| void navigate({ search: next }); | ||
| void navigate({ | ||
| search: { | ||
| ...next, | ||
| status: search.status, | ||
| workflowName: search.workflowName, | ||
| }, | ||
| }); | ||
| }} | ||
| /> | ||
| </div> | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For databases with older runs stored as
sleepingorsucceeded, clicking Running or Completed excludes runs counted by that card.toWorkflowRunCountsfolds these older statuses into the displayed counts, butlistWorkflowRunsmatches the requested status exactly. The migrations do not rewrite those statuses, so a nonzero card can lead to an empty list. Make the dashboard filters include the same statuses as the counts.