diff --git a/apps/dashboard/src/components/app-layout.tsx b/apps/dashboard/src/components/app-layout.tsx index 32e4f74e..acf9350d 100644 --- a/apps/dashboard/src/components/app-layout.tsx +++ b/apps/dashboard/src/components/app-layout.tsx @@ -23,10 +23,27 @@ export function AppLayout({ children }: AppLayoutProps) { - + + diff --git a/apps/dashboard/src/components/list-pagination.tsx b/apps/dashboard/src/components/list-pagination.tsx new file mode 100644 index 00000000..c8a01859 --- /dev/null +++ b/apps/dashboard/src/components/list-pagination.tsx @@ -0,0 +1,62 @@ +import { CursorPaginationControls } from "./cursor-pagination-controls"; +import { Button } from "./ui/button"; +import { + RUNS_PAGE_SIZE_OPTIONS, + resolveRunsPageSize, + shouldShowPaginationControls, + type RunsPaginationSearch, +} from "@/lib/runs-page-pagination"; +import type { PaginatedResponse } from "openworkflow/internal"; + +interface ListPaginationProps { + pagination: PaginatedResponse["pagination"]; + search: RunsPaginationSearch; + itemCount: number; + itemName: string; + onChange: (search: RunsPaginationSearch) => void; +} + +export function ListPagination({ + pagination, + search, + itemCount, + itemName, + onChange, +}: ListPaginationProps) { + if (itemCount === 0 && (search.after ?? search.before)) { + return ( +
+ +
+ ); + } + if (!shouldShowPaginationControls(pagination)) return null; + return ( + { + onChange({ limit: search.limit, after: pagination.next ?? undefined }); + }} + onPreviousPage={() => { + onChange({ limit: search.limit, before: pagination.prev ?? undefined }); + }} + onPageSizeChange={(limit) => { + onChange({ limit }); + }} + /> + ); +} diff --git a/apps/dashboard/src/components/schedule-actions.tsx b/apps/dashboard/src/components/schedule-actions.tsx new file mode 100644 index 00000000..b59cb3a1 --- /dev/null +++ b/apps/dashboard/src/components/schedule-actions.tsx @@ -0,0 +1,131 @@ +import { ScheduleEditor } from "./schedule-editor"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "./ui/alert-dialog"; +import { Button } from "./ui/button"; +import { + deleteWorkflowScheduleServerFn, + setWorkflowSchedulePausedServerFn, +} from "@/lib/api"; +import { PauseIcon, PlayIcon, TrashIcon } from "@phosphor-icons/react"; +import { useNavigate, useRouter } from "@tanstack/react-router"; +import type { WorkflowSchedule } from "openworkflow"; +import { useState } from "react"; + +// oxlint-disable-next-line max-lines-per-function -- Keep the confirmation and mutation state together, as in RunAction. +export function ScheduleActions({ schedule }: { schedule: WorkflowSchedule }) { + const router = useRouter(); + const navigate = useNavigate(); + const [deleting, setDeleting] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const paused = schedule.nextRunAt === null; + const toggleLabel = paused ? "Resume Schedule" : "Pause Schedule"; + + async function performAction(action: "toggle" | "delete") { + setPending(true); + setError(null); + try { + const data = { workflowScheduleId: schedule.id }; + if (action === "delete") { + await deleteWorkflowScheduleServerFn({ data }); + await navigate({ to: "/schedules" }); + } else { + await setWorkflowSchedulePausedServerFn({ + data: { ...data, paused: !paused }, + }); + } + await router.invalidate(); + setDeleting(false); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Unable to update schedule", + ); + } finally { + setPending(false); + } + } + + return ( +
+
+ + + { + if (!pending) { + setDeleting(next); + setError(null); + } + }} + > + + + + Delete schedule? + + Delete “{schedule.id}” to stop future scheduled runs. Existing + runs are kept. If you define this schedule in code, remove it + there too so the next worker startup doesn't recreate it. + + + {error && ( +

+ {error} +

