Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/lemon-aliens-jog.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/world-local": patch
---

World-local: filter by workflowName/status if passed
5 changes: 5 additions & 0 deletions .changeset/wide-wombats-own.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@workflow/web": patch
---

Web: Allow filtering by workflow name and status on the runs list view
187 changes: 167 additions & 20 deletions packages/web/src/components/runs-table.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
'use client';

import { parseWorkflowName } from '@workflow/core/parse-name';
import type { WorkflowRunStatus } from '@workflow/world';
import {
AlertCircle,
ArrowDownAZ,
Expand All @@ -9,10 +10,18 @@ import {
ChevronRight,
RefreshCw,
} from 'lucide-react';
import { useMemo, useState } from 'react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { DocsLink } from '@/components/ui/docs-link';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
Expand All @@ -39,6 +48,15 @@ interface RunsTableProps {
onRunClick: (runId: string) => void;
}

const statusMap: Record<WorkflowRunStatus, { label: string; color: string }> = {
pending: { label: 'Pending', color: 'bg-neutral-600 dark:bg-neutral-400' },
running: { label: 'Running', color: 'bg-blue-600 dark:bg-blue-400' },
completed: { label: 'Completed', color: 'bg-green-600 dark:bg-green-400' },
failed: { label: 'Failed', color: 'bg-red-600 dark:bg-red-400' },
paused: { label: 'Paused', color: 'bg-yellow-600 dark:bg-yellow-400' },
cancelled: { label: 'Cancelled', color: 'bg-gray-600 dark:bg-gray-400' },
};

/**
* RunsTable - Displays workflow runs with server-side pagination.
* Uses the PaginatingTable pattern: fetches data for each page as needed from the server.
Expand All @@ -48,12 +66,33 @@ interface RunsTableProps {
* which fetches all data upfront and paginates client-side.
*/
export function RunsTable({ config, onRunClick }: RunsTableProps) {
const router = useRouter();
const pathname = usePathname();
const searchParams = useSearchParams();
// Validate status parameter - only allow known valid statuses or 'all'
const rawStatus = searchParams.get('status');
const validStatuses = Object.keys(statusMap) as WorkflowRunStatus[];
const status: WorkflowRunStatus | 'all' | undefined =
rawStatus === 'all' ||
(rawStatus && validStatuses.includes(rawStatus as WorkflowRunStatus))
? (rawStatus as WorkflowRunStatus | 'all')
: undefined;
const workflowNameFilter = searchParams.get('workflow') as string | 'all';
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
const [lastRefreshTime, setLastRefreshTime] = useState<Date | null>(
() => new Date()
);
const env = useMemo(() => worldConfigToEnvMap(config), [config]);

// TODO: World-vercel doesn't support filtering by status without a workflow name filter
const statusFilterRequiresWorkflowNameFilter =
config.backend?.includes('vercel');
// TODO: This is a workaround. We should be getting a list of valid workflow names
// from the manifest, which we need to put on the World interface.
const [seenWorkflowNames, setSeenWorkflowNames] = useState<Set<string>>(
new Set()
);

const {
data,
error,
Expand All @@ -65,8 +104,24 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
pageInfo,
} = useWorkflowRuns(env, {
sortOrder,
workflowName: workflowNameFilter === 'all' ? undefined : workflowNameFilter,
status: status === 'all' ? undefined : status,
});

// Track seen workflow names from loaded data
useEffect(() => {
if (data.data && data.data.length > 0) {
const newNames = new Set(data.data.map((run) => run.workflowName));
setSeenWorkflowNames((prev) => {
const updated = new Set(prev);
for (const name of newNames) {
updated.add(name);
}
return updated;
});
}
}, [data.data]);

const loading = data.isLoading;

const onReload = () => {
Expand All @@ -78,6 +133,16 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
setSortOrder((prev) => (prev === 'desc' ? 'asc' : 'desc'));
};

const createQueryString = useCallback(
(name: string, value: string) => {
const params = new URLSearchParams(searchParams.toString());
params.set(name, value);

return params.toString();
},
[searchParams]
);

// Show skeleton for initial load
if (loading && !data?.data) {
return <TableSkeleton title="Runs" />;
Expand All @@ -86,32 +151,100 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
return (
<div>
<div className="flex items-center justify-between my-4">
<h2 className="text-2xl my-2 font-semibold leading-none tracking-tight">
Runs
<h2 className="text-2xl my-2 font-semibold leading-none tracking-tight flex gap-4 items-end">
<span className="flex items-center gap-2">Runs</span>
{lastRefreshTime && (
<RelativeTime
date={lastRefreshTime}
className="text-sm text-muted-foreground"
type="distance"
/>
)}
</h2>
<div className="flex items-center gap-4">
{
<>
{lastRefreshTime && (
<RelativeTime
date={lastRefreshTime}
className="text-sm text-muted-foreground"
type="distance"
/>
)}
<Select
value={workflowNameFilter ?? 'all'}
onValueChange={(value) => {
if (value === 'all') {
const params = new URLSearchParams(searchParams.toString());
params.delete('workflow');
params.delete('status');
router.push(`${pathname}?${params.toString()}`);
} else {
router.push(
`${pathname}?${createQueryString('workflow', value)}`
);
}
}}
disabled={loading}
>
<SelectTrigger className="w-[180px] h-9">
<SelectValue placeholder="Filter by workflow" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All Workflows</SelectItem>
{Array.from(seenWorkflowNames)
.sort()
.map((name) => (
<SelectItem key={name} value={name}>
{parseWorkflowName(name)?.shortName || name}
</SelectItem>
))}
</SelectContent>
</Select>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onReload}
disabled={loading}
>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</Button>
<div>
<Select
value={status || 'all'}
onValueChange={(value) => {
if (value === 'all') {
const params = new URLSearchParams(
searchParams.toString()
);
params.delete('status');
router.push(`${pathname}?${params.toString()}`);
} else {
router.push(
`${pathname}?${createQueryString('status', value)}`
);
}
}}
disabled={
loading ||
(statusFilterRequiresWorkflowNameFilter &&
!workflowNameFilter)
}
>
<SelectTrigger className="w-[140px] h-9">
<SelectValue placeholder="Filter by status" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">All</SelectItem>
{Object.entries(statusMap).map(
([status, { label, color }]) => (
<SelectItem key={status} value={status}>
<div className="flex items-center">
<span
className={`${color} size-1.5 rounded-full mr-2`}
/>
{label}
</div>
</SelectItem>
)
)}
</SelectContent>
</Select>
</div>
</TooltipTrigger>
<TooltipContent>Note that this resets pages</TooltipContent>
<TooltipContent>
{statusFilterRequiresWorkflowNameFilter &&
workflowNameFilter === 'all'
? 'Select a workflow first to filter by status'
: 'Filter runs by status'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
Expand All @@ -135,6 +268,20 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) {
: 'Showing oldest first'}
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="sm"
onClick={onReload}
disabled={loading}
>
<RefreshCw className={loading ? 'animate-spin' : ''} />
Refresh
</Button>
</TooltipTrigger>
<TooltipContent>Note that this resets pages</TooltipContent>
</Tooltip>
</>
}
</div>
Expand Down
15 changes: 12 additions & 3 deletions packages/world-local/src/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,18 @@ export function createStorage(basedir: string): Storage {
const result = await paginatedFileSystemQuery({
directory: path.join(basedir, 'runs'),
schema: WorkflowRunSchema,
filter: params?.workflowName
? (run) => run.workflowName === params.workflowName
: undefined,
filter: (run) => {
if (
params?.workflowName &&
run.workflowName !== params.workflowName
) {
return false;
}
if (params?.status && run.status !== params.status) {
return false;
}
return true;
},
sortOrder: params?.pagination?.sortOrder ?? 'desc',
limit: params?.pagination?.limit,
cursor: params?.pagination?.cursor,
Expand Down