diff --git a/.changeset/cron-triggers-local-explorer-ui.md b/.changeset/cron-triggers-local-explorer-ui.md new file mode 100644 index 00000000000..e702b033ca6 --- /dev/null +++ b/.changeset/cron-triggers-local-explorer-ui.md @@ -0,0 +1,7 @@ +--- +"@cloudflare/local-explorer-ui": minor +--- + +Add one-off Cron Trigger testing to Local Explorer + +Developers can invoke a Worker's configured or custom Cron Trigger with an exact or guided expression, choose a UTC or epoch-millisecond scheduled time, and inspect the latest structured result without editing configuration. diff --git a/.changeset/cron-triggers-miniflare-assets.md b/.changeset/cron-triggers-miniflare-assets.md new file mode 100644 index 00000000000..23fa1ad9f60 --- /dev/null +++ b/.changeset/cron-triggers-miniflare-assets.md @@ -0,0 +1,7 @@ +--- +"miniflare": minor +--- + +Package the Cron Triggers interface in Local Explorer + +The Local Explorer assets now include an interactive Cron Triggers destination for one-off local scheduled-handler testing. diff --git a/packages/local-explorer-ui/src/__e2e__/cron-triggers/cron-triggers.spec.ts b/packages/local-explorer-ui/src/__e2e__/cron-triggers/cron-triggers.spec.ts new file mode 100644 index 00000000000..600616424db --- /dev/null +++ b/packages/local-explorer-ui/src/__e2e__/cron-triggers/cron-triggers.spec.ts @@ -0,0 +1,552 @@ +import { afterEach, describe, test } from "vitest"; +import { page, viteUrl } from "../utils"; + +const WORKERS_ROUTE = "**/cdn-cgi/local/explorer/api/local/workers"; +const SCHEDULED_ROUTE = + "**/cdn-cgi/local/explorer/api/local/scheduled?worker=*"; +const STORAGE_PREFIX = "local-explorer.cron-triggers.custom-rows.v1"; + +interface MockWorkerMetadata { + isSelf?: boolean; + name: string; + persistenceScope?: string; + triggers: { crons: string[] }; +} + +async function mockWorkerMetadata( + workers: MockWorkerMetadata[] +): Promise { + await page.route(WORKERS_ROUTE, async (route) => { + await route.fulfill({ + body: JSON.stringify({ + errors: [], + messages: [], + result: workers, + success: true, + }), + contentType: "application/json", + }); + }); +} + +async function mockWorkers(crons: string[]): Promise { + await mockWorkerMetadata([ + { + isSelf: true, + name: "cron-worker", + persistenceScope: "cron-e2e-project", + triggers: { crons }, + }, + ]); +} + +async function openCronTriggers(): Promise { + await page.goto( + new URL( + "/cdn-cgi/local/explorer/cron-triggers?worker=cron-worker", + viteUrl + ).toString() + ); +} + +afterEach(async () => { + await page.setViewportSize({ height: 720, width: 1280 }); + await page.evaluate((prefix) => { + for (let index = localStorage.length - 1; index >= 0; index--) { + const key = localStorage.key(index); + if (key?.startsWith(prefix)) { + localStorage.removeItem(key); + } + } + }, STORAGE_PREFIX); + await page.unroute(WORKERS_ROUTE); + await page.unroute(SCHEDULED_ROUTE); +}); + +describe("Cron Triggers", () => { + test("shows the authoritative empty state without test controls", async ({ + expect, + }) => { + await mockWorkers([]); + await openCronTriggers(); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBeNull(); + await page + .getByRole("heading", { name: "No Cron Triggers configured" }) + .waitFor(); + expect(await page.getByRole("button", { name: "Add draft" }).count()).toBe( + 0 + ); + expect( + await page.getByRole("button", { name: "Trigger", exact: true }).count() + ).toBe(0); + }); + + test("preserves the requested worker when bootstrap fails", async ({ + expect, + }) => { + await page.route(WORKERS_ROUTE, async (route) => { + await route.fulfill({ + body: JSON.stringify({ + errors: [{ code: 10000, message: "Workers unavailable" }], + messages: [], + success: false, + }), + contentType: "application/json", + status: 500, + }); + }); + + await page.goto( + new URL( + "/cdn-cgi/local/explorer/cron-triggers?worker=requested-worker", + viteUrl + ).toString() + ); + await expect + .poll(() => page.locator("body").innerText()) + .toContain("Cron Triggers are unavailable"); + expect(new URL(page.url()).searchParams.get("worker")).toBe( + "requested-worker" + ); + }); + + test("canonicalizes missing and invalid workers before dispatch", async ({ + expect, + }) => { + await mockWorkerMetadata([ + { + isSelf: true, + name: "worker-1", + persistenceScope: "cron-project-1", + triggers: { crons: ["first-worker-cron"] }, + }, + { + name: "worker-2", + persistenceScope: "cron-project-2", + triggers: { crons: ["second-worker-cron"] }, + }, + ]); + const requestedWorkers: Array = []; + await page.route(SCHEDULED_ROUTE, async (route) => { + requestedWorkers.push( + new URL(route.request().url()).searchParams.get("worker") + ); + await route.fulfill({ + body: JSON.stringify({ + errors: [], + messages: [], + result: { noRetry: false, outcome: "ok" }, + success: true, + }), + contentType: "application/json", + }); + }); + + await page.goto( + new URL("/cdn-cgi/local/explorer/cron-triggers", viteUrl).toString() + ); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-1"); + + await page.goto( + new URL( + "/cdn-cgi/local/explorer/cron-triggers?worker=missing-worker", + viteUrl + ).toString() + ); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-1"); + expect(await page.getByLabel("Cron expression").first().inputValue()).toBe( + "first-worker-cron" + ); + await page.getByRole("button", { name: "Trigger", exact: true }).click(); + await expect.poll(() => requestedWorkers).toEqual(["worker-1"]); + + requestedWorkers.length = 0; + await page.goto( + new URL( + "/cdn-cgi/local/explorer/cron-triggers?worker=worker-2", + viteUrl + ).toString() + ); + expect(new URL(page.url()).searchParams.get("worker")).toBe("worker-2"); + expect(await page.getByLabel("Cron expression").first().inputValue()).toBe( + "second-worker-cron" + ); + await page.getByRole("button", { name: "Trigger", exact: true }).click(); + await expect.poll(() => requestedWorkers).toEqual(["worker-2"]); + }); + + test("uses full-width equal panes and flattens only wide rows", async ({ + expect, + }) => { + await page.setViewportSize({ height: 900, width: 1920 }); + await mockWorkers(["0 17 * * sun"]); + await openCronTriggers(); + const configuredPane = page.getByRole("region", { + name: "Configured crons", + }); + const customPane = page.getByRole("region", { name: "Draft crons" }); + const [configuredBox, customBox] = await Promise.all([ + configuredPane.boundingBox(), + customPane.boundingBox(), + ]); + expect( + Math.abs((configuredBox?.width ?? 0) - (customBox?.width ?? 0)) + ).toBeLessThanOrEqual(1); + expect( + Math.abs( + (customBox?.x ?? 0) - + ((configuredBox?.x ?? 0) + (configuredBox?.width ?? 0)) + ) + ).toBeLessThanOrEqual(1); + const [configuredBorder, customBorder] = await Promise.all([ + configuredPane.evaluate( + (element) => getComputedStyle(element).borderRightWidth + ), + customPane.evaluate( + (element) => getComputedStyle(element).borderLeftWidth + ), + ]); + expect(configuredBorder).toBe("0px"); + expect(customBorder).toBe("0px"); + const [configuredPadding, customPadding] = await Promise.all([ + configuredPane.locator("[data-cron-pane-scroll]").evaluate((element) => { + const style = getComputedStyle(element); + return { left: style.paddingLeft, right: style.paddingRight }; + }), + customPane.locator("[data-cron-pane-scroll]").evaluate((element) => { + const style = getComputedStyle(element); + return { left: style.paddingLeft, right: style.paddingRight }; + }), + ]); + expect(configuredPadding).toEqual({ left: "16px", right: "8px" }); + expect(customPadding).toEqual({ left: "8px", right: "16px" }); + expect( + 1920 - ((customBox?.x ?? 0) + (customBox?.width ?? 0)) + ).toBeLessThanOrEqual(1); + const [configuredHeaderBox, customHeaderBox] = await Promise.all([ + configuredPane.locator("header").boundingBox(), + customPane.locator("header").boundingBox(), + ]); + expect(configuredHeaderBox?.height).toBe(customHeaderBox?.height); + + await configuredPane + .getByRole("button", { name: "Duplicate cron" }) + .click(); + const customRow = customPane.locator("[data-row-id]").first(); + expect( + await customRow.getByText("Cron expression", { exact: true }).count() + ).toBe(0); + expect( + await customRow.getByText(/This custom cron is saved locally/).count() + ).toBe(0); + const [cronInputBox, triggerBox, duplicateBox, removeBox] = + await Promise.all([ + customRow.getByLabel("Cron expression").boundingBox(), + customRow + .getByRole("button", { name: "Trigger", exact: true }) + .boundingBox(), + customRow.getByRole("button", { name: "Duplicate cron" }).boundingBox(), + customRow.getByRole("button", { name: "Remove row" }).boundingBox(), + ]); + expect( + Math.abs((cronInputBox?.y ?? 0) - (triggerBox?.y ?? 0)) + ).toBeLessThanOrEqual(1); + expect((cronInputBox?.x ?? 0) + (cronInputBox?.width ?? 0)).toBeLessThan( + triggerBox?.x ?? 0 + ); + expect(triggerBox?.height).toBe(cronInputBox?.height); + expect(duplicateBox?.height).toBe(cronInputBox?.height); + expect(removeBox?.height).toBe(cronInputBox?.height); + const cardPadding = await customRow.evaluate((element) => { + const style = getComputedStyle(element); + return { left: style.paddingLeft, top: style.paddingTop }; + }); + expect(cardPadding.left).toBe(cardPadding.top); + await customRow.getByRole("button", { name: "Build expression" }).click(); + expect(await customRow.locator("fieldset code").count()).toBe(0); + expect( + await customRow + .getByText(/Step values start at the field minimum/) + .count() + ).toBe(0); + await customRow.getByRole("button", { name: "Cron builder help" }).hover(); + await page.getByText(/Step values start at the field minimum/).waitFor(); + await customRow.getByRole("button", { name: "Custom time" }).click(); + const expressionControls = customRow.locator( + "[data-cron-expression-controls]" + ); + const timeControls = customRow.locator("[data-cron-time-controls]"); + const [wideExpressionBox, wideTimeBox] = await Promise.all([ + expressionControls.boundingBox(), + timeControls.boundingBox(), + ]); + expect( + Math.abs((wideExpressionBox?.y ?? 0) - (wideTimeBox?.y ?? 0)) + ).toBeLessThanOrEqual(1); + expect(wideTimeBox?.x ?? 0).toBeGreaterThan(wideExpressionBox?.x ?? 0); + expect( + await customRow.evaluate( + (element) => element.scrollWidth <= element.clientWidth + ) + ).toBe(true); + + await page.setViewportSize({ height: 720, width: 1280 }); + const [narrowExpressionBox, narrowTimeBox] = await Promise.all([ + expressionControls.boundingBox(), + timeControls.boundingBox(), + ]); + expect(narrowTimeBox?.y ?? 0).toBeGreaterThan( + (narrowExpressionBox?.y ?? 0) + (narrowExpressionBox?.height ?? 0) + ); + expect( + await customRow.evaluate( + (element) => element.scrollWidth <= element.clientWidth + ) + ).toBe(true); + }); + + test("duplicates a configured cron and sends an immutable one-off request", async ({ + expect, + }) => { + await mockWorkers(["0 17 * * sun"]); + let body: unknown; + await page.route(SCHEDULED_ROUTE, async (route) => { + body = route.request().postDataJSON(); + await route.fulfill({ + body: JSON.stringify({ + errors: [], + messages: [], + result: { noRetry: true, outcome: "ok" }, + success: true, + }), + contentType: "application/json", + }); + }); + await page.goto( + new URL("/cdn-cgi/local/explorer/?worker=cron-worker", viteUrl).toString() + ); + await page.evaluate(() => { + document.documentElement.dataset.navigationMarker = "preserved"; + }); + await page.getByRole("link", { name: "Cron Triggers" }).click(); + expect(new URL(page.url()).searchParams.get("worker")).toBeNull(); + expect( + await page.evaluate( + () => document.documentElement.dataset.navigationMarker + ) + ).toBeUndefined(); + await page.getByLabel("Cron expression").waitFor(); + const configuredPane = page.getByRole("region", { + name: "Configured crons", + }); + const customPane = page.getByRole("region", { name: "Draft crons" }); + const [configuredBox, customBox] = await Promise.all([ + configuredPane.boundingBox(), + customPane.boundingBox(), + ]); + expect(configuredBox?.x).toBeLessThan(customBox?.x ?? 0); + expect( + Math.abs((configuredBox?.width ?? 0) - (customBox?.width ?? 0)) + ).toBe(0); + expect( + await configuredPane + .locator("[data-cron-pane-scroll]") + .evaluate((element) => getComputedStyle(element).overflowY) + ).toBe("auto"); + expect( + await customPane + .locator("[data-cron-pane-scroll]") + .evaluate((element) => getComputedStyle(element).overflowY) + ).toBe("auto"); + const cronInputs = page.getByLabel("Cron expression"); + await cronInputs.waitFor(); + expect(await cronInputs.first().inputValue()).toBe("0 17 * * sun"); + await page.getByRole("button", { name: "Duplicate cron" }).click(); + await expect.poll(() => cronInputs.count()).toBe(2); + await expect + .poll(() => + page + .getByRole("button", { name: "Now" }) + .last() + .getAttribute("aria-pressed") + ) + .toBe("true"); + + await configuredPane + .getByRole("button", { name: "Duplicate cron" }) + .click(); + await expect + .poll(() => customPane.locator("[data-row-id]").count()) + .toBe(2); + expect( + await page + .getByRole("heading", { + exact: true, + name: /Configured Cron|Custom Cron/, + }) + .count() + ).toBe(0); + await customPane + .getByRole("button", { name: "Remove row" }) + .first() + .click(); + await expect + .poll(() => + customPane + .getByLabel("Cron expression") + .evaluate((element) => element === document.activeElement) + ) + .toBe(true); + + const before = Date.now(); + await page + .getByRole("button", { name: "Trigger", exact: true }) + .last() + .click(); + await page.getByText("Outcome: ok").waitFor(); + const after = Date.now(); + expect(body).toMatchObject({ cron: "0 17 * * sun" }); + const scheduledTime = (body as { scheduled_time: number }).scheduled_time; + expect(scheduledTime).toBeGreaterThanOrEqual(before); + expect(scheduledTime).toBeLessThanOrEqual(after); + await page.getByText(/one-off local test/).waitFor(); + }); + + test("sends custom times only as UTC calendar values or epoch milliseconds", async ({ + expect, + }) => { + await mockWorkers(["0 17 * * sun"]); + const bodies: Array<{ cron: string; scheduled_time: number }> = []; + await page.route(SCHEDULED_ROUTE, async (route) => { + bodies.push(route.request().postDataJSON()); + await route.fulfill({ + body: JSON.stringify({ + errors: [], + messages: [], + result: { noRetry: false, outcome: "ok" }, + success: true, + }), + contentType: "application/json", + }); + }); + await openCronTriggers(); + await page.getByRole("button", { name: "Custom time" }).click(); + expect(await page.getByText("Time zone", { exact: true }).count()).toBe(0); + + await page + .getByLabel("Date and Time (UTC)") + .fill("2026-01-10T12:30:45.123"); + await page.getByRole("button", { name: "Trigger", exact: true }).click(); + await expect.poll(() => bodies.length).toBe(1); + expect(bodies[0]).toEqual({ + cron: "0 17 * * sun", + scheduled_time: Date.parse("2026-01-10T12:30:45.123Z"), + }); + + await page.getByRole("button", { name: "Epoch milliseconds" }).click(); + await page.getByLabel("Epoch milliseconds").fill("-1"); + await page.getByRole("button", { name: "Trigger", exact: true }).click(); + await expect.poll(() => bodies.length).toBe(2); + expect(bodies[1]).toEqual({ + cron: "0 17 * * sun", + scheduled_time: -1, + }); + }); + + test("preserves custom time drafts after switching to now", async ({ + expect, + }) => { + await mockWorkers(["0 17 * * sun"]); + await openCronTriggers(); + await page.getByRole("button", { name: "Custom time" }).click(); + + const calendarInput = page.getByLabel("Date and Time (UTC)"); + await calendarInput.fill("2026-01-10T12:30:45.123"); + await page.getByRole("button", { name: "Now", exact: true }).click(); + await page.getByRole("button", { name: "Custom time" }).click(); + expect(await calendarInput.inputValue()).toBe("2026-01-10T12:30:45.123"); + + await page.getByRole("button", { name: "Epoch milliseconds" }).click(); + const epochInput = page.getByLabel("Epoch milliseconds"); + await epochInput.fill("123456789"); + await page.getByRole("button", { name: "Now", exact: true }).click(); + await page.getByRole("button", { name: "Custom time" }).click(); + expect(await epochInput.inputValue()).toBe("123456789"); + }); + + test("restores persisted custom drafts as idle rows after navigation and reload", async ({ + expect, + }) => { + await mockWorkers(["0 17 * * sun"]); + await page.route(SCHEDULED_ROUTE, async (route) => { + await route.fulfill({ + body: JSON.stringify({ + errors: [], + messages: [], + result: { noRetry: false, outcome: "ok" }, + success: true, + }), + contentType: "application/json", + }); + }); + await openCronTriggers(); + await page.getByRole("button", { name: "Duplicate cron" }).click(); + await page.getByLabel("Cron expression").last().fill("15 4 * * *"); + await page.getByRole("button", { name: "Custom time" }).last().click(); + await page + .getByRole("button", { name: "Epoch milliseconds" }) + .last() + .click(); + await page.getByLabel("Epoch milliseconds").fill("123456789"); + await page + .getByRole("button", { name: "Trigger", exact: true }) + .last() + .click(); + await page.getByText("Outcome: ok").waitFor(); + await page.getByRole("button", { name: "Build expression" }).last().click(); + await page.getByLabel("Hour").last().fill(""); + await expect + .poll(() => + page.evaluate( + (prefix) => + Object.keys(localStorage).some((key) => key.startsWith(prefix)), + STORAGE_PREFIX + ) + ) + .toBe(true); + + await page.getByRole("link", { name: "Traces" }).click(); + await page.getByRole("link", { name: "Cron Triggers" }).click(); + + await page.getByLabel("Cron expression").last().waitFor(); + expect( + await page + .getByRole("button", { name: "Build expression" }) + .last() + .getAttribute("aria-pressed") + ).toBe("true"); + expect(await page.getByLabel("Hour").last().inputValue()).toBe(""); + expect(await page.getByLabel("Epoch milliseconds").inputValue()).toBe( + "123456789" + ); + expect(await page.getByText("Outcome: ok").count()).toBe(0); + + await page.reload(); + + await page.getByLabel("Cron expression").last().waitFor(); + expect(await page.getByLabel("Cron expression").last().inputValue()).toBe( + "15 4 * * *" + ); + expect(await page.getByLabel("Epoch milliseconds").inputValue()).toBe( + "123456789" + ); + expect(await page.getByText("Outcome: ok").count()).toBe(0); + expect(await page.getByText("Invocation is running…").count()).toBe(0); + }); +}); diff --git a/packages/local-explorer-ui/src/__e2e__/workflows/workflow.spec.ts b/packages/local-explorer-ui/src/__e2e__/workflows/workflow.spec.ts index e201dc87958..8b7396cc71a 100644 --- a/packages/local-explorer-ui/src/__e2e__/workflows/workflow.spec.ts +++ b/packages/local-explorer-ui/src/__e2e__/workflows/workflow.spec.ts @@ -1,7 +1,6 @@ import { beforeEach, describe, test } from "vitest"; import { cleanupWorkflow, - clickButton, isTextVisible, navigateToWorkflow, page, @@ -13,6 +12,10 @@ import { const WORKFLOW_NAME = "my-workflow"; +async function clickTriggerButton(): Promise { + await page.getByRole("button", { name: "Trigger", exact: true }).click(); +} + describe("Workflows", () => { beforeEach(async () => { await cleanupWorkflow(WORKFLOW_NAME); @@ -35,7 +38,10 @@ describe("Workflows", () => { test("shows Trigger button", async () => { await navigateToWorkflow(WORKFLOW_NAME); - const triggerButton = page.getByRole("button", { name: "Trigger" }); + const triggerButton = page.getByRole("button", { + name: "Trigger", + exact: true, + }); await triggerButton.waitFor({ state: "visible", timeout: 10_000 }); }); @@ -77,7 +83,7 @@ describe("Workflows", () => { test("opens trigger dialog via 'Trigger' button", async () => { await navigateToWorkflow(WORKFLOW_NAME); - await clickButton("Trigger"); + await clickTriggerButton(); await waitForSelector('[role="dialog"]', { timeout: 5_000 }); await waitForText("Trigger this workflow?"); @@ -86,7 +92,7 @@ describe("Workflows", () => { test("triggers a new instance and navigates to its detail page", async () => { await navigateToWorkflow(WORKFLOW_NAME); - await clickButton("Trigger"); + await clickTriggerButton(); await waitForSelector('[role="dialog"]', { timeout: 5_000 }); const dialog = page.getByRole("dialog"); @@ -103,7 +109,7 @@ describe("Workflows", () => { test("cancels the trigger dialog", async () => { await navigateToWorkflow(WORKFLOW_NAME); - await clickButton("Trigger"); + await clickTriggerButton(); await waitForSelector('[role="dialog"]', { timeout: 5_000 }); const dialog = page.getByRole("dialog"); @@ -118,7 +124,7 @@ describe("Workflows", () => { test("shows validation error for invalid JSON params", async () => { await navigateToWorkflow(WORKFLOW_NAME); - await clickButton("Trigger"); + await clickTriggerButton(); await waitForSelector('[role="dialog"]', { timeout: 5_000 }); const dialog = page.getByRole("dialog"); diff --git a/packages/local-explorer-ui/src/__tests__/cron-triggers/cron-builder.test.ts b/packages/local-explorer-ui/src/__tests__/cron-triggers/cron-builder.test.ts new file mode 100644 index 00000000000..ee248ba5fcc --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/cron-triggers/cron-builder.test.ts @@ -0,0 +1,73 @@ +import { describe, it } from "vitest"; +import { generateCronExpression } from "../../components/cron-triggers/cron-builder"; + +describe("generateCronExpression", () => { + it("generates interval and calendar expressions", ({ expect }) => { + expect( + generateCronExpression({ kind: "minute-interval", every: "05" }) + ).toEqual({ expression: "*/5 * * * *", errors: {} }); + expect( + generateCronExpression({ + kind: "month-interval", + every: "3", + dayOfMonth: "31", + hour: "09", + minute: "00", + }) + ).toEqual({ expression: "0 9 31 */3 *", errors: {} }); + }); + + it("sorts weekday lists and generates Cloudflare extensions", ({ + expect, + }) => { + expect( + generateCronExpression({ + kind: "weekdays", + weekdays: ["fri", "mon", "sun"], + hour: "17", + minute: "0", + }).expression + ).toBe("0 17 * * sun,mon,fri"); + expect( + generateCronExpression({ + kind: "nearest-weekday", + dayOfMonth: "15", + hour: "9", + minute: "0", + }).expression + ).toBe("0 9 15W * *"); + expect( + generateCronExpression({ + kind: "last-weekday-of-month", + hour: "23", + minute: "59", + }).expression + ).toBe("59 23 LW * *"); + expect( + generateCronExpression({ + kind: "nth-weekday", + weekday: "mon", + occurrence: "2", + hour: "8", + minute: "30", + }).expression + ).toBe("30 8 * * mon#2"); + }); + + it("preserves invalid drafts and reports every invalid field", ({ + expect, + }) => { + const result = generateCronExpression({ + kind: "day-of-month-interval", + every: "", + hour: "24", + minute: "-1", + }); + expect(result.expression).toBeUndefined(); + expect(Object.keys(result.errors).sort()).toEqual([ + "every", + "hour", + "minute", + ]); + }); +}); diff --git a/packages/local-explorer-ui/src/__tests__/cron-triggers/persistence.test.ts b/packages/local-explorer-ui/src/__tests__/cron-triggers/persistence.test.ts new file mode 100644 index 00000000000..47733b62868 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/cron-triggers/persistence.test.ts @@ -0,0 +1,182 @@ +import { describe, it } from "vitest"; +import { + CRON_CUSTOM_ROWS_STORAGE_PREFIX, + cronCustomRowsStorageKey, + MAX_PERSISTED_CUSTOM_ROWS, + MAX_PERSISTED_CUSTOM_ROWS_BYTES, + readPersistedCustomCronRows, + writePersistedCustomCronRows, +} from "../../components/cron-triggers/persistence"; +import { createCronRow } from "../../components/cron-triggers/row-state"; + +class MemoryStorage implements Storage { + #values = new Map(); + + get length(): number { + return this.#values.size; + } + + clear(): void { + this.#values.clear(); + } + + getItem(key: string): string | null { + return this.#values.get(key) ?? null; + } + + key(index: number): string | null { + return [...this.#values.keys()][index] ?? null; + } + + removeItem(key: string): void { + this.#values.delete(key); + } + + setItem(key: string, value: string): void { + this.#values.set(key, value); + } +} + +describe("Cron Trigger custom-row persistence", () => { + it("uses the versioned project and encoded Worker key", ({ expect }) => { + expect(cronCustomRowsStorageKey(undefined, "worker")).toBeUndefined(); + expect(cronCustomRowsStorageKey("project-scope", "a Worker/name")).toBe( + `${CRON_CUSTOM_ROWS_STORAGE_PREFIX}.project-scope.a%20Worker%2Fname` + ); + }); + + it("persists only editable custom drafts and rebuilds transient state", ({ + expect, + }) => { + const storage = new MemoryStorage(); + const configured = createCronRow("configured", "configured"); + const custom = { + ...createCronRow("0 12 * * *"), + calendarValue: "2026-09-10T12:34:56.789", + customEpochMs: 1, + invocation: { + cron: "0 12 * * *", + requestId: "request-id", + scheduledTime: 1, + status: "pending" as const, + }, + timeMode: "custom" as const, + }; + + writePersistedCustomCronRows(storage, "key", [configured, custom]); + const raw = storage.getItem("key") ?? ""; + expect(raw).not.toContain(custom.id); + expect(raw).not.toContain("customEpochMs"); + expect(raw).not.toContain("request-id"); + expect(raw).not.toContain("source"); + + const restored = readPersistedCustomCronRows(storage, "key"); + expect(restored).toHaveLength(1); + expect(restored[0]).toMatchObject({ + calendarValue: "2026-09-10T12:34:56.789", + cron: "0 12 * * *", + customEpochMs: Date.UTC(2026, 8, 10, 12, 34, 56, 789), + source: "custom", + timeMode: "custom", + }); + expect(restored[0]?.id).not.toBe(custom.id); + expect(restored[0]?.invocation).toBeUndefined(); + }); + + it("recomputes valid epoch input and retains invalid editable input", ({ + expect, + }) => { + const storage = new MemoryStorage(); + const valid = { + ...createCronRow("valid"), + customTimeInputMode: "epoch" as const, + epochValue: "123456789", + timeMode: "custom" as const, + }; + const invalid = { + ...createCronRow("invalid"), + cronBuilder: { kind: "daily" as const, hour: "", minute: "7" }, + cronInputMode: "builder" as const, + customTimeInputMode: "epoch" as const, + epochValue: "not-an-integer", + timeMode: "custom" as const, + }; + writePersistedCustomCronRows(storage, "key", [valid, invalid]); + + const restored = readPersistedCustomCronRows(storage, "key"); + expect(restored[0]?.customEpochMs).toBe(123456789); + expect(restored[1]?.epochValue).toBe("not-an-integer"); + expect(restored[1]?.customEpochMs).toBeUndefined(); + expect(restored[1]).toMatchObject({ + cronBuilder: { kind: "daily", hour: "", minute: "7" }, + cronInputMode: "builder", + }); + }); + + for (const [label, raw] of [ + ["invalid JSON", "not json"], + ["unknown envelope", JSON.stringify({ rows: [] })], + [ + "unknown row field", + JSON.stringify([ + { + cron: "* * * * *", + cronBuilder: { kind: "daily", hour: "0", minute: "0" }, + cronInputMode: "expression", + customTimeInputMode: "calendar", + timeMode: "now", + unknown: true, + }, + ]), + ], + ] as const) { + it(`removes malformed or unknown storage: ${label}`, ({ expect }) => { + const storage = new MemoryStorage(); + storage.setItem("key", raw); + expect(readPersistedCustomCronRows(storage, "key")).toEqual([]); + expect(storage.getItem("key")).toBeNull(); + }); + } + + it("enforces row and byte bounds", ({ expect }) => { + const storage = new MemoryStorage(); + const rows = Array.from( + { length: MAX_PERSISTED_CUSTOM_ROWS + 1 }, + (_, index) => createCronRow(String(index)) + ); + writePersistedCustomCronRows(storage, "key", rows); + expect(JSON.parse(storage.getItem("key") ?? "[]")).toHaveLength( + MAX_PERSISTED_CUSTOM_ROWS + ); + + storage.setItem( + "oversized", + `"${"x".repeat(MAX_PERSISTED_CUSTOM_ROWS_BYTES)}"` + ); + expect(readPersistedCustomCronRows(storage, "oversized")).toEqual([]); + expect(storage.getItem("oversized")).toBeNull(); + }); + + it("removes empty state and tolerates storage failures", ({ expect }) => { + const storage = new MemoryStorage(); + storage.setItem("key", "old"); + writePersistedCustomCronRows(storage, "key", []); + expect(storage.getItem("key")).toBeNull(); + + const throwing = { + getItem: () => { + throw new Error("blocked"); + }, + removeItem: () => { + throw new Error("blocked"); + }, + setItem: () => { + throw new Error("blocked"); + }, + } as unknown as Storage; + expect(() => readPersistedCustomCronRows(throwing, "key")).not.toThrow(); + expect(() => + writePersistedCustomCronRows(throwing, "key", [createCronRow("cron")]) + ).not.toThrow(); + }); +}); diff --git a/packages/local-explorer-ui/src/__tests__/cron-triggers/provider-state.test.ts b/packages/local-explorer-ui/src/__tests__/cron-triggers/provider-state.test.ts new file mode 100644 index 00000000000..da219b1e5e5 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/cron-triggers/provider-state.test.ts @@ -0,0 +1,126 @@ +import { describe, it } from "vitest"; +import { + createCronStateFromSeed, + parseCronWorkerMetadata, + reconcilePersistenceKeysForRefresh, + RefreshGenerationTracker, + selectCronFallbackWorker, + shouldReplaceCustomRowsForPersistenceScope, +} from "../../components/cron-triggers/CronTriggersContext"; + +describe("Cron Triggers provider state", () => { + it("treats absent and explicit empty trigger metadata as authoritative empty arrays", ({ + expect, + }) => { + const metadata = parseCronWorkerMetadata([ + { name: "absent" }, + { name: "empty", triggers: { crons: [] } }, + ]); + expect(metadata).toEqual([ + { name: "absent", triggers: { crons: [] } }, + { name: "empty", triggers: { crons: [] } }, + ]); + const state = createCronStateFromSeed(metadata ?? [], true); + expect(state.absent?.authoritative).toBe(true); + expect(state.absent?.crons).toEqual([]); + }); + + it("does not claim authority when the root request failed", ({ expect }) => { + expect( + createCronStateFromSeed( + [{ name: "worker", triggers: { crons: ["0 0 * * *"] } }], + false + ) + ).toEqual({}); + }); + + it("retains an optional persistence scope from Worker metadata", ({ + expect, + }) => { + expect( + parseCronWorkerMetadata([ + { name: "worker", persistenceScope: "project-scope" }, + ]) + ).toEqual([ + { + name: "worker", + persistenceScope: "project-scope", + triggers: { crons: [] }, + }, + ]); + }); + + it("retains persistence keys for Workers omitted from a partial refresh", ({ + expect, + }) => { + expect( + reconcilePersistenceKeysForRefresh( + { + omitted: "key-for-omitted-worker", + returned: "old-key-for-returned-worker", + }, + [ + { + name: "returned", + persistenceScope: "new-scope", + }, + ] + ) + ).toEqual({ + omitted: "key-for-omitted-worker", + returned: + "local-explorer.cron-triggers.custom-rows.v1.new-scope.returned", + }); + }); + + it("removes a known key when returned metadata explicitly has no scope", ({ + expect, + }) => { + expect( + reconcilePersistenceKeysForRefresh( + { returned: "old-key-for-returned-worker" }, + [{ name: "returned" }] + ) + ).toEqual({}); + }); + + it("only replaces drafts when one explicit scope changes to another", ({ + expect, + }) => { + expect( + shouldReplaceCustomRowsForPersistenceScope("scope-a", "scope-b") + ).toBe(true); + expect( + shouldReplaceCustomRowsForPersistenceScope("scope-a", "scope-a") + ).toBe(false); + expect( + shouldReplaceCustomRowsForPersistenceScope("scope-a", undefined) + ).toBe(false); + expect( + shouldReplaceCustomRowsForPersistenceScope(undefined, "scope-a") + ).toBe(false); + expect( + shouldReplaceCustomRowsForPersistenceScope(undefined, undefined) + ).toBe(false); + }); + + it("recovers a self-first fallback without exposing internal workers", ({ + expect, + }) => { + expect( + selectCronFallbackWorker([ + { isSelf: true, name: "__router-worker__" }, + { name: "peer" }, + { isSelf: true, name: "self" }, + ]) + ).toBe("self"); + }); + + it("rejects stale out-of-order refresh generations", ({ expect }) => { + const tracker = new RefreshGenerationTracker(); + const older = tracker.start(); + const newer = tracker.start(); + expect(tracker.isLatest(newer)).toBe(true); + expect(tracker.isLatest(older)).toBe(false); + }); +}); diff --git a/packages/local-explorer-ui/src/__tests__/cron-triggers/row-state.test.ts b/packages/local-explorer-ui/src/__tests__/cron-triggers/row-state.test.ts new file mode 100644 index 00000000000..e33223bcd51 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/cron-triggers/row-state.test.ts @@ -0,0 +1,59 @@ +import { describe, it } from "vitest"; +import { + createCronRow, + duplicateCronRow, + reconcileConfiguredRows, +} from "../../components/cron-triggers/row-state"; + +describe("Cron Trigger row state", () => { + it("keeps duplicate configured rows stable across reordering", ({ + expect, + }) => { + const rows = reconcileConfiguredRows([], ["a", "b", "a"]); + const reordered = reconcileConfiguredRows(rows, ["a", "a", "b"]); + const ids = rows.map((row) => row.id); + expect(reordered.map((row) => row.id)).toEqual([ids[0], ids[2], ids[1]]); + }); + + it("drops removed idle rows and retains settled rows as stale", ({ + expect, + }) => { + const rows = reconcileConfiguredRows([], ["idle", "settled"]); + const settled = rows[1]; + if (!settled) { + throw new Error("Expected a configured row."); + } + rows[1] = { + ...settled, + invocation: { + cron: "settled", + requestId: "request", + result: { outcome: "ok", noRetry: false }, + scheduledTime: 0, + status: "result", + }, + }; + const reconciled = reconcileConfiguredRows(rows, []); + expect(reconciled).toHaveLength(1); + expect(reconciled[0]?.source).toBe("no-longer-configured"); + }); + + it("never changes custom rows and duplicates without result state", ({ + expect, + }) => { + const custom = createCronRow("0 0 * * *"); + custom.invocation = { + cron: custom.cron, + requestId: "request", + scheduledTime: 10, + status: "error", + error: "failed", + }; + const reconciled = reconcileConfiguredRows([custom], ["configured"]); + expect(reconciled[1]).toBe(custom); + const duplicate = duplicateCronRow(custom); + expect(duplicate.invocation).toBeUndefined(); + expect(duplicate.cron).toBe(custom.cron); + expect(duplicate.id).not.toBe(custom.id); + }); +}); diff --git a/packages/local-explorer-ui/src/__tests__/cron-triggers/scheduled-time.test.ts b/packages/local-explorer-ui/src/__tests__/cron-triggers/scheduled-time.test.ts new file mode 100644 index 00000000000..e7f415be8de --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/cron-triggers/scheduled-time.test.ts @@ -0,0 +1,86 @@ +import { describe, it } from "vitest"; +import { createCronRow } from "../../components/cron-triggers/row-state"; +import { + enterCustomTimeMode, + formatUtcCalendarValue, + parseEpochMilliseconds, + resolveUtcCalendarTime, +} from "../../components/cron-triggers/scheduled-time"; + +describe("Cron Trigger scheduled-time conversion", () => { + it("converts UTC calendar values without losing milliseconds", ({ + expect, + }) => { + const resolved = resolveUtcCalendarTime("2026-01-10T12:30:45.123"); + expect(resolved).toEqual({ + kind: "exact", + epochMs: 1_768_048_245_123, + utc: "2026-01-10T12:30:45.123Z", + }); + if (resolved.kind === "exact") { + expect(formatUtcCalendarValue(resolved.epochMs)).toBe( + "2026-01-10T12:30:45.123" + ); + } + }); + + it("supports years 0001–9999 and rejects invalid UTC dates", ({ expect }) => { + expect(resolveUtcCalendarTime("0001-01-01T00:00:00.000").kind).toBe( + "exact" + ); + expect(resolveUtcCalendarTime("9999-12-31T23:59:59.999").kind).toBe( + "exact" + ); + for (const value of [ + "0000-01-01T00:00:00.000", + "2026-02-29T00:00:00.000", + "2026-01-01T24:00:00.000", + ]) { + expect(resolveUtcCalendarTime(value).kind).toBe("invalid"); + } + }); + + it("accepts scheduled-time boundaries, zero, and negative epochs", ({ + expect, + }) => { + for (const value of ["-9223372036854", "-1", "0", "9223372036854"]) { + expect(parseEpochMilliseconds(value).epochMs).toBe(Number(value)); + } + expect(parseEpochMilliseconds("1.5").error).toBeDefined(); + expect(parseEpochMilliseconds("-9223372036855").error).toBeDefined(); + expect(parseEpochMilliseconds("9223372036855").error).toBeDefined(); + }); + + it("preserves existing custom values when returning from now mode", ({ + expect, + }) => { + const calendar = enterCustomTimeMode( + { + ...createCronRow("* * * * *"), + calendarValue: "2026-01-10T12:30:45.123", + timeMode: "now", + }, + 999 + ); + expect(calendar).toMatchObject({ + calendarValue: "2026-01-10T12:30:45.123", + customEpochMs: 1_768_048_245_123, + timeMode: "custom", + }); + + const epoch = enterCustomTimeMode( + { + ...createCronRow("* * * * *"), + customTimeInputMode: "epoch", + epochValue: "123456789", + timeMode: "now", + }, + 999 + ); + expect(epoch).toMatchObject({ + customEpochMs: 123_456_789, + epochValue: "123456789", + timeMode: "custom", + }); + }); +}); diff --git a/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts b/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts index e49b19d30e7..d5871ffd831 100644 --- a/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts +++ b/packages/local-explorer-ui/src/__tests__/utils/sidebar-state.test.ts @@ -113,6 +113,7 @@ describe("sidebar-state", () => { r2: false, workflows: true, email: false, + "cron-triggers": false, }; storageStub.setItem(GROUPS_STORAGE_KEY, JSON.stringify(stored)); expect(loadGroupState()).toEqual(stored); @@ -161,6 +162,7 @@ describe("sidebar-state", () => { r2: true, workflows: false, email: true, + "cron-triggers": false, }; saveGroupState(state); const raw = storageStub.getItem(GROUPS_STORAGE_KEY); @@ -188,6 +190,7 @@ describe("sidebar-state", () => { r2: false, workflows: true, email: true, + "cron-triggers": true, }; saveGroupState(state); expect(loadGroupState()).toEqual(state); diff --git a/packages/local-explorer-ui/src/components/Sidebar.tsx b/packages/local-explorer-ui/src/components/Sidebar.tsx index 1adb274e5dc..486d1eb014a 100644 --- a/packages/local-explorer-ui/src/components/Sidebar.tsx +++ b/packages/local-explorer-ui/src/components/Sidebar.tsx @@ -6,6 +6,7 @@ import { useSidebar, } from "@cloudflare/kumo"; import { + ClockCountdownIcon, EnvelopeSimpleIcon, MonitorIcon, MoonIcon, @@ -239,6 +240,24 @@ export function AppSidebar({ ], title: "Email", }, + { + emptyLabel: "", + groupId: "cron-triggers" as const, + icon: ClockCountdownIcon, + items: [ + { + id: "cron-triggers", + isActive: currentPath.startsWith("/cron-triggers"), + label: "Cron Triggers", + link: { + params: {}, + search: workerSearch, + to: "/cron-triggers", + }, + }, + ], + title: "Cron Triggers", + }, ] satisfies Array<{ emptyLabel: string; groupId: SidebarGroupId; diff --git a/packages/local-explorer-ui/src/components/cron-triggers/CronRowCard.tsx b/packages/local-explorer-ui/src/components/cron-triggers/CronRowCard.tsx new file mode 100644 index 00000000000..3f070cc3ac8 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/CronRowCard.tsx @@ -0,0 +1,742 @@ +import { Button, Tooltip } from "@cloudflare/kumo"; +import { CopyIcon, InfoIcon, TrashIcon } from "@phosphor-icons/react"; +import { useEffect, useMemo, useRef } from "react"; +import { + changeCronBuilderKind, + cronBuilderKinds, + cronWeekdays, + generateCronExpression, +} from "./cron-builder"; +import { + enterCustomTimeMode, + parseEpochMilliseconds, + resolveUtcCalendarTime, +} from "./scheduled-time"; +import type { UtcCalendarResolution } from "./scheduled-time"; +import type { CronBuilderDraft, CronRow, CronWeekday } from "./types"; +import type { JSX } from "react"; + +const CRON_CONFIGURATION_DOCS = + "https://developers.cloudflare.com/workers/configuration/cron-triggers/"; + +interface CronRowCardProps { + focusRequested: boolean; + onDuplicate: () => void; + onFocusHandled: () => void; + onRemove: () => void; + onUpdate: (update: (row: CronRow) => CronRow) => void; + row: CronRow; + trigger: (scheduledTime: number) => void; + triggerEnabled: boolean; +} + +export function CronRowCard({ + focusRequested, + onDuplicate, + onFocusHandled, + onRemove, + onUpdate, + row, + trigger, + triggerEnabled, +}: CronRowCardProps): JSX.Element { + const inputRef = useRef(null); + const actionRef = useRef(null); + const pending = row.invocation?.status === "pending"; + const builder = generateCronExpression(row.cronBuilder); + const calendar = useMemo( + () => + row.timeMode === "custom" && + row.customTimeInputMode === "calendar" && + row.calendarValue + ? resolveUtcCalendarTime(row.calendarValue) + : undefined, + [row.calendarValue, row.customTimeInputMode, row.timeMode] + ); + const scheduledTime = getScheduledTime(row, calendar); + const cronValid = + row.cron.trim() !== "" && + (row.cronInputMode !== "builder" || + (row.builderApplied === true && builder.expression === row.cron)); + const canTrigger = + triggerEnabled && !pending && cronValid && scheduledTime.valid; + + useEffect(() => { + if (focusRequested) { + if (row.source === "configured") { + actionRef.current?.focus(); + } else { + inputRef.current?.focus(); + } + onFocusHandled(); + } + }, [focusRequested, onFocusHandled, row.source]); + + function activateTrigger(): void { + if (!canTrigger) { + return; + } + trigger(row.timeMode === "now" ? Date.now() : scheduledTime.epochMs); + } + + return ( +
+ {row.source === "no-longer-configured" ? ( +
+

+ No longer configured +

+

+ This row is retained for this page session only. See the{" "} + + supported expressions + + . +

+
+ ) : null} + +
+
+ + onUpdate((current) => ({ + ...current, + cron: event.target.value, + cronInputMode: "expression", + builderApplied: false, + })) + } + ref={inputRef} + readOnly={row.source === "configured"} + value={row.cron} + /> + {!cronValid ? ( + + {row.cronInputMode === "builder" + ? "Complete the builder before triggering." + : "Enter a non-empty cron expression."} + + ) : null} +
+
+ + +
+
+ +
+
+ {row.source !== "configured" ? ( +
+ + onUpdate((current) => { + const firstBuilderUse = + mode === "builder" && current.cron.trim() === ""; + const generated = generateCronExpression( + current.cronBuilder + ); + return { + ...current, + builderApplied: + mode === "builder" + ? firstBuilderUse + : current.builderApplied, + cron: + firstBuilderUse && generated.expression + ? generated.expression + : current.cron, + cronInputMode: mode, + }; + }) + } + options={[ + { label: "Expression", value: "expression" }, + { label: "Build expression", value: "builder" }, + ]} + value={row.cronInputMode} + /> + + {row.cronInputMode === "builder" ? ( + + onUpdate((current) => { + const generated = generateCronExpression(draft); + return { + ...current, + cron: + current.builderApplied && generated.expression + ? generated.expression + : current.cron, + cronBuilder: draft, + }; + }) + } + onUse={() => { + const expression = builder.expression; + if (!expression) { + return; + } + onUpdate((current) => ({ + ...current, + builderApplied: true, + cron: expression, + })); + }} + rowId={row.id} + value={row.cronBuilder} + /> + ) : null} +
+ ) : null} + +
+ + onUpdate((current) => + mode === "custom" + ? enterCustomTimeMode(current, Date.now()) + : { ...current, timeMode: "now" } + ) + } + options={[ + { label: "Now", value: "now" }, + { label: "Custom time", value: "custom" }, + ]} + value={row.timeMode} + /> + + {row.timeMode === "custom" ? ( + onUpdate((current) => update(current))} + row={row} + /> + ) : null} +
+
+ +
+
+ ); +} + +function getScheduledTime( + row: CronRow, + calendar: UtcCalendarResolution | undefined +): { valid: boolean; epochMs: number } { + if (row.timeMode === "now") { + return { valid: true, epochMs: 0 }; + } + if (row.customTimeInputMode === "epoch") { + return row.customEpochMs === undefined + ? { valid: false, epochMs: 0 } + : { valid: true, epochMs: row.customEpochMs }; + } + if (calendar?.kind === "exact") { + return { valid: true, epochMs: calendar.epochMs }; + } + return { valid: false, epochMs: 0 }; +} + +function ModeGroup({ + disabled, + label, + onChange, + options, + value, +}: { + disabled: boolean; + label: string; + onChange: (value: T) => void; + options: Array<{ label: string; value: T }>; + value: T; +}): JSX.Element { + return ( +
+ {options.map((option) => ( + + ))} +
+ ); +} + +function CronBuilder({ + disabled, + onUpdate, + onUse, + rowId, + value, +}: { + disabled: boolean; + onUpdate: (draft: CronBuilderDraft) => void; + onUse: () => void; + rowId: string; + value: CronBuilderDraft; +}): JSX.Element { + const generated = generateCronExpression(value); + function updateField(field: string, nextValue: string | CronWeekday[]): void { + onUpdate({ ...value, [field]: nextValue } as CronBuilderDraft); + } + return ( +
+ + + Build a Cron expression (UTC) + + Step values start at the field minimum and reset at its + boundary. Day steps start on day 1; month steps start in + January. Dates 29–31 do not run in months without that date. W + selects the nearest weekday, LW the last weekday, weekday L the + last named weekday, and # an occurrence of a named weekday in + the month. + + } + > + + + + + +
+ {"every" in value ? ( + + ) : null} + {"dayOfMonth" in value ? ( + + ) : null} + {"hour" in value ? ( + + ) : null} + {"minute" in value ? ( + + ) : null} + {"occurrence" in value ? ( + + ) : null} + {"weekday" in value ? ( + + ) : null} +
+ {value.kind === "weekdays" ? ( +
+ {cronWeekdays.map((weekday) => ( + + ))} + {generated.errors.weekdays ? ( + + {generated.errors.weekdays} + + ) : null} +
+ ) : null} +
+ +
+
+ ); +} + +function BuilderNumber({ + errors, + field, + label, + onChange, + rowId, + value, +}: { + errors: Record; + field: string; + label: string; + onChange: (field: string, value: string) => void; + rowId: string; + value: string; +}): JSX.Element { + const error = errors[field]; + return ( + + ); +} + +function CustomTimeEditor({ + calendar, + disabled, + onChange, + row, +}: { + calendar?: UtcCalendarResolution; + disabled: boolean; + onChange: (update: (row: CronRow) => CronRow) => void; + row: CronRow; +}): JSX.Element { + return ( +
+ + onChange((current) => ({ + ...current, + customEpochMs: + mode === "epoch" + ? parseEpochMilliseconds(current.epochValue ?? "").epochMs + : current.customEpochMs, + customTimeInputMode: mode, + })) + } + options={[ + { label: "Date and Time (UTC)", value: "calendar" as const }, + { label: "Epoch milliseconds", value: "epoch" as const }, + ]} + value={row.customTimeInputMode} + /> + {row.customTimeInputMode === "calendar" ? ( +
+ + {calendar?.kind === "invalid" ? ( +

+ {calendar.error} +

+ ) : null} + {calendar?.kind === "exact" ? ( +

+ UTC:{" "} + {calendar.utc} ( + {calendar.epochMs} ms) +

+ ) : null} +
+ ) : ( +
+ + {parseEpochMilliseconds(row.epochValue ?? "").error ? ( + + {parseEpochMilliseconds(row.epochValue ?? "").error} + + ) : row.customEpochMs !== undefined ? ( + + UTC: {new Date(row.customEpochMs).toISOString()} + + ) : null} +
+ )} +
+ ); +} + +function InvocationResult({ row }: { row: CronRow }): JSX.Element | null { + const invocation = row.invocation; + if (!invocation) { + return null; + } + return ( +
+ {invocation.status === "pending" ?

Invocation is running…

: null} + {invocation.status === "error" ? ( +

{invocation.error}

+ ) : null} + {invocation.status === "result" && invocation.result ? ( +
+

+ Outcome:{" "} + {invocation.result.outcome} +

+

+ {invocation.result.noRetry + ? "noRetry() was requested; this remains a one-off local test." + : "noRetry() was not requested."} +

+
+ ) : null} +
+

+ Dispatched cron:{" "} + {invocation.cron} +

+

+ Scheduled time:{" "} + + {new Date(invocation.scheduledTime).toISOString()} + {" "} + ({invocation.scheduledTime} ms) +

+
+
+ ); +} diff --git a/packages/local-explorer-ui/src/components/cron-triggers/CronTriggersContext.tsx b/packages/local-explorer-ui/src/components/cron-triggers/CronTriggersContext.tsx new file mode 100644 index 00000000000..e856f19f7dc --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/CronTriggersContext.tsx @@ -0,0 +1,619 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { LOCAL_EXPLORER_API_PATH } from "../../constants"; +import { + cronCustomRowsStorageKey, + readPersistedCustomCronRows, + writePersistedCustomCronRows, +} from "./persistence"; +import { + createCronRow, + duplicateCronRow, + reconcileConfiguredRows, +} from "./row-state"; +import type { CronRow, CronWorkerState, FetcherScheduledResult } from "./types"; +import type { PropsWithChildren } from "react"; + +const REFRESH_HEADER = "X-Miniflare-Explorer-Refresh"; +const POLL_INTERVAL_MS = 5_000; + +interface WorkerMetadata { + isSelf?: boolean; + name: string; + persistenceScope?: string; + triggers?: { crons?: string[] }; +} + +interface WorkersEnvelope { + result?: unknown; +} + +interface ScheduledEnvelope { + result?: unknown; + errors?: Array<{ message?: string }>; +} + +export interface CronTriggersContextValue { + addCustom(workerName: string): string; + duplicateRow(workerName: string, rowId: string): string | undefined; + entry: (workerName: string) => CronWorkerState; + fallbackWorkerName: string; + isRefreshing(workerName: string): boolean; + invoke( + workerName: string, + rowId: string, + scheduledTime: number + ): Promise; + refresh(workerName: string, automatic?: boolean): Promise; + removeRow(workerName: string, rowId: string): void; + updateRow( + workerName: string, + rowId: string, + update: (row: CronRow) => CronRow + ): void; +} + +const CronTriggersContext = createContext( + null +); + +export function parseCronWorkerMetadata( + value: unknown +): WorkerMetadata[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + const workers: WorkerMetadata[] = []; + for (const item of value) { + if (typeof item !== "object" || item === null || !("name" in item)) { + return undefined; + } + const name = (item as { name?: unknown }).name; + if (typeof name !== "string") { + return undefined; + } + const triggers = (item as { triggers?: unknown }).triggers; + const isSelf = (item as { isSelf?: unknown }).isSelf; + const persistenceScope = (item as { persistenceScope?: unknown }) + .persistenceScope; + let crons: string[] = []; + if ( + typeof triggers === "object" && + triggers !== null && + "crons" in triggers + ) { + const candidate = (triggers as { crons?: unknown }).crons; + if ( + !Array.isArray(candidate) || + candidate.some((cron) => typeof cron !== "string") + ) { + return undefined; + } + crons = candidate; + } + workers.push({ + ...(typeof isSelf === "boolean" ? { isSelf } : {}), + name, + ...(typeof persistenceScope === "string" ? { persistenceScope } : {}), + triggers: { crons }, + }); + } + return workers; +} + +const INTERNAL_WORKERS = new Set([ + "__router-worker__", + "__asset-worker__", + "__vite_proxy_worker__", +]); + +export function selectCronFallbackWorker(metadata: WorkerMetadata[]): string { + const visible = metadata.filter( + (worker) => !INTERNAL_WORKERS.has(worker.name) + ); + return ( + visible.find((worker) => worker.isSelf)?.name ?? visible[0]?.name ?? "" + ); +} + +export function createCronStateFromSeed( + seedWorkers: unknown[], + authoritative: boolean +): Record { + if (!authoritative) { + return {}; + } + const metadata = parseCronWorkerMetadata(seedWorkers) ?? []; + return Object.fromEntries( + metadata.map((worker) => { + const crons = worker.triggers?.crons ?? []; + return [ + worker.name, + { + authoritative: true, + crons, + rows: reconcileConfiguredRows([], crons), + stale: false, + } satisfies CronWorkerState, + ]; + }) + ); +} + +function emptyState(): CronWorkerState { + return { authoritative: false, rows: [], stale: true }; +} + +function localStorageIfAvailable(): Storage | undefined { + try { + return window.localStorage; + } catch { + return undefined; + } +} + +function persistenceKeysForMetadata( + metadata: WorkerMetadata[] +): Record { + return Object.fromEntries( + metadata.flatMap((worker) => { + const key = cronCustomRowsStorageKey( + worker.persistenceScope, + worker.name + ); + return key ? [[worker.name, key]] : []; + }) + ); +} + +export function reconcilePersistenceKeysForRefresh( + previousKeys: Record, + metadata: WorkerMetadata[] +): Record { + const nextKeys = { ...previousKeys }; + const returnedKeys = persistenceKeysForMetadata(metadata); + for (const worker of metadata) { + const key = returnedKeys[worker.name]; + if (key === undefined) { + delete nextKeys[worker.name]; + } else { + nextKeys[worker.name] = key; + } + } + return nextKeys; +} + +export function shouldReplaceCustomRowsForPersistenceScope( + previousKey: string | undefined, + nextKey: string | undefined +): boolean { + return ( + previousKey !== undefined && + nextKey !== undefined && + previousKey !== nextKey + ); +} + +function hydrateInitialCustomRows( + workers: Record, + keys: Record, + storage: Storage | undefined +): Record { + if (!storage) { + return workers; + } + return Object.fromEntries( + Object.entries(workers).map(([workerName, entry]) => { + const key = keys[workerName]; + return [ + workerName, + key + ? { + ...entry, + rows: [ + ...entry.rows, + ...readPersistedCustomCronRows(storage, key), + ], + } + : entry, + ]; + }) + ); +} + +function scheduledResult(value: unknown): FetcherScheduledResult | undefined { + if ( + typeof value !== "object" || + value === null || + !("outcome" in value) || + !("noRetry" in value) + ) { + return undefined; + } + const outcome = (value as { outcome?: unknown }).outcome; + const noRetry = (value as { noRetry?: unknown }).noRetry; + return typeof outcome === "string" && typeof noRetry === "boolean" + ? ({ ...(value as object), outcome, noRetry } as FetcherScheduledResult) + : undefined; +} + +export function CronTriggersProvider({ + active, + activeWorkerName, + bootstrapAuthoritative, + children, + seedWorkers, +}: PropsWithChildren<{ + activeWorkerName?: string; + bootstrapAuthoritative: boolean; + seedWorkers: unknown[]; + active: boolean; +}>) { + const storage = useRef(localStorageIfAvailable()); + const [initialPersistence] = useState(() => { + const metadata = bootstrapAuthoritative + ? (parseCronWorkerMetadata(seedWorkers) ?? []) + : []; + const keys = persistenceKeysForMetadata(metadata); + return { + keys, + workers: hydrateInitialCustomRows( + createCronStateFromSeed(seedWorkers, bootstrapAuthoritative), + keys, + storage.current + ), + }; + }); + const [workers, setWorkers] = useState>( + initialPersistence.workers + ); + const [persistenceKeys, setPersistenceKeys] = useState( + initialPersistence.keys + ); + const persistenceKeysRef = useRef(initialPersistence.keys); + const lastScopedPersistenceKeys = useRef(initialPersistence.keys); + const hydratedPersistenceKeys = useRef( + new Set(Object.values(initialPersistence.keys)) + ); + const lastPersistedCustomRows = useRef(new Map()); + const [fallbackWorkerName, setFallbackWorkerName] = useState(() => { + const metadata = parseCronWorkerMetadata(seedWorkers) ?? []; + return selectCronFallbackWorker(metadata); + }); + const [refreshingWorkers, setRefreshingWorkers] = useState>( + new Set() + ); + const generation = useRef(new RefreshGenerationTracker()); + const refreshingGeneration = useRef(new Map()); + const pendingRows = useRef(new Set()); + + useEffect(() => { + if (!storage.current) { + return; + } + for (const [workerName, key] of Object.entries(persistenceKeys)) { + if (!hydratedPersistenceKeys.current.has(key)) { + continue; + } + const customRows = (workers[workerName]?.rows ?? []).filter( + (row) => row.source === "custom" + ); + const previous = lastPersistedCustomRows.current.get(key); + if ( + previous && + previous.length === customRows.length && + previous.every((row, index) => row === customRows[index]) + ) { + continue; + } + writePersistedCustomCronRows(storage.current, key, customRows); + lastPersistedCustomRows.current.set(key, customRows); + } + }, [persistenceKeys, workers]); + + const updateRow = useCallback( + (workerName: string, id: string, update: (row: CronRow) => CronRow) => { + setWorkers((current) => { + const entry = current[workerName] ?? emptyState(); + return { + ...current, + [workerName]: { + ...entry, + rows: entry.rows.map((row) => (row.id === id ? update(row) : row)), + }, + }; + }); + }, + [] + ); + + const refresh = useCallback(async (workerName: string, automatic = false) => { + const requestGeneration = generation.current.start(); + refreshingGeneration.current.set(workerName, requestGeneration); + setRefreshingWorkers((current) => new Set(current).add(workerName)); + try { + const response = await fetch(`${LOCAL_EXPLORER_API_PATH}/local/workers`, { + headers: automatic ? { [REFRESH_HEADER]: "poll" } : undefined, + }); + if (!response.ok) { + throw new Error(`Refresh failed with status ${response.status}.`); + } + const envelope = (await response.json()) as WorkersEnvelope; + const metadata = parseCronWorkerMetadata(envelope.result); + if (!metadata) { + throw new Error("Refresh returned invalid Worker metadata."); + } + if (!generation.current.isLatest(requestGeneration)) { + return; + } + setFallbackWorkerName(selectCronFallbackWorker(metadata)); + const previousScopedPersistenceKeys = lastScopedPersistenceKeys.current; + const returnedPersistenceKeys = persistenceKeysForMetadata(metadata); + const nextPersistenceKeys = reconcilePersistenceKeysForRefresh( + persistenceKeysRef.current, + metadata + ); + setWorkers((current) => { + const next = { ...current }; + for (const worker of metadata) { + const entry = current[worker.name] ?? emptyState(); + const crons = worker.triggers?.crons ?? []; + const persistenceKey = returnedPersistenceKeys[worker.name]; + const persistenceScopeChanged = + shouldReplaceCustomRowsForPersistenceScope( + previousScopedPersistenceKeys[worker.name], + persistenceKey + ); + const existingRows = persistenceScopeChanged + ? entry.rows.filter((row) => row.source !== "custom") + : entry.rows; + let rows = reconcileConfiguredRows(existingRows, crons); + if ( + persistenceKey && + (persistenceScopeChanged || + !hydratedPersistenceKeys.current.has(persistenceKey)) + ) { + rows = [ + ...rows, + ...(storage.current + ? readPersistedCustomCronRows(storage.current, persistenceKey) + : []), + ]; + hydratedPersistenceKeys.current.add(persistenceKey); + } + next[worker.name] = { + authoritative: true, + crons, + rows, + stale: false, + }; + } + if ( + workerName !== "" && + !metadata.some((worker) => worker.name === workerName) + ) { + const entry = current[workerName] ?? emptyState(); + next[workerName] = { ...entry, stale: true }; + } + return next; + }); + lastScopedPersistenceKeys.current = { + ...previousScopedPersistenceKeys, + ...nextPersistenceKeys, + }; + persistenceKeysRef.current = nextPersistenceKeys; + setPersistenceKeys(nextPersistenceKeys); + } catch { + if (!generation.current.isLatest(requestGeneration)) { + return; + } + setWorkers((current) => { + if (workerName === "") { + return current; + } + const entry = current[workerName] ?? emptyState(); + return { ...current, [workerName]: { ...entry, stale: true } }; + }); + } finally { + if (refreshingGeneration.current.get(workerName) === requestGeneration) { + refreshingGeneration.current.delete(workerName); + setRefreshingWorkers((current) => { + const next = new Set(current); + next.delete(workerName); + return next; + }); + } + } + }, []); + + useEffect(() => { + if (!active) { + return; + } + const refreshWorker = activeWorkerName ?? ""; + void refresh(refreshWorker, true); + const poll = window.setInterval(() => { + if (document.visibilityState === "visible") { + void refresh(refreshWorker, true); + } + }, POLL_INTERVAL_MS); + const refreshWhenVisible = () => { + if (document.visibilityState === "visible") { + void refresh(refreshWorker, true); + } + }; + window.addEventListener("focus", refreshWhenVisible); + document.addEventListener("visibilitychange", refreshWhenVisible); + return () => { + window.clearInterval(poll); + window.removeEventListener("focus", refreshWhenVisible); + document.removeEventListener("visibilitychange", refreshWhenVisible); + }; + }, [active, activeWorkerName, refresh]); + + const value = useMemo( + () => ({ + addCustom(workerName) { + const row = createCronRow(""); + setWorkers((current) => { + const entry = current[workerName] ?? emptyState(); + return { + ...current, + [workerName]: { ...entry, rows: [...entry.rows, row] }, + }; + }); + return row.id; + }, + duplicateRow(workerName, id) { + let duplicateId: string | undefined; + setWorkers((current) => { + const entry = current[workerName] ?? emptyState(); + const row = entry.rows.find((candidate) => candidate.id === id); + if (!row) { + return current; + } + const duplicate = duplicateCronRow(row); + duplicateId = duplicate.id; + return { + ...current, + [workerName]: { ...entry, rows: [...entry.rows, duplicate] }, + }; + }); + return duplicateId; + }, + entry: (workerName) => workers[workerName] ?? emptyState(), + fallbackWorkerName, + isRefreshing: (workerName) => refreshingWorkers.has(workerName), + async invoke(workerName, id, scheduledTime) { + const pendingKey = `${workerName}\u0000${id}`; + const row = workers[workerName]?.rows.find( + (candidate) => candidate.id === id + ); + if ( + !row || + pendingRows.current.has(pendingKey) || + row.invocation?.status === "pending" || + row.cron.trim() === "" + ) { + return; + } + pendingRows.current.add(pendingKey); + const requestId = crypto.randomUUID(); + const snapshot = { cron: row.cron, requestId, scheduledTime }; + updateRow(workerName, id, (current) => ({ + ...current, + invocation: { ...snapshot, status: "pending" }, + })); + try { + const response = await fetch( + `${LOCAL_EXPLORER_API_PATH}/local/scheduled?worker=${encodeURIComponent(workerName)}`, + { + body: JSON.stringify({ + cron: snapshot.cron, + scheduled_time: snapshot.scheduledTime, + }), + headers: { "Content-Type": "application/json" }, + method: "POST", + } + ); + const envelope = (await response.json().catch(() => undefined)) as + | ScheduledEnvelope + | undefined; + if (!response.ok) { + throw new Error( + envelope?.errors?.[0]?.message ?? + `Invocation failed with status ${response.status}.` + ); + } + const result = scheduledResult(envelope?.result); + if (!result) { + throw new Error("Invocation returned an invalid result."); + } + updateRow(workerName, id, (current) => + current.invocation?.requestId === requestId + ? { + ...current, + invocation: { ...snapshot, result, status: "result" }, + } + : current + ); + } catch (error) { + updateRow(workerName, id, (current) => + current.invocation?.requestId === requestId + ? { + ...current, + invocation: { + ...snapshot, + error: + error instanceof Error + ? error.message + : "Invocation failed.", + status: "error", + }, + } + : current + ); + } finally { + pendingRows.current.delete(pendingKey); + } + }, + refresh, + removeRow(workerName, id) { + setWorkers((current) => { + const entry = current[workerName] ?? emptyState(); + const row = entry.rows.find((candidate) => candidate.id === id); + if (!row || row.invocation?.status === "pending") { + return current; + } + return { + ...current, + [workerName]: { + ...entry, + rows: entry.rows.filter((candidate) => candidate.id !== id), + }, + }; + }); + }, + updateRow, + }), + [fallbackWorkerName, refresh, refreshingWorkers, updateRow, workers] + ); + + return ( + + {children} + + ); +} + +export function useCronTriggers(): CronTriggersContextValue { + const context = useContext(CronTriggersContext); + if (!context) { + throw new Error( + "useCronTriggers must be used inside CronTriggersProvider." + ); + } + return context; +} + +export { REFRESH_HEADER }; + +export class RefreshGenerationTracker { + #latest = 0; + + start(): number { + this.#latest += 1; + return this.#latest; + } + + isLatest(generation: number): boolean { + return generation === this.#latest; + } +} diff --git a/packages/local-explorer-ui/src/components/cron-triggers/CronTriggersPage.tsx b/packages/local-explorer-ui/src/components/cron-triggers/CronTriggersPage.tsx new file mode 100644 index 00000000000..acbbfa2f585 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/CronTriggersPage.tsx @@ -0,0 +1,334 @@ +import { Button, RefreshButton, Tooltip } from "@cloudflare/kumo"; +import { ClockCountdownIcon, InfoIcon, PlusIcon } from "@phosphor-icons/react"; +import { useEffect, useRef, useState } from "react"; +import { Breadcrumbs } from "../Breadcrumbs"; +import { CronRowCard } from "./CronRowCard"; +import { useCronTriggers } from "./CronTriggersContext"; +import type { CronRow } from "./types"; +import type { JSX } from "react"; + +const CRON_CONFIGURATION_DOCS = + "https://developers.cloudflare.com/workers/configuration/cron-triggers/"; +const SCHEDULED_HANDLER_DOCS = + "https://developers.cloudflare.com/workers/runtime-apis/handlers/scheduled/"; + +export function CronTriggersPage({ + activeWorkerName, +}: { + activeWorkerName?: string; +}): JSX.Element { + const cron = useCronTriggers(); + const workerName = activeWorkerName ?? cron.fallbackWorkerName; + const entry = cron.entry(workerName); + const [focusRow, setFocusRow] = useState(); + const focusedRow = useRef(undefined); + const configured = entry.crons ?? []; + const configuredRows = entry.rows.filter((row) => row.source !== "custom"); + const customRows = entry.rows.filter((row) => row.source === "custom"); + const previousPaneRows = useRef({ + configured: configuredRows.map((row) => row.id), + custom: customRows.map((row) => row.id), + }); + const showNoConfiguration = entry.authoritative && configured.length === 0; + const canAddCustom = configured.length > 0; + + useEffect(() => { + const nextPaneRows = { + configured: entry.rows + .filter((row) => row.source !== "custom") + .map((row) => row.id), + custom: entry.rows + .filter((row) => row.source === "custom") + .map((row) => row.id), + }; + const focused = focusedRow.current; + const activeElement = document.activeElement as HTMLElement | null; + const activeRow = + activeElement?.closest("[data-row-id]")?.dataset.rowId; + if ( + focused && + !entry.rows.some((row) => row.id === focused) && + activeRow === undefined && + (activeElement === document.body || activeElement === null) + ) { + const pane = previousPaneRows.current.custom.includes(focused) + ? "custom" + : "configured"; + const previousIndex = previousPaneRows.current[pane].indexOf(focused); + const nextRows = nextPaneRows[pane]; + const target = + nextRows[previousIndex] ?? nextRows[Math.max(0, previousIndex - 1)]; + requestAnimationFrame(() => { + if (target) { + document + .querySelector(`[data-row-id="${target}"] button`) + ?.focus(); + } else { + focusPane(pane); + } + }); + } else if (activeRow !== focused) { + focusedRow.current = activeRow; + } + previousPaneRows.current = nextPaneRows; + }, [entry.rows]); + + function focusSoon(rowId: string | undefined, pane?: CronPaneKind): void { + if (rowId) { + setFocusRow(rowId); + return; + } + requestAnimationFrame(() => { + if (pane) { + focusPane(pane); + } else { + document + .querySelector("[data-add-custom], [data-cron-heading]") + ?.focus(); + } + }); + } + + function renderRow(row: CronRow): JSX.Element { + return ( + setFocusRow(undefined)} + onDuplicate={() => focusSoon(cron.duplicateRow(workerName, row.id))} + onRemove={() => { + const pane = row.source === "custom" ? "custom" : "configured"; + const paneRows = pane === "custom" ? customRows : configuredRows; + const index = paneRows.findIndex( + (candidate) => candidate.id === row.id + ); + const nextFocus = paneRows[index + 1]?.id ?? paneRows[index - 1]?.id; + cron.removeRow(workerName, row.id); + focusSoon(nextFocus, pane); + }} + onUpdate={(update) => cron.updateRow(workerName, row.id, update)} + row={row} + triggerEnabled={!showNoConfiguration} + trigger={(scheduledTime) => + void cron.invoke(workerName, row.id, scheduledTime) + } + /> + ); + } + + return ( +
+ Cron Triggers]} + title="Cron Triggers" + > +
+ + + + void cron.refresh(workerName)} + /> +
+
+ +
{ + focusedRow.current = ( + event.target as HTMLElement + ).closest("[data-row-id]")?.dataset.rowId; + }} + > + {entry.stale && entry.authoritative ? : null} + {!entry.authoritative ? ( + void cron.refresh(workerName)} /> + ) : ( + <> + {showNoConfiguration ? : null} + {entry.rows.length > 0 ? ( +
+ + {configuredRows.map(renderRow)} + + + focusSoon(cron.addCustom(workerName))} + size="sm" + variant="secondary" + > + Add draft + + ) : null + } + help="Draft crons are stored locally and do not modify your Worker configuration." + pane="custom" + title="Draft crons" + > + {customRows.length === 0 ? ( +

+ Draft crons you add or duplicate appear here. +

+ ) : null} + {customRows.map(renderRow)} +
+
+ ) : null} + + )} +
+
+ ); +} + +function CronPane({ + action, + children, + help, + pane, + title, +}: { + action?: JSX.Element | null; + children: React.ReactNode; + help?: string; + pane: CronPaneKind; + title: string; +}): JSX.Element { + return ( +
+
+
+

+ {title} +

+ {help ? ( + + + + ) : null} +
+ {action} +
+
+ {children} +
+
+ ); +} + +type CronPaneKind = "configured" | "custom"; + +function focusPane(pane: CronPaneKind): void { + document + .querySelector( + `[data-cron-pane="${pane}"] [data-add-custom], [data-cron-pane="${pane}"] [data-cron-pane-heading], [data-cron-heading]` + ) + ?.focus(); +} + +function RefreshWarning(): JSX.Element { + return ( +
+ Cron Triggers could not be refreshed. Existing rows may be stale; check + your development console. +
+ ); +} + +function UnavailableState({ + onRefresh, +}: { + onRefresh: () => void; +}): JSX.Element { + return ( +
+

+ Cron Triggers are unavailable +

+

+ Worker metadata could not be loaded. Check your development console and + try again. +

+ +
+ ); +} + +function NoConfigurationState(): JSX.Element { + return ( +
+

+ No Cron Triggers configured +

+

+ Configure at least one Cron Trigger to test this Worker. Its scheduled() + handler consumes Cron Trigger invocations. +

+

+ + Configure Cron Triggers + + + Scheduled Handler documentation + +

+
+ ); +} diff --git a/packages/local-explorer-ui/src/components/cron-triggers/cron-builder.ts b/packages/local-explorer-ui/src/components/cron-triggers/cron-builder.ts new file mode 100644 index 00000000000..ea3da3d2e40 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/cron-builder.ts @@ -0,0 +1,233 @@ +import type { CronBuilderDraft, CronWeekday } from "./types"; + +const WEEKDAYS: CronWeekday[] = [ + "sun", + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", +]; + +export interface CronBuilderResult { + expression?: string; + errors: Record; +} + +function integer( + value: string, + minimum: number, + maximum: number, + label: string +): { error?: string; value?: number } { + if (!/^\d+$/.test(value)) { + return { error: `${label} must be a whole number.` }; + } + const parsed = Number(value); + if (parsed < minimum || parsed > maximum) { + return { error: `${label} must be between ${minimum} and ${maximum}.` }; + } + return { value: parsed }; +} + +function field( + errors: Record, + name: string, + value: string, + minimum: number, + maximum: number, + label: string +): string { + const result = integer(value, minimum, maximum, label); + if (result.error) { + errors[name] = result.error; + } + return result.value === undefined ? value : String(result.value); +} + +export function createDefaultCronBuilderDraft(): CronBuilderDraft { + return { kind: "daily", hour: "0", minute: "0" }; +} + +/** Generate the exact five-field expression represented by a builder draft. */ +export function generateCronExpression( + draft: CronBuilderDraft +): CronBuilderResult { + const errors: Record = {}; + const minute = + "minute" in draft + ? field(errors, "minute", draft.minute, 0, 59, "Minute") + : undefined; + const hour = + "hour" in draft + ? field(errors, "hour", draft.hour, 0, 23, "Hour") + : undefined; + let expression: string; + + switch (draft.kind) { + case "minute-interval": { + const every = field( + errors, + "every", + draft.every, + 1, + 59, + "Minute interval" + ); + expression = `*/${every} * * * *`; + break; + } + case "hour-interval": { + const every = field(errors, "every", draft.every, 1, 23, "Hour interval"); + expression = `${minute} */${every} * * *`; + break; + } + case "day-of-month-interval": { + const every = field(errors, "every", draft.every, 1, 31, "Day interval"); + expression = `${minute} ${hour} */${every} * *`; + break; + } + case "month-interval": { + const every = field( + errors, + "every", + draft.every, + 1, + 12, + "Month interval" + ); + const day = field( + errors, + "dayOfMonth", + draft.dayOfMonth, + 1, + 31, + "Day of month" + ); + expression = `${minute} ${hour} ${day} */${every} *`; + break; + } + case "daily": + expression = `${minute} ${hour} * * *`; + break; + case "weekdays": { + const selected = WEEKDAYS.filter((weekday) => + draft.weekdays.includes(weekday) + ); + if (selected.length === 0) { + errors.weekdays = "Select at least one weekday."; + } + expression = `${minute} ${hour} * * ${selected.join(",")}`; + break; + } + case "monthly": { + const day = field( + errors, + "dayOfMonth", + draft.dayOfMonth, + 1, + 31, + "Day of month" + ); + expression = `${minute} ${hour} ${day} * *`; + break; + } + case "last-day-of-month": + expression = `${minute} ${hour} L * *`; + break; + case "last-weekday-of-month": + expression = `${minute} ${hour} LW * *`; + break; + case "nearest-weekday": { + const day = field( + errors, + "dayOfMonth", + draft.dayOfMonth, + 1, + 31, + "Day of month" + ); + expression = `${minute} ${hour} ${day}W * *`; + break; + } + case "last-named-weekday": + expression = `${minute} ${hour} * * ${draft.weekday}L`; + break; + case "nth-weekday": { + const occurrence = field( + errors, + "occurrence", + draft.occurrence, + 1, + 5, + "Weekday occurrence" + ); + expression = `${minute} ${hour} * * ${draft.weekday}#${occurrence}`; + break; + } + } + + return Object.keys(errors).length === 0 ? { expression, errors } : { errors }; +} + +export const cronBuilderKinds: Array<{ + label: string; + value: CronBuilderDraft["kind"]; +}> = [ + { label: "Every N minutes", value: "minute-interval" }, + { label: "Every N hours", value: "hour-interval" }, + { label: "Every N days of the month", value: "day-of-month-interval" }, + { label: "Every N months", value: "month-interval" }, + { label: "Every day", value: "daily" }, + { label: "Selected weekdays", value: "weekdays" }, + { label: "Day of each month", value: "monthly" }, + { label: "Last day of each month", value: "last-day-of-month" }, + { label: "Nearest weekday", value: "nearest-weekday" }, + { label: "Last weekday of each month", value: "last-weekday-of-month" }, + { label: "Last selected weekday", value: "last-named-weekday" }, + { label: "Nth selected weekday", value: "nth-weekday" }, +]; + +export function changeCronBuilderKind( + kind: CronBuilderDraft["kind"] +): CronBuilderDraft { + switch (kind) { + case "minute-interval": + return { kind, every: "5" }; + case "hour-interval": + return { kind, every: "1", minute: "0" }; + case "day-of-month-interval": + return { kind, every: "1", hour: "0", minute: "0" }; + case "month-interval": + return { + kind, + every: "1", + dayOfMonth: "1", + hour: "0", + minute: "0", + }; + case "daily": + return { kind, hour: "0", minute: "0" }; + case "weekdays": + return { kind, weekdays: ["mon"], hour: "0", minute: "0" }; + case "monthly": + case "nearest-weekday": + return { kind, dayOfMonth: "1", hour: "0", minute: "0" }; + case "last-day-of-month": + case "last-weekday-of-month": + return { kind, hour: "0", minute: "0" }; + case "last-named-weekday": + return { kind, weekday: "fri", hour: "0", minute: "0" }; + case "nth-weekday": + return { + kind, + weekday: "mon", + occurrence: "1", + hour: "0", + minute: "0", + }; + } +} + +export { WEEKDAYS as cronWeekdays }; diff --git a/packages/local-explorer-ui/src/components/cron-triggers/persistence.ts b/packages/local-explorer-ui/src/components/cron-triggers/persistence.ts new file mode 100644 index 00000000000..3e8be974755 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/persistence.ts @@ -0,0 +1,316 @@ +import { createCronRow } from "./row-state"; +import { + parseEpochMilliseconds, + resolveUtcCalendarTime, +} from "./scheduled-time"; +import type { CronBuilderDraft, CronRow, CronWeekday } from "./types"; + +export const CRON_CUSTOM_ROWS_STORAGE_PREFIX = + "local-explorer.cron-triggers.custom-rows.v1"; +export const MAX_PERSISTED_CUSTOM_ROWS = 100; +export const MAX_PERSISTED_CUSTOM_ROWS_BYTES = 256 * 1024; + +type PersistedCustomRow = Pick< + CronRow, + | "builderApplied" + | "calendarValue" + | "cron" + | "cronBuilder" + | "cronInputMode" + | "customTimeInputMode" + | "epochValue" + | "timeMode" +>; + +const WEEKDAYS = new Set([ + "sun", + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", +]); + +const ROW_KEYS = new Set([ + "builderApplied", + "calendarValue", + "cron", + "cronBuilder", + "cronInputMode", + "customTimeInputMode", + "epochValue", + "timeMode", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOnlyKeys( + value: Record, + required: string[], + optional: string[] = [] +): boolean { + const requiredKeys = new Set(required); + const allowedKeys = new Set([...required, ...optional]); + return ( + required.every((key) => key in value) && + Object.keys(value).every((key) => allowedKeys.has(key)) && + Object.keys(value).length >= requiredKeys.size + ); +} + +function stringFields( + value: Record, + fields: string[] +): boolean { + return fields.every((field) => typeof value[field] === "string"); +} + +function parseCronBuilderDraft(value: unknown): CronBuilderDraft | undefined { + if (!isRecord(value) || typeof value.kind !== "string") { + return undefined; + } + + let required: string[]; + switch (value.kind) { + case "minute-interval": + required = ["kind", "every"]; + break; + case "hour-interval": + required = ["kind", "every", "minute"]; + break; + case "day-of-month-interval": + required = ["kind", "every", "hour", "minute"]; + break; + case "month-interval": + required = ["kind", "every", "dayOfMonth", "hour", "minute"]; + break; + case "daily": + case "last-day-of-month": + case "last-weekday-of-month": + required = ["kind", "hour", "minute"]; + break; + case "weekdays": + required = ["kind", "weekdays", "hour", "minute"]; + if ( + !Array.isArray(value.weekdays) || + value.weekdays.length > WEEKDAYS.size || + value.weekdays.some( + (weekday) => + typeof weekday !== "string" || !WEEKDAYS.has(weekday as CronWeekday) + ) || + new Set(value.weekdays).size !== value.weekdays.length + ) { + return undefined; + } + break; + case "monthly": + case "nearest-weekday": + required = ["kind", "dayOfMonth", "hour", "minute"]; + break; + case "last-named-weekday": + required = ["kind", "weekday", "hour", "minute"]; + if ( + typeof value.weekday !== "string" || + !WEEKDAYS.has(value.weekday as CronWeekday) + ) { + return undefined; + } + break; + case "nth-weekday": + required = ["kind", "weekday", "occurrence", "hour", "minute"]; + if ( + typeof value.weekday !== "string" || + !WEEKDAYS.has(value.weekday as CronWeekday) + ) { + return undefined; + } + break; + default: + return undefined; + } + + if (!hasOnlyKeys(value, required)) { + return undefined; + } + const nonStringFields = new Set(["kind", "weekday", "weekdays"]); + if ( + !stringFields( + value, + required.filter((key) => !nonStringFields.has(key)) + ) + ) { + return undefined; + } + return value as unknown as CronBuilderDraft; +} + +function parsePersistedCustomRow( + value: unknown +): PersistedCustomRow | undefined { + if ( + !isRecord(value) || + !("cron" in value) || + !("cronBuilder" in value) || + !("cronInputMode" in value) || + !("customTimeInputMode" in value) || + !("timeMode" in value) || + Object.keys(value).some((key) => !ROW_KEYS.has(key)) || + typeof value.cron !== "string" || + (value.cronInputMode !== "expression" && + value.cronInputMode !== "builder") || + (value.customTimeInputMode !== "calendar" && + value.customTimeInputMode !== "epoch") || + (value.timeMode !== "now" && value.timeMode !== "custom") || + (value.builderApplied !== undefined && + typeof value.builderApplied !== "boolean") || + (value.calendarValue !== undefined && + typeof value.calendarValue !== "string") || + (value.epochValue !== undefined && typeof value.epochValue !== "string") + ) { + return undefined; + } + const cronBuilder = parseCronBuilderDraft(value.cronBuilder); + if (!cronBuilder) { + return undefined; + } + return { + ...(value.builderApplied === undefined + ? {} + : { builderApplied: value.builderApplied }), + ...(value.calendarValue === undefined + ? {} + : { calendarValue: value.calendarValue }), + cron: value.cron, + cronBuilder, + cronInputMode: value.cronInputMode, + customTimeInputMode: value.customTimeInputMode, + ...(value.epochValue === undefined ? {} : { epochValue: value.epochValue }), + timeMode: value.timeMode, + }; +} + +function byteLength(value: string): number { + return new TextEncoder().encode(value).byteLength; +} + +function remove(storage: Storage, key: string): void { + try { + storage.removeItem(key); + } catch { + // Storage can be unavailable in privacy modes or restricted frames. + } +} + +export function cronCustomRowsStorageKey( + persistenceScope: string | undefined, + workerName: string +): string | undefined { + return persistenceScope + ? `${CRON_CUSTOM_ROWS_STORAGE_PREFIX}.${persistenceScope}.${encodeURIComponent(workerName)}` + : undefined; +} + +function hydrateRow(draft: PersistedCustomRow): CronRow { + let customEpochMs: number | undefined; + if (draft.timeMode === "custom") { + if (draft.customTimeInputMode === "calendar") { + const resolved = resolveUtcCalendarTime(draft.calendarValue ?? ""); + customEpochMs = resolved.kind === "exact" ? resolved.epochMs : undefined; + } else { + customEpochMs = parseEpochMilliseconds(draft.epochValue ?? "").epochMs; + } + } + return { + ...createCronRow(draft.cron), + ...draft, + ...(customEpochMs === undefined ? {} : { customEpochMs }), + }; +} + +/** Read validated custom drafts and recreate transient row state from scratch. */ +export function readPersistedCustomCronRows( + storage: Storage, + key: string +): CronRow[] { + let raw: string | null; + try { + raw = storage.getItem(key); + } catch { + return []; + } + if (raw === null) { + return []; + } + if (byteLength(raw) > MAX_PERSISTED_CUSTOM_ROWS_BYTES) { + remove(storage, key); + return []; + } + + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + remove(storage, key); + return []; + } + if ( + !Array.isArray(value) || + value.length === 0 || + value.length > MAX_PERSISTED_CUSTOM_ROWS + ) { + remove(storage, key); + return []; + } + const drafts = value.map(parsePersistedCustomRow); + if (drafts.some((draft) => draft === undefined)) { + remove(storage, key); + return []; + } + return (drafts as PersistedCustomRow[]).map(hydrateRow); +} + +function persistedDraft(row: CronRow): PersistedCustomRow { + return { + ...(row.builderApplied === undefined + ? {} + : { builderApplied: row.builderApplied }), + ...(row.calendarValue === undefined + ? {} + : { calendarValue: row.calendarValue }), + cron: row.cron, + cronBuilder: row.cronBuilder, + cronInputMode: row.cronInputMode, + customTimeInputMode: row.customTimeInputMode, + ...(row.epochValue === undefined ? {} : { epochValue: row.epochValue }), + timeMode: row.timeMode, + }; +} + +/** Persist only editable custom-row drafts; transient and configured state is omitted. */ +export function writePersistedCustomCronRows( + storage: Storage, + key: string, + rows: CronRow[] +): void { + const customRows = rows + .filter((row) => row.source === "custom") + .slice(0, MAX_PERSISTED_CUSTOM_ROWS) + .map(persistedDraft); + if (customRows.length === 0) { + remove(storage, key); + return; + } + const raw = JSON.stringify(customRows); + if (byteLength(raw) > MAX_PERSISTED_CUSTOM_ROWS_BYTES) { + remove(storage, key); + return; + } + try { + storage.setItem(key, raw); + } catch { + // Quota, privacy, and security errors must not break the editor. + } +} diff --git a/packages/local-explorer-ui/src/components/cron-triggers/row-state.ts b/packages/local-explorer-ui/src/components/cron-triggers/row-state.ts new file mode 100644 index 00000000000..b6b55bff72e --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/row-state.ts @@ -0,0 +1,76 @@ +import { createDefaultCronBuilderDraft } from "./cron-builder"; +import type { CronRow } from "./types"; + +function rowId(prefix: string): string { + return `${prefix}-${crypto.randomUUID()}`; +} + +function configuredKeys(crons: string[]): string[] { + const counts = new Map(); + return crons.map((cron) => { + const ordinal = counts.get(cron) ?? 0; + counts.set(cron, ordinal + 1); + return `${cron}\u0000${ordinal}`; + }); +} + +export function createCronRow( + cron: string, + source: CronRow["source"] = "custom" +): CronRow { + return { + id: rowId(source), + source, + cron, + cronInputMode: "expression", + cronBuilder: createDefaultCronBuilderDraft(), + timeMode: "now", + customTimeInputMode: "calendar", + }; +} + +/** Merge configured rows by exact expression plus duplicate occurrence ordinal. */ +export function reconcileConfiguredRows( + rows: CronRow[], + crons: string[] +): CronRow[] { + const oldConfigured = rows.filter((row) => row.source === "configured"); + const oldKeys = configuredKeys(oldConfigured.map((row) => row.cron)); + const oldByKey = new Map(); + oldKeys.forEach((key, index) => { + const row = oldConfigured[index]; + if (row) { + oldByKey.set(key, row); + } + }); + const nextKeys = configuredKeys(crons); + const retainedIds = new Set(); + const configured = crons.map((cron, index) => { + const key = nextKeys[index]; + const existing = key === undefined ? undefined : oldByKey.get(key); + if (existing) { + retainedIds.add(existing.id); + return existing; + } + return createCronRow(cron, "configured"); + }); + const staleSettled = oldConfigured + .filter((row) => !retainedIds.has(row.id) && row.invocation !== undefined) + .map((row) => ({ ...row, source: "no-longer-configured" as const })); + const local = rows.filter((row) => row.source !== "configured"); + return [...configured, ...local, ...staleSettled]; +} + +export function duplicateCronRow(row: CronRow): CronRow { + return { + ...row, + id: rowId("custom"), + source: "custom", + cronBuilder: structuredClone(row.cronBuilder), + invocation: undefined, + }; +} + +export function rowIsPending(row: CronRow): boolean { + return row.invocation?.status === "pending"; +} diff --git a/packages/local-explorer-ui/src/components/cron-triggers/scheduled-time.ts b/packages/local-explorer-ui/src/components/cron-triggers/scheduled-time.ts new file mode 100644 index 00000000000..ddca809030d --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/scheduled-time.ts @@ -0,0 +1,102 @@ +import type { CronRow } from "./types"; + +export const MIN_DATE_EPOCH_MS = -9_223_372_036_854; +export const MAX_DATE_EPOCH_MS = 9_223_372_036_854; + +export type UtcCalendarResolution = + | { kind: "invalid"; error: string } + | { kind: "exact"; epochMs: number; utc: string }; + +const UTC_CALENDAR_PATTERN = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?$/; + +/** Resolve an explicitly UTC calendar value using UTC getters and setters only. */ +export function resolveUtcCalendarTime(value: string): UtcCalendarResolution { + const match = UTC_CALENDAR_PATTERN.exec(value); + if (!match) { + return { + kind: "invalid", + error: + "Enter a UTC date and time with a four-digit year and optional milliseconds.", + }; + } + + const year = Number(match[1]); + const month = Number(match[2]); + const day = Number(match[3]); + const hour = Number(match[4]); + const minute = Number(match[5]); + const second = Number(match[6] ?? "0"); + const millisecond = Number((match[7] ?? "0").padEnd(3, "0")); + if (year < 1 || year > 9999) { + return { kind: "invalid", error: "Year must be between 0001 and 9999." }; + } + + const date = new Date(0); + date.setUTCFullYear(year, month - 1, day); + date.setUTCHours(hour, minute, second, millisecond); + if ( + date.getUTCFullYear() !== year || + date.getUTCMonth() !== month - 1 || + date.getUTCDate() !== day || + date.getUTCHours() !== hour || + date.getUTCMinutes() !== minute || + date.getUTCSeconds() !== second || + date.getUTCMilliseconds() !== millisecond + ) { + return { + kind: "invalid", + error: "Enter a valid UTC calendar date and time.", + }; + } + + const epochMs = date.getTime(); + return { kind: "exact", epochMs, utc: date.toISOString() }; +} + +export function parseEpochMilliseconds(value: string): { + epochMs?: number; + error?: string; +} { + if (!/^-?(0|[1-9]\d*)$/.test(value)) { + return { error: "Epoch milliseconds must be a base-10 integer." }; + } + const epochMs = Number(value); + if ( + !Number.isSafeInteger(epochMs) || + epochMs < MIN_DATE_EPOCH_MS || + epochMs > MAX_DATE_EPOCH_MS + ) { + return { + error: + "Epoch milliseconds are outside the supported scheduled-time range.", + }; + } + return { epochMs }; +} + +export function formatUtcCalendarValue(epochMs: number): string { + return new Date(epochMs).toISOString().slice(0, -1); +} + +/** Enter custom-time mode, initializing only rows without an existing draft. */ +export function enterCustomTimeMode(row: CronRow, now: number): CronRow { + if (row.calendarValue === undefined && row.epochValue === undefined) { + return { + ...row, + calendarValue: formatUtcCalendarValue(now), + customEpochMs: now, + epochValue: String(now), + timeMode: "custom", + }; + } + + const customEpochMs = + row.customTimeInputMode === "calendar" + ? (() => { + const resolution = resolveUtcCalendarTime(row.calendarValue ?? ""); + return resolution.kind === "exact" ? resolution.epochMs : undefined; + })() + : parseEpochMilliseconds(row.epochValue ?? "").epochMs; + return { ...row, customEpochMs, timeMode: "custom" }; +} diff --git a/packages/local-explorer-ui/src/components/cron-triggers/types.ts b/packages/local-explorer-ui/src/components/cron-triggers/types.ts new file mode 100644 index 00000000000..85479b8e589 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/types.ts @@ -0,0 +1,91 @@ +export type CronRowSource = "configured" | "custom" | "no-longer-configured"; + +export type CronInputMode = "expression" | "builder"; +export type CronWeekday = "sun" | "mon" | "tue" | "wed" | "thu" | "fri" | "sat"; + +export type CronBuilderDraft = + | { kind: "minute-interval"; every: string } + | { kind: "hour-interval"; every: string; minute: string } + | { + kind: "day-of-month-interval"; + every: string; + hour: string; + minute: string; + } + | { + kind: "month-interval"; + every: string; + dayOfMonth: string; + hour: string; + minute: string; + } + | { kind: "daily"; hour: string; minute: string } + | { + kind: "weekdays"; + weekdays: CronWeekday[]; + hour: string; + minute: string; + } + | { kind: "monthly"; dayOfMonth: string; hour: string; minute: string } + | { kind: "last-day-of-month"; hour: string; minute: string } + | { kind: "last-weekday-of-month"; hour: string; minute: string } + | { + kind: "nearest-weekday"; + dayOfMonth: string; + hour: string; + minute: string; + } + | { + kind: "last-named-weekday"; + weekday: CronWeekday; + hour: string; + minute: string; + } + | { + kind: "nth-weekday"; + weekday: CronWeekday; + occurrence: string; + hour: string; + minute: string; + }; + +export type TimeMode = "now" | "custom"; +export type CustomTimeInputMode = "calendar" | "epoch"; +export type InvocationStatus = "pending" | "result" | "error"; + +export interface FetcherScheduledResult { + outcome: string; + noRetry: boolean; + [key: string]: unknown; +} + +export interface InvocationSnapshot { + requestId: string; + cron: string; + scheduledTime: number; + status: InvocationStatus; + result?: FetcherScheduledResult; + error?: string; +} + +export interface CronRow { + id: string; + source: CronRowSource; + cron: string; + cronInputMode: CronInputMode; + cronBuilder: CronBuilderDraft; + builderApplied?: boolean; + timeMode: TimeMode; + customTimeInputMode: CustomTimeInputMode; + customEpochMs?: number; + epochValue?: string; + calendarValue?: string; + invocation?: InvocationSnapshot; +} + +export interface CronWorkerState { + authoritative: boolean; + crons?: string[]; + rows: CronRow[]; + stale: boolean; +} diff --git a/packages/local-explorer-ui/src/routeTree.gen.ts b/packages/local-explorer-ui/src/routeTree.gen.ts index 688eb74a692..80aac26f6e3 100644 --- a/packages/local-explorer-ui/src/routeTree.gen.ts +++ b/packages/local-explorer-ui/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as EmailRouteImport } from './routes/email' import { Route as IndexRouteImport } from './routes/index' import { Route as ObservabilityIndexRouteImport } from './routes/observability/index' +import { Route as CronTriggersIndexRouteImport } from './routes/cron-triggers/index' import { Route as WorkflowsWorkflowNameRouteImport } from './routes/workflows/$workflowName' import { Route as R2BucketNameRouteImport } from './routes/r2/$bucketName' import { Route as ObservabilityEventsRouteImport } from './routes/observability/events' @@ -44,6 +45,11 @@ const ObservabilityIndexRoute = ObservabilityIndexRouteImport.update({ path: '/observability/', getParentRoute: () => rootRouteImport, } as any) +const CronTriggersIndexRoute = CronTriggersIndexRouteImport.update({ + id: '/cron-triggers/', + path: '/cron-triggers/', + getParentRoute: () => rootRouteImport, +} as any) const WorkflowsWorkflowNameRoute = WorkflowsWorkflowNameRouteImport.update({ id: '/workflows/$workflowName', path: '/workflows/$workflowName', @@ -138,6 +144,7 @@ export interface FileRoutesByFullPath { '/observability/events': typeof ObservabilityEventsRoute '/r2/$bucketName': typeof R2BucketNameRouteWithChildren '/workflows/$workflowName': typeof WorkflowsWorkflowNameRouteWithChildren + '/cron-triggers/': typeof CronTriggersIndexRoute '/observability/': typeof ObservabilityIndexRoute '/do/$className/$objectId': typeof DoClassNameObjectIdRoute '/email/routing/$emailId': typeof EmailRoutingEmailIdRoute @@ -155,6 +162,7 @@ export interface FileRoutesByTo { '/email/sending': typeof EmailSendingRoute '/kv/$namespaceId': typeof KvNamespaceIdRoute '/observability/events': typeof ObservabilityEventsRoute + '/cron-triggers': typeof CronTriggersIndexRoute '/observability': typeof ObservabilityIndexRoute '/do/$className/$objectId': typeof DoClassNameObjectIdRoute '/email/routing/$emailId': typeof EmailRoutingEmailIdRoute @@ -177,6 +185,7 @@ export interface FileRoutesById { '/observability/events': typeof ObservabilityEventsRoute '/r2/$bucketName': typeof R2BucketNameRouteWithChildren '/workflows/$workflowName': typeof WorkflowsWorkflowNameRouteWithChildren + '/cron-triggers/': typeof CronTriggersIndexRoute '/observability/': typeof ObservabilityIndexRoute '/do/$className/$objectId': typeof DoClassNameObjectIdRoute '/email/routing/$emailId': typeof EmailRoutingEmailIdRoute @@ -200,6 +209,7 @@ export interface FileRouteTypes { | '/observability/events' | '/r2/$bucketName' | '/workflows/$workflowName' + | '/cron-triggers/' | '/observability/' | '/do/$className/$objectId' | '/email/routing/$emailId' @@ -217,6 +227,7 @@ export interface FileRouteTypes { | '/email/sending' | '/kv/$namespaceId' | '/observability/events' + | '/cron-triggers' | '/observability' | '/do/$className/$objectId' | '/email/routing/$emailId' @@ -238,6 +249,7 @@ export interface FileRouteTypes { | '/observability/events' | '/r2/$bucketName' | '/workflows/$workflowName' + | '/cron-triggers/' | '/observability/' | '/do/$className/$objectId' | '/email/routing/$emailId' @@ -258,6 +270,7 @@ export interface RootRouteChildren { ObservabilityEventsRoute: typeof ObservabilityEventsRoute R2BucketNameRoute: typeof R2BucketNameRouteWithChildren WorkflowsWorkflowNameRoute: typeof WorkflowsWorkflowNameRouteWithChildren + CronTriggersIndexRoute: typeof CronTriggersIndexRoute ObservabilityIndexRoute: typeof ObservabilityIndexRoute } @@ -284,6 +297,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ObservabilityIndexRouteImport parentRoute: typeof rootRouteImport } + '/cron-triggers/': { + id: '/cron-triggers/' + path: '/cron-triggers' + fullPath: '/cron-triggers/' + preLoaderRoute: typeof CronTriggersIndexRouteImport + parentRoute: typeof rootRouteImport + } '/workflows/$workflowName': { id: '/workflows/$workflowName' path: '/workflows/$workflowName' @@ -477,6 +497,7 @@ const rootRouteChildren: RootRouteChildren = { ObservabilityEventsRoute: ObservabilityEventsRoute, R2BucketNameRoute: R2BucketNameRouteWithChildren, WorkflowsWorkflowNameRoute: WorkflowsWorkflowNameRouteWithChildren, + CronTriggersIndexRoute: CronTriggersIndexRoute, ObservabilityIndexRoute: ObservabilityIndexRoute, } export const routeTree = rootRouteImport diff --git a/packages/local-explorer-ui/src/routes/__root.tsx b/packages/local-explorer-ui/src/routes/__root.tsx index 9c4900717cd..88709859d0c 100644 --- a/packages/local-explorer-ui/src/routes/__root.tsx +++ b/packages/local-explorer-ui/src/routes/__root.tsx @@ -29,10 +29,17 @@ import type { ThemeMode } from "../utils/theme-state"; export const Route = createRootRoute({ component: RootLayout, notFoundComponent: NotFound, - loader: async () => { - const workersResponse = await localExplorerListWorkers(); - const workers = workersResponse.data?.result ?? []; - return { workers }; + loader: async ({ location }) => { + try { + const workersResponse = await localExplorerListWorkers(); + const workers = workersResponse.data?.result ?? []; + return { bootstrapAuthoritative: true, workers }; + } catch (error) { + if (/\/cron-triggers\/?$/.test(location.pathname)) { + return { bootstrapAuthoritative: false, workers: [] }; + } + throw error; + } }, }); diff --git a/packages/local-explorer-ui/src/routes/cron-triggers/index.tsx b/packages/local-explorer-ui/src/routes/cron-triggers/index.tsx new file mode 100644 index 00000000000..66d33ce9f0b --- /dev/null +++ b/packages/local-explorer-ui/src/routes/cron-triggers/index.tsx @@ -0,0 +1,71 @@ +import { createFileRoute, getRouteApi } from "@tanstack/react-router"; +import { useEffect } from "react"; +import { CronTriggersProvider } from "../../components/cron-triggers/CronTriggersContext"; +import { CronTriggersPage } from "../../components/cron-triggers/CronTriggersPage"; +import { + filterVisibleWorkers, + getSelectedWorker, +} from "../../components/WorkerSelector"; +import type { JSX } from "react"; + +export const Route = createFileRoute("/cron-triggers/")({ + component: CronTriggersRoute, + validateSearch: (search: Record): { worker?: string } => ({ + worker: + typeof search.worker === "string" && search.worker !== "" + ? search.worker + : undefined, + }), +}); + +const rootRoute = getRouteApi("__root__"); + +function CronTriggersRoute(): JSX.Element { + const loaderData = rootRoute.useLoaderData(); + const navigate = Route.useNavigate(); + const search = Route.useSearch(); + const visibleWorkerCount = filterVisibleWorkers(loaderData.workers).length; + const selectedWorker = loaderData.bootstrapAuthoritative + ? getSelectedWorker( + loaderData.workers, + search.worker + ? new URLSearchParams({ worker: search.worker }).toString() + : "" + ) + : undefined; + const activeWorkerName = loaderData.bootstrapAuthoritative + ? selectedWorker?.name + : search.worker; + + useEffect(() => { + if (!loaderData.bootstrapAuthoritative || !selectedWorker) { + return; + } + const canonicalWorker = + visibleWorkerCount > 1 ? selectedWorker.name : undefined; + if (search.worker === canonicalWorker) { + return; + } + void navigate({ + replace: true, + search: (previous) => ({ ...previous, worker: canonicalWorker }), + }); + }, [ + loaderData.bootstrapAuthoritative, + navigate, + search.worker, + selectedWorker, + visibleWorkerCount, + ]); + + return ( + + + + ); +} diff --git a/packages/local-explorer-ui/src/utils/sidebar-state.ts b/packages/local-explorer-ui/src/utils/sidebar-state.ts index 31b1839f115..9f6e7457ea7 100644 --- a/packages/local-explorer-ui/src/utils/sidebar-state.ts +++ b/packages/local-explorer-ui/src/utils/sidebar-state.ts @@ -9,6 +9,7 @@ export const SIDEBAR_GROUP_IDS = [ "r2", "workflows", "email", + "cron-triggers", ] as const; export type SidebarGroupId = (typeof SIDEBAR_GROUP_IDS)[number]; @@ -23,6 +24,7 @@ export const DEFAULT_GROUP_STATE: SidebarGroupState = { r2: true, workflows: true, email: true, + "cron-triggers": true, }; /**