+ )} + + + Keep Schedule + + { + event.preventDefault(); + void performAction("delete"); + }} + > + {pending ? "Deleting..." : "Delete Schedule"} + + +
+
+
+ {error && !deleting && ( +

+ {error} +

+ )} +
+ ); +} diff --git a/apps/dashboard/src/components/schedule-editor.tsx b/apps/dashboard/src/components/schedule-editor.tsx new file mode 100644 index 00000000..93abf13f --- /dev/null +++ b/apps/dashboard/src/components/schedule-editor.tsx @@ -0,0 +1,221 @@ +import { Button } from "./ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "./ui/dialog"; +import { Field, FieldDescription, FieldGroup, FieldLabel } from "./ui/field"; +import { Input } from "./ui/input"; +import { MonacoJsonEditor } from "./ui/monaco-json-editor"; +import { saveWorkflowScheduleServerFn } from "@/lib/api"; +import { PencilSimpleIcon, PlusIcon } from "@phosphor-icons/react"; +import { useNavigate, useRouter } from "@tanstack/react-router"; +import type { WorkflowSchedule } from "openworkflow"; +import { useState } from "react"; + +// oxlint-disable-next-line max-lines-per-function -- Keep the dialog, form state, and submit lifecycle together, as in CreateRunForm. +export function ScheduleEditor({ + schedule, + disabled = false, +}: { + schedule?: WorkflowSchedule; + disabled?: boolean; +}) { + const navigate = useNavigate(); + const router = useRouter(); + const [open, setOpen] = useState(false); + const [pending, setPending] = useState(false); + const [error, setError] = useState(null); + const [input, setInput] = useState(""); + const submitLabel = schedule ? "Save Changes" : "Create Schedule"; + + function changeOpen(next: boolean) { + if (pending) return; + if (next && !open) { + setError(null); + setInput( + schedule?.input === undefined || schedule.input === null + ? "" + : JSON.stringify(schedule.input, null, 2), + ); + } + setOpen(next); + } + + async function save(event: React.SyntheticEvent) { + event.preventDefault(); + const form = new FormData(event.currentTarget); + const workflowName = readFormField(form, "workflowName").trim(); + setPending(true); + setError(null); + try { + const saved = await saveWorkflowScheduleServerFn({ + data: { + id: + schedule?.id ?? (readFormField(form, "id").trim() || workflowName), + workflowName, + cron: readFormField(form, "cron"), + timezone: readFormField(form, "timezone"), + input, + mode: schedule ? "edit" : "create", + }, + }); + await router.invalidate(); + await navigate({ + to: "/schedules/$scheduleId", + params: { scheduleId: saved.id }, + }); + setOpen(false); + } catch (cause) { + setError( + cause instanceof Error ? cause.message : "Unable to save schedule", + ); + } finally { + setPending(false); + } + } + + return ( + + + + + + {schedule ? "Edit Schedule" : "Create Schedule"} + + + {schedule + ? "Changes apply to future runs. Paused schedules stay paused." + : "Run a workflow automatically on a recurring schedule."} + + + {open && ( +
{ + void save(event); + }} + className="grid gap-5 p-5" + > + + + + Input (JSON) + + + Each scheduled run uses this input. + + + + {error && ( +

+ {error} +

+ )} +
+ + +
+
+ )} +
+
+ ); +} + +function ScheduleFields({ + schedule, + pending, +}: { + schedule: WorkflowSchedule | undefined; + pending: boolean; +}) { + const fields = [ + { + name: "workflowName", + label: "Workflow Name *", + value: schedule?.workflowName ?? "", + placeholder: "e.g. daily-report", + description: "Use a workflow registered with a worker.", + }, + { + name: "id", + label: "Schedule ID", + value: schedule?.id ?? "", + placeholder: "Defaults to workflow name", + description: schedule + ? "The ID cannot be changed." + : "Use a unique ID for each schedule of the same workflow.", + }, + { + name: "cron", + label: "Cron Expression *", + value: schedule?.cron ?? "0 9 * * 1-5", + placeholder: "0 9 * * 1-5", + description: + "Minute, hour, day, month, weekday. Add seconds as an optional sixth field at the beginning.", + }, + { + name: "timezone", + label: "Time Zone *", + value: schedule?.timezone ?? "UTC", + placeholder: "America/Chicago", + description: "An IANA time zone, such as America/Chicago or UTC.", + }, + ]; + return ( +
+ {fields.map((field) => ( + + + {field.label} + + + {field.description} + + ))} +
+ ); +} + +function readFormField(form: FormData, name: string): string { + // safety: ScheduleFields renders named text inputs only, with no file inputs. + return (form.get(name) as string | null) ?? ""; +} diff --git a/apps/dashboard/src/components/schedule-status.tsx b/apps/dashboard/src/components/schedule-status.tsx new file mode 100644 index 00000000..b0116336 --- /dev/null +++ b/apps/dashboard/src/components/schedule-status.tsx @@ -0,0 +1,36 @@ +import { Badge } from "./ui/badge"; +import type { WorkflowSchedule } from "openworkflow"; + +export function ScheduleStatus({ schedule }: { schedule: WorkflowSchedule }) { + return ( + + + {schedule.nextRunAt === null ? "Paused" : "Active"} + + ); +} + +export function ScheduleNextRun({ schedule }: { schedule: WorkflowSchedule }) { + if (!schedule.nextRunAt) + return ( + + No upcoming runs while paused + + ); + return ( + + ); +} diff --git a/apps/dashboard/src/lib/api.ts b/apps/dashboard/src/lib/api.ts index fa2cb945..6ad1832f 100644 --- a/apps/dashboard/src/lib/api.ts +++ b/apps/dashboard/src/lib/api.ts @@ -1,4 +1,6 @@ import { getBackend } from "./backend"; +import { parseWorkflowInput } from "./input"; +import { saveWorkflowSchedule, scheduleInputSchema } from "./schedules.server"; import { createServerFn } from "@tanstack/react-start"; import { rerunWorkflowRun } from "openworkflow/internal"; import type { @@ -158,33 +160,55 @@ export const createWorkflowRunServerFn = createServerFn({ method: "POST" }) const version = versionValue === undefined || versionValue === "" ? null : versionValue; - const inputValue = data.input?.trim(); - const normalizedInputValue = - inputValue === undefined || inputValue === "" ? null : inputValue; - const availableAt = parseOptionalDate(data.availableAt, "Schedule for"); const deadlineAt = parseOptionalDate(data.deadlineAt, "Deadline"); - let parsedInput: WorkflowRun["input"] = null; - if (normalizedInputValue) { - try { - // safety: JSON.parse without a reviver returns the JSON values accepted by workflow input. - parsedInput = JSON.parse(normalizedInputValue) as WorkflowRun["input"]; - } catch { - throw new TypeError("Input must be valid JSON"); - } - } - return backend.createWorkflowRun({ workflowName: data.workflowName, version, idempotencyKey: null, config: {}, context: null, - input: parsedInput, + input: parseWorkflowInput(data.input), parentStepAttemptNamespaceId: null, parentStepAttemptId: null, availableAt, deadlineAt, }); }); + +export const listWorkflowSchedulesServerFn = createServerFn({ method: "GET" }) + .validator(z.object(paginationInputFields)) + .handler(async ({ data }) => { + const backend = await getBackend(); + return backend.listWorkflowSchedules(getPaginationOptions(data)); + }); + +const scheduleIdInput = z.object({ workflowScheduleId: z.string().min(1) }); + +export const getWorkflowScheduleServerFn = createServerFn({ method: "GET" }) + .validator(scheduleIdInput) + .handler(async ({ data }) => { + const backend = await getBackend(); + return backend.getWorkflowSchedule(data); + }); + +export const saveWorkflowScheduleServerFn = createServerFn({ method: "POST" }) + .validator(scheduleInputSchema) + .handler(async ({ data }) => saveWorkflowSchedule(await getBackend(), data)); + +export const setWorkflowSchedulePausedServerFn = createServerFn({ + method: "POST", +}) + .validator(scheduleIdInput.extend({ paused: z.boolean() })) + .handler(async ({ data }) => { + const backend = await getBackend(); + return backend.pauseWorkflowSchedule(data); + }); + +export const deleteWorkflowScheduleServerFn = createServerFn({ method: "POST" }) + .validator(scheduleIdInput) + .handler(async ({ data }) => { + const backend = await getBackend(); + return backend.deleteWorkflowSchedule(data); + }); diff --git a/apps/dashboard/src/lib/input.ts b/apps/dashboard/src/lib/input.ts new file mode 100644 index 00000000..3f4cbecd --- /dev/null +++ b/apps/dashboard/src/lib/input.ts @@ -0,0 +1,18 @@ +import type { WorkflowRun } from "openworkflow/internal"; + +/** + * Parse an optional JSON workflow payload. Empty input means no payload. + * @param value - JSON input from a dashboard form + * @returns Parsed workflow input + */ +export function parseWorkflowInput( + value: string | null | undefined, +): WorkflowRun["input"] { + if (!value?.trim()) return null; + try { + // safety: JSON.parse without a reviver produces JSON-compatible workflow input. + return JSON.parse(value) as WorkflowRun["input"]; + } catch { + throw new TypeError("Input must be valid JSON"); + } +} diff --git a/apps/dashboard/src/lib/schedules.server.test.ts b/apps/dashboard/src/lib/schedules.server.test.ts new file mode 100644 index 00000000..b21c70a8 --- /dev/null +++ b/apps/dashboard/src/lib/schedules.server.test.ts @@ -0,0 +1,73 @@ +import { saveWorkflowSchedule } from "./schedules.server"; +import { BackendSqlite } from "openworkflow/sqlite"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +describe("dashboard schedule administration", () => { + let backend: BackendSqlite; + const data = { + id: "daily-report", + workflowName: "report", + cron: "0 9 * * 1-5", + timezone: "America/Chicago", + input: '{"accountId":"acme"}', + mode: "create" as const, + }; + + beforeEach(() => { + backend = BackendSqlite.connect(":memory:"); + }); + afterEach(async () => { + await backend.stop(); + }); + + it("creates a validated schedule and updates it without resuming a paused schedule", async () => { + const created = await saveWorkflowSchedule(backend, data); + expect(created).toMatchObject({ + id: data.id, + workflowName: data.workflowName, + timezone: data.timezone, + input: { accountId: "acme" }, + }); + expect(created.nextRunAt?.getTime()).toBeGreaterThan(Date.now()); + await backend.pauseWorkflowSchedule({ + workflowScheduleId: data.id, + paused: true, + }); + const edited = await saveWorkflowSchedule(backend, { + ...data, + mode: "edit", + cron: "30 10 * * *", + input: '{"accountId":"updated"}', + }); + expect(edited.nextRunAt).toBeNull(); + expect(edited.createdAt).toEqual(created.createdAt); + expect(edited.input).toEqual({ accountId: "updated" }); + }); + + it("rejects invalid JSON without persisting it", async () => { + await expect( + saveWorkflowSchedule(backend, { ...data, input: "{invalid}" }), + ).rejects.toThrow("Input must be valid JSON"); + expect( + await backend.getWorkflowSchedule({ workflowScheduleId: data.id }), + ).toBeNull(); + }); + + it("does not overwrite an existing schedule through the create form", async () => { + const created = await saveWorkflowSchedule(backend, data); + await expect( + saveWorkflowSchedule(backend, { ...data, input: "false" }), + ).rejects.toThrow("already exists"); + expect( + await backend.getWorkflowSchedule({ workflowScheduleId: data.id }), + ).toEqual(created); + }); + + it("rejects an edit when the schedule is already deleted", async () => { + await saveWorkflowSchedule(backend, data); + await backend.deleteWorkflowSchedule({ workflowScheduleId: data.id }); + await expect( + saveWorkflowSchedule(backend, { ...data, mode: "edit" }), + ).rejects.toThrow("no longer exists"); + }); +}); diff --git a/apps/dashboard/src/lib/schedules.server.ts b/apps/dashboard/src/lib/schedules.server.ts new file mode 100644 index 00000000..b27871c2 --- /dev/null +++ b/apps/dashboard/src/lib/schedules.server.ts @@ -0,0 +1,41 @@ +import { parseWorkflowInput } from "./input"; +import { OpenWorkflow } from "openworkflow"; +import type { Backend } from "openworkflow/internal"; +import * as z from "zod"; + +export const scheduleInputSchema = z.object({ + id: z.string().min(1), + workflowName: z.string().trim().min(1), + cron: z.string().trim().min(1), + timezone: z.string().trim().min(1), + input: z.string(), + mode: z.enum(["create", "edit"]), +}); + +/** + * Save a schedule using the SDK's cron and timezone validation. + * @param backend - Configured storage + * @param data - Schedule form values + * @returns Persisted schedule + */ +export async function saveWorkflowSchedule( + backend: Backend, + data: z.infer, +) { + const ow = new OpenWorkflow({ backend }); + const existing = await ow.schedules.get(data.id); + // These checks are best-effort; the v0.11 SDK saves schedules with an upsert. + if (data.mode === "create" && existing) { + throw new Error( + "This schedule ID already exists. Use a different ID or edit the existing schedule.", + ); + } + if (data.mode === "edit" && !existing) { + throw new Error("This schedule no longer exists. Refresh the page."); + } + return ow.scheduleWorkflow( + { name: data.workflowName }, + parseWorkflowInput(data.input), + { id: data.id, cron: data.cron, timezone: data.timezone }, + ); +} diff --git a/apps/dashboard/src/routeTree.gen.ts b/apps/dashboard/src/routeTree.gen.ts index 7c242ad9..d9889963 100644 --- a/apps/dashboard/src/routeTree.gen.ts +++ b/apps/dashboard/src/routeTree.gen.ts @@ -12,6 +12,8 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as IndexRouteImport } from './routes/index' import { Route as MetricsRouteImport } from './routes/metrics' import { Route as RunsRunIdRouteImport } from './routes/runs/$runId' +import { Route as SchedulesIndexRouteImport } from './routes/schedules.index' +import { Route as SchedulesScheduleIdRouteImport } from './routes/schedules.$scheduleId' const IndexRoute = IndexRouteImport.update({ id: '/', @@ -28,35 +30,61 @@ const RunsRunIdRoute = RunsRunIdRouteImport.update({ path: '/runs/$runId', getParentRoute: () => rootRouteImport, } as any) +const SchedulesIndexRoute = SchedulesIndexRouteImport.update({ + id: '/schedules/', + path: '/schedules/', + getParentRoute: () => rootRouteImport, +} as any) +const SchedulesScheduleIdRoute = SchedulesScheduleIdRouteImport.update({ + id: '/schedules/$scheduleId', + path: '/schedules/$scheduleId', + getParentRoute: () => rootRouteImport, +} as any) export interface FileRoutesByFullPath { '/': typeof IndexRoute '/metrics': typeof MetricsRoute '/runs/$runId': typeof RunsRunIdRoute + '/schedules/$scheduleId': typeof SchedulesScheduleIdRoute + '/schedules/': typeof SchedulesIndexRoute } export interface FileRoutesByTo { '/': typeof IndexRoute '/metrics': typeof MetricsRoute '/runs/$runId': typeof RunsRunIdRoute + '/schedules/$scheduleId': typeof SchedulesScheduleIdRoute + '/schedules': typeof SchedulesIndexRoute } export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute '/metrics': typeof MetricsRoute '/runs/$runId': typeof RunsRunIdRoute + '/schedules/$scheduleId': typeof SchedulesScheduleIdRoute + '/schedules/': typeof SchedulesIndexRoute } export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath - fullPaths: '/' | '/metrics' | '/runs/$runId' + fullPaths: + '/' | '/metrics' | '/runs/$runId' | '/schedules/$scheduleId' | '/schedules/' fileRoutesByTo: FileRoutesByTo - to: '/' | '/metrics' | '/runs/$runId' - id: '__root__' | '/' | '/metrics' | '/runs/$runId' + to: + '/' | '/metrics' | '/runs/$runId' | '/schedules/$scheduleId' | '/schedules' + id: + | '__root__' + | '/' + | '/metrics' + | '/runs/$runId' + | '/schedules/$scheduleId' + | '/schedules/' fileRoutesById: FileRoutesById } export interface RootRouteChildren { IndexRoute: typeof IndexRoute MetricsRoute: typeof MetricsRoute RunsRunIdRoute: typeof RunsRunIdRoute + SchedulesScheduleIdRoute: typeof SchedulesScheduleIdRoute + SchedulesIndexRoute: typeof SchedulesIndexRoute } declare module '@tanstack/react-router' { @@ -82,6 +110,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof RunsRunIdRouteImport parentRoute: typeof rootRouteImport } + '/schedules/': { + id: '/schedules/' + path: '/schedules' + fullPath: '/schedules/' + preLoaderRoute: typeof SchedulesIndexRouteImport + parentRoute: typeof rootRouteImport + } + '/schedules/$scheduleId': { + id: '/schedules/$scheduleId' + path: '/schedules/$scheduleId' + fullPath: '/schedules/$scheduleId' + preLoaderRoute: typeof SchedulesScheduleIdRouteImport + parentRoute: typeof rootRouteImport + } } } @@ -89,6 +131,8 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, MetricsRoute: MetricsRoute, RunsRunIdRoute: RunsRunIdRoute, + SchedulesScheduleIdRoute: SchedulesScheduleIdRoute, + SchedulesIndexRoute: SchedulesIndexRoute, } export const routeTree = rootRouteImport ._addFileChildren(rootRouteChildren) diff --git a/apps/dashboard/src/routes/index.tsx b/apps/dashboard/src/routes/index.tsx index 3f4fd0fd..83f6d70e 100644 --- a/apps/dashboard/src/routes/index.tsx +++ b/apps/dashboard/src/routes/index.tsx @@ -1,6 +1,6 @@ import { AppLayout } from "@/components/app-layout"; import { CreateRunForm } from "@/components/create-run-form"; -import { CursorPaginationControls } from "@/components/cursor-pagination-controls"; +import { ListPagination } from "@/components/list-pagination"; import { RunList, type ChildRunRelation } from "@/components/run-list"; import { Button } from "@/components/ui/button"; import { @@ -18,10 +18,7 @@ import { listWorkflowRunsServerFn, } from "@/lib/api"; import { - RUNS_PAGE_SIZE_OPTIONS, - type RunsPaginationSearch, resolveRunsPageSize, - shouldShowPaginationControls, validateRunsPaginationSearch, } from "@/lib/runs-page-pagination"; import { usePolling } from "@/lib/use-polling"; @@ -110,50 +107,7 @@ function HomePage() { const navigate = Route.useNavigate(); const [isCreateRunOpen, setIsCreateRunOpen] = useState(false); - const pageSize = resolveRunsPageSize(search.limit); const runs = data; - const showRunsPagination = shouldShowPaginationControls(pagination); - - function updateRunsSearch(next: Partial) { - void navigate({ - to: "/", - search: (previous) => ({ - ...previous, - ...next, - }), - }); - } - - function handlePageSizeChange(nextPageSize: number) { - const limit = resolveRunsPageSize(nextPageSize); - updateRunsSearch({ - limit, - after: undefined, - before: undefined, - }); - } - - function goToNextPage() { - if (!pagination.next) { - return; - } - - updateRunsSearch({ - after: pagination.next, - before: undefined, - }); - } - - function goToPreviousPage() { - if (!pagination.prev) { - return; - } - - updateRunsSearch({ - before: pagination.prev, - after: undefined, - }); - } usePolling(); @@ -165,7 +119,7 @@ function HomePage() {

Workflow Runs

- Monitor and trigger workflow runs. + Start workflows and check their progress.