diff --git a/.changeset/lemon-aliens-jog.md b/.changeset/lemon-aliens-jog.md new file mode 100644 index 000000000..597b60922 --- /dev/null +++ b/.changeset/lemon-aliens-jog.md @@ -0,0 +1,5 @@ +--- +"@workflow/world-local": patch +--- + +World-local: filter by workflowName/status if passed diff --git a/.changeset/wide-wombats-own.md b/.changeset/wide-wombats-own.md new file mode 100644 index 000000000..5eabc80f6 --- /dev/null +++ b/.changeset/wide-wombats-own.md @@ -0,0 +1,5 @@ +--- +"@workflow/web": patch +--- + +Web: Allow filtering by workflow name and status on the runs list view diff --git a/packages/web/src/components/runs-table.tsx b/packages/web/src/components/runs-table.tsx index 3e6939e41..37ff4512d 100644 --- a/packages/web/src/components/runs-table.tsx +++ b/packages/web/src/components/runs-table.tsx @@ -1,6 +1,7 @@ 'use client'; import { parseWorkflowName } from '@workflow/core/parse-name'; +import type { WorkflowRunStatus } from '@workflow/world'; import { AlertCircle, ArrowDownAZ, @@ -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, @@ -39,6 +48,15 @@ interface RunsTableProps { onRunClick: (runId: string) => void; } +const statusMap: Record = { + 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. @@ -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( () => 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>( + new Set() + ); + const { data, error, @@ -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 = () => { @@ -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 ; @@ -86,32 +151,100 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { return (
-

- Runs +

+ Runs + {lastRefreshTime && ( + + )}

{ <> - {lastRefreshTime && ( - - )} + - +
+ +
- Note that this resets pages + + {statusFilterRequiresWorkflowNameFilter && + workflowNameFilter === 'all' + ? 'Select a workflow first to filter by status' + : 'Filter runs by status'} +
@@ -135,6 +268,20 @@ export function RunsTable({ config, onRunClick }: RunsTableProps) { : 'Showing oldest first'} + + + + + Note that this resets pages + }
diff --git a/packages/world-local/src/storage.ts b/packages/world-local/src/storage.ts index 95cffd4cb..2292832ca 100644 --- a/packages/world-local/src/storage.ts +++ b/packages/world-local/src/storage.ts @@ -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,