Skip to content
Merged
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
16 changes: 16 additions & 0 deletions .changeset/workflows-local-explorer.md
Original file line number Diff line number Diff line change
@@ -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
3 changes: 3 additions & 0 deletions packages/local-explorer-ui/src/assets/icons/workflows.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
24 changes: 24 additions & 0 deletions packages/local-explorer-ui/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -101,6 +103,8 @@ interface SidebarProps {
workers: LocalExplorerWorker[];
selectedWorker: string;
onWorkerChange: (workerName: string) => void;
workflows: WorkflowsWorkflow[];
workflowsError: string | null;
}

export function Sidebar({
Expand All @@ -116,6 +120,8 @@ export function Sidebar({
workers,
selectedWorker,
onWorkerChange,
workflows,
workflowsError,
}: SidebarProps) {
const showWorkerSelector = workers.length > 1;

Expand Down Expand Up @@ -225,6 +231,24 @@ export function Sidebar({
})}
title="R2 Buckets"
/>
<SidebarItemGroup
emptyLabel="No workflows"
error={workflowsError}
icon={WorkflowsIcon}
items={workflows.map((wf) => ({
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",
},
Comment thread
pombosilva marked this conversation as resolved.
}))}
title="Workflows"
/>
</aside>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<button
className="inline-flex size-7 cursor-pointer items-center justify-center rounded-md text-text-secondary transition-colors hover:bg-border/60"
onClick={handleCopy}
title={label}
>
{copied ? (
<CheckIcon size={14} className="text-[#00c950]" />
) : (
<CopyIcon size={14} />
)}
</button>
);
}
Original file line number Diff line number Diff line change
@@ -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<boolean>(false);
const [error, setError] = useState<string | null>(null);
const [instanceId, setInstanceId] = useState<string>("");
const [params, setParams] = useState<string>("");
const [paramsError, setParamsError] = useState<string | null>(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<void> {
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 (
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
<Dialog size="lg">
{/* Header */}
<div className="border-b border-border px-6 pt-6 pb-4">
{/* @ts-expect-error - Type mismatch due to pnpm monorepo @types/react version conflict */}
<Dialog.Title className="text-lg font-semibold text-text">
Trigger this workflow?
</Dialog.Title>
<p className="mt-1 text-sm text-text-secondary">
Manually trigger an instance of this Workflow using the payload
below.
</p>
</div>

{/* Body */}
<div className="px-6 py-6">
{error && (
<div className="mb-5 rounded-lg border border-danger/20 bg-danger/8 p-3 text-sm text-danger">
{error}
</div>
)}

{/* Instance ID */}
<div className="mb-5">
<label className="mb-2 block text-sm font-medium text-text">
Instance ID{" "}
<span className="font-normal text-text-secondary">
(optional)
</span>
</label>
<input
className="w-full rounded-lg border border-border bg-bg px-3 py-2.5 text-sm text-text placeholder-text-secondary! focus:border-primary focus:shadow-focus-primary focus:outline-none"
onChange={(e) => setInstanceId(e.target.value)}
placeholder="Auto-generated UUID if empty"
type="text"
value={instanceId}
/>
</div>

{/* Params */}
<div>
<label className="mb-2 block text-sm font-medium text-text">
Params
</label>
<textarea
className={`w-full resize-y rounded-lg border bg-bg px-3 py-2.5 font-mono text-sm text-text placeholder-text-secondary! focus:shadow-focus-primary focus:outline-none ${
paramsError
? "border-danger focus:border-danger"
: "border-border focus:border-primary"
}`}
onChange={(e) => {
setParams(e.target.value);
if (paramsError) {
setParamsError(null);
}
}}
placeholder='{"key": "value"}'
rows={8}
value={params}
/>
{paramsError ? (
<p className="mt-1 text-xs text-danger">{paramsError}</p>
) : (
<p className="mt-1 text-xs text-text-secondary">
JSON payload passed to the workflow
</p>
)}
</div>
</div>

{/* Footer */}
<div className="flex justify-end gap-2 border-t border-border px-6 py-4">
<Button
variant="secondary"
onClick={() => handleOpenChange(false)}
disabled={creating}
>
Cancel
</Button>

<Button
variant="primary"
disabled={creating}
loading={creating}
onClick={handleCreate}
>
{creating ? "Triggering..." : "Trigger Instance"}
</Button>
</div>
</Dialog>
</Dialog.Root>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
export function ScrollableCodeBlock({
content,
}: {
content: string;
}): JSX.Element {
return (
<pre className="max-h-64 overflow-y-auto px-4 py-3 font-mono text-xs break-words whitespace-pre-wrap text-text">
{content}
</pre>
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { cn } from "@cloudflare/kumo";
import type { WorkflowsInstance } from "../../api";

type WorkflowStatus = NonNullable<WorkflowsInstance["status"]>;

/*
* Color scheme matching the Cloudflare dashboard.
* Light: soft pastel bg + dark text. Dark: vibrant saturated bg + dark text.
* Tailwind arbitrary values with dark: variant for proper theme switching.
*/
const statusStyles: Record<WorkflowStatus, string> = {
complete: "bg-[#059669] text-[#fffdfb] dark:bg-[#008236] dark:text-[#ffeee6]",
errored: "bg-[#fb2b36] text-[#fffdfb] dark:bg-[#c10007] dark:text-[#ffeee6]",
terminated:
"bg-[#fb2b36] text-[#fffdfb] dark:bg-[#c10007] dark:text-[#ffeee6]",
waiting: "bg-[#cf7ee9] text-[#fffdfb] dark:bg-[#831ca6] dark:text-[#ffeee6]",
paused: "bg-[#cf7ee9] text-[#fffdfb] dark:bg-[#831ca6] dark:text-[#ffeee6]",
running: "bg-[#2b7bfb] text-[#fffdfb] dark:bg-[#004ac2] dark:text-[#ffeee6]",
waitingForPause:
"bg-[#2b7bfb] text-[#fffdfb] dark:bg-[#004ac2] dark:text-[#ffeee6]",
queued: "bg-[#d9d9d9] text-[#fffdfb] dark:bg-[#b6b6b6] dark:text-[#ffeee6]",
unknown: "bg-[#d9d9d9] text-[#fffdfb] dark:bg-[#b6b6b6] dark:text-[#ffeee6]",
};

const statusLabels: Record<WorkflowStatus, string> = {
queued: "Queued",
running: "Running",
paused: "Paused",
errored: "Errored",
terminated: "Terminated",
complete: "Complete",
waitingForPause: "Waiting for Pause",
waiting: "Waiting",
unknown: "Unknown",
};

interface WorkflowStatusBadgeProps {
status: WorkflowStatus | string | undefined;
}

export function WorkflowStatusBadge({
status,
}: WorkflowStatusBadgeProps): JSX.Element {
const resolvedStatus = (
status && status in statusStyles ? status : "unknown"
) as WorkflowStatus;

return (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs",
statusStyles[resolvedStatus]
)}
>
{statusLabels[resolvedStatus]}
</span>
);
}
Loading
Loading