Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions apps/dashboard/src/components/run-filters-form.tsx
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>
);
}
6 changes: 3 additions & 3 deletions apps/dashboard/src/components/run-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export interface RunListProps {
title?: string;
showHeader?: boolean;
showCount?: boolean;
emptyMessage?: string;
}

export function RunList({
Expand All @@ -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 (
Expand All @@ -39,9 +41,7 @@ export function RunList({
</div>
)}
<Card className="bg-card border-border p-8 text-center">
<p className="text-muted-foreground">
No workflow runs have been created yet.
</p>
<p className="text-muted-foreground">{emptyMessage}</p>
</Card>
</div>
);
Expand Down
48 changes: 29 additions & 19 deletions apps/dashboard/src/components/workflow-stats.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Card } from "@/components/ui/card";
import { RUN_FILTER_STATUSES, type RunFilterStatus } from "@/lib/run-filters";
import {
getRunStatusConfig,
getStatusStatCardClass,
Expand All @@ -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 {
Expand All @@ -41,21 +40,32 @@ export function WorkflowStats({ workflowRunCounts }: WorkflowStatsProps) {
<Card
key={stat.label}
className={cn(
"bg-card p-3 transition-colors sm:p-5",
"bg-card p-0 transition-colors",
stat.cardClass,
selectedStatus === stat.status && "ring-foreground/60 ring-2",
)}
>
<div className="flex items-start justify-between">
<div className="space-y-1">
<p className="text-muted-foreground text-xs sm:text-sm">
<button
type="button"
aria-label={`Filter ${stat.label.toLowerCase()} runs`}
aria-pressed={selectedStatus === stat.status}
className="hover:bg-foreground/5 focus-visible:ring-ring flex w-full cursor-pointer items-start justify-between p-3 text-left outline-none focus-visible:ring-2 focus-visible:ring-inset sm:p-5"
onClick={() => {
onStatusChange(
selectedStatus === stat.status ? undefined : stat.status,
);
}}
>
<span className="space-y-1">
<span className="text-muted-foreground block text-xs sm:text-sm">
{stat.label}
</p>
<p className="font-mono text-2xl font-semibold sm:text-3xl">
</span>
<span className="block font-mono text-2xl font-semibold sm:text-3xl">
{stat.value}
</p>
</div>
</span>
</span>
<Icon className={cn("size-4 sm:size-5", stat.iconClass)} />
</div>
</button>
</Card>
);
})}
Expand Down
12 changes: 9 additions & 3 deletions apps/dashboard/src/lib/api.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<PaginatedResponse<WorkflowRun>> => {
const backend = await getBackend();
const result = await backend.listWorkflowRuns(getPaginationOptions(data));
const params: ListWorkflowRunsParams = getPaginationOptions(data);
if (data.status !== undefined) params.status = data.status;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Filters hide older runs

For databases with older runs stored as sleeping or succeeded, clicking Running or Completed excludes runs counted by that card. toWorkflowRunCounts folds these older statuses into the displayed counts, but listWorkflowRuns matches 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.

if (data.workflowName !== undefined)
params.workflowName = data.workflowName;
const result = await backend.listWorkflowRuns(params);
return result;
});

Expand Down
37 changes: 37 additions & 0 deletions apps/dashboard/src/lib/run-filters.ts
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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Exact names stop matching

A workflow created through runWorkflow can have a name such as " report ", which the client stores unchanged. Both RunFiltersForm and runFiltersSchema trim that name to "report", so the backend’s exact match cannot find it. Users must be able to search for the name as stored; preserve meaningful spaces in both places.

.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 };
}
50 changes: 43 additions & 7 deletions apps/dashboard/src/routes/index.tsx
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 {
Expand All @@ -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 }) => {
Expand All @@ -40,6 +39,8 @@ export const Route = createFileRoute("/")({
limit,
after: deps.after,
before: deps.before,
status: deps.status,
workflowName: deps.workflowName,
},
}),
getWorkflowRunCountsServerFn(),
Expand Down Expand Up @@ -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();

Expand All @@ -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 ?? ""}`}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Card clicks erase typed names

If a user types a workflow name and then clicks a status card before applying it, their text disappears. The card changes search.status, which changes this key and mounts a fresh RunFiltersForm. Its input resets to the saved search.workflowName rather than the pending edit. Keep the typed name when changing the selected status so users do not have to enter it again.

filters={search}
onChange={updateFilters}
/>
<RunList
runs={runs}
childRunRelationsByRunId={childRunRelationsByRunId}
showHeader={false}
emptyMessage={emptyMessage}
/>

<ListPagination
Expand All @@ -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>
Expand Down
Loading