diff --git a/.changeset/workflows-local-explorer.md b/.changeset/workflows-local-explorer.md new file mode 100644 index 0000000000..0d3ce6ea56 --- /dev/null +++ b/.changeset/workflows-local-explorer.md @@ -0,0 +1,16 @@ +--- +"miniflare": minor +"@cloudflare/workflows-shared": patch +--- + +Add Workflows support to the local explorer UI. + +The local explorer (`/cdn-cgi/explorer/`) now includes a full Workflows dashboard for viewing and managing workflow instances during local development. + +UI features: + +- Workflow instance list with status badges, creation time, action buttons, and pagination +- Status summary bar with instance counts per status +- Status filter dropdown and search +- Instance detail page with step history, params/output cards, error display, and expandable step details +- Create instance dialog with optional ID and JSON params diff --git a/packages/local-explorer-ui/src/assets/icons/workflows.svg b/packages/local-explorer-ui/src/assets/icons/workflows.svg new file mode 100644 index 0000000000..04c8ccb8d8 --- /dev/null +++ b/packages/local-explorer-ui/src/assets/icons/workflows.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/local-explorer-ui/src/components/Sidebar.tsx b/packages/local-explorer-ui/src/components/Sidebar.tsx index 1da117625a..5bfbdeb88e 100644 --- a/packages/local-explorer-ui/src/components/Sidebar.tsx +++ b/packages/local-explorer-ui/src/components/Sidebar.tsx @@ -6,12 +6,14 @@ import D1Icon from "../assets/icons/d1.svg?react"; import DOIcon from "../assets/icons/durable-objects.svg?react"; import KVIcon from "../assets/icons/kv.svg?react"; import R2Icon from "../assets/icons/r2.svg?react"; +import WorkflowsIcon from "../assets/icons/workflows.svg?react"; import { WorkerSelector, type LocalExplorerWorker } from "./WorkerSelector"; import type { D1DatabaseResponse, R2Bucket, WorkersKvNamespace, WorkersNamespace, + WorkflowsWorkflow, } from "../api"; import type { FileRouteTypes } from "../routeTree.gen"; import type { FC } from "react"; @@ -101,6 +103,8 @@ interface SidebarProps { workers: LocalExplorerWorker[]; selectedWorker: string; onWorkerChange: (workerName: string) => void; + workflows: WorkflowsWorkflow[]; + workflowsError: string | null; } export function Sidebar({ @@ -116,6 +120,8 @@ export function Sidebar({ workers, selectedWorker, onWorkerChange, + workflows, + workflowsError, }: SidebarProps) { const showWorkerSelector = workers.length > 1; @@ -225,6 +231,24 @@ export function Sidebar({ })} title="R2 Buckets" /> + ({ + id: wf.name as string, + isActive: + currentPath === `/workflows/${wf.name}` || + currentPath.startsWith(`/workflows/${wf.name}/`), + label: wf.name as string, + link: { + params: { workflowName: wf.name }, + search: workerSearch, + to: "/workflows/$workflowName", + }, + }))} + title="Workflows" + /> ); } diff --git a/packages/local-explorer-ui/src/components/workflows/CopyButton.tsx b/packages/local-explorer-ui/src/components/workflows/CopyButton.tsx new file mode 100644 index 0000000000..4daeb57dfe --- /dev/null +++ b/packages/local-explorer-ui/src/components/workflows/CopyButton.tsx @@ -0,0 +1,33 @@ +import { CheckIcon, CopyIcon } from "@phosphor-icons/react"; +import { useState } from "react"; + +export function CopyButton({ + text, + label = "Copy", +}: { + text: string; + label?: string; +}): JSX.Element { + const [copied, setCopied] = useState(false); + + function handleCopy(): void { + void navigator.clipboard.writeText(text).then(() => { + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }); + } + + return ( + + ); +} diff --git a/packages/local-explorer-ui/src/components/workflows/CreateInstanceDialog.tsx b/packages/local-explorer-ui/src/components/workflows/CreateInstanceDialog.tsx new file mode 100644 index 0000000000..d1eb50dd64 --- /dev/null +++ b/packages/local-explorer-ui/src/components/workflows/CreateInstanceDialog.tsx @@ -0,0 +1,180 @@ +import { Button, Dialog } from "@cloudflare/kumo"; +import { useCallback, useState } from "react"; +import { workflowsCreateInstance } from "../../api"; + +interface CreateWorkflowInstanceDialogProps { + onCreated: () => void; + onOpenChange: (open: boolean) => void; + open: boolean; + workflowName: string; +} + +export function CreateWorkflowInstanceDialog({ + onCreated, + onOpenChange, + open, + workflowName, +}: CreateWorkflowInstanceDialogProps): JSX.Element { + const [creating, setCreating] = useState(false); + const [error, setError] = useState(null); + const [instanceId, setInstanceId] = useState(""); + const [params, setParams] = useState(""); + const [paramsError, setParamsError] = useState(null); + + const resetForm = useCallback(() => { + setInstanceId(""); + setParams(""); + setError(null); + setParamsError(null); + }, []); + + function handleOpenChange(newOpen: boolean): void { + if (!newOpen) { + resetForm(); + } + onOpenChange(newOpen); + } + + function parseParams(): { valid: true; value: unknown } | { valid: false } { + if (!params.trim()) { + setParamsError(null); + return { valid: true, value: undefined }; + } + try { + const parsed = JSON.parse(params) as unknown; + setParamsError(null); + return { valid: true, value: parsed }; + } catch { + setParamsError("Invalid JSON"); + return { valid: false }; + } + } + + async function handleCreate(): Promise { + const result = parseParams(); + if (!result.valid) { + return; + } + + setCreating(true); + setError(null); + + try { + const body: { id?: string; params?: unknown } = {}; + if (instanceId.trim()) { + body.id = instanceId.trim(); + } + if (result.value !== undefined) { + body.params = result.value; + } + + await workflowsCreateInstance({ + path: { workflow_name: workflowName }, + body, + }); + + resetForm(); + onCreated(); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to create instance" + ); + } finally { + setCreating(false); + } + } + + return ( + + + {/* Header */} +
+ {/* @ts-expect-error - Type mismatch due to pnpm monorepo @types/react version conflict */} + + Trigger this workflow? + +

+ Manually trigger an instance of this Workflow using the payload + below. +

+
+ + {/* Body */} +
+ {error && ( +
+ {error} +
+ )} + + {/* Instance ID */} +
+ + setInstanceId(e.target.value)} + placeholder="Auto-generated UUID if empty" + type="text" + value={instanceId} + /> +
+ + {/* Params */} +
+ +