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..da2db9311e5 --- /dev/null +++ b/packages/local-explorer-ui/src/__e2e__/cron-triggers/cron-triggers.spec.ts @@ -0,0 +1,703 @@ +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."; + +interface MockWorkerMetadata { + bindings?: { + kv: Array<{ bindingName: string; id: string }>; + }; + 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("keeps ad-hoc triggers available without configured crons", async ({ + expect, + }) => { + await mockWorkers([]); + 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 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 trigger" }).count() + ).toBe(0); + expect( + await page.getByRole("button", { name: "Trigger", exact: true }).count() + ).toBe(0); + + await page.getByRole("link", { name: "Ad-Hoc Triggers" }).click(); + expect( + await page + .getByRole("heading", { name: "No Cron Triggers configured" }) + .count() + ).toBe(0); + await page.getByRole("button", { name: "Add trigger" }).click(); + await page.getByLabel("Cron expression").fill("15 4 * * *"); + await page.getByRole("button", { name: "Trigger", exact: true }).click(); + await expect.poll(() => bodies.length).toBe(1); + expect(bodies[0]?.cron).toBe("15 4 * * *"); + }); + + test("recovers Worker selection after bootstrap fails", async ({ + expect, + }) => { + let requestCount = 0; + const refreshHeaders: Array = []; + let releaseRefresh: (() => void) | undefined; + const delayedRefresh = new Promise((resolve) => { + releaseRefresh = resolve; + }); + await page.route(WORKERS_ROUTE, async (route) => { + requestCount += 1; + refreshHeaders.push( + route.request().headers()["x-miniflare-explorer-refresh"] + ); + if (requestCount === 2) { + await route.fulfill({ + body: JSON.stringify({ + errors: [], + messages: [], + result: [], + success: true, + }), + contentType: "application/json", + }); + return; + } + if (requestCount > 2) { + await delayedRefresh; + await route.fulfill({ + body: JSON.stringify({ + errors: [], + messages: [], + result: [ + { + isSelf: true, + name: "worker-1", + persistenceScope: "cron-project-1", + triggers: { crons: ["recovered-cron"] }, + }, + { + isSelf: false, + name: "worker-2", + persistenceScope: "cron-project-2", + triggers: { crons: ["peer-cron"] }, + }, + ], + success: true, + }), + contentType: "application/json", + }); + return; + } + 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/configured?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" + ); + + await expect.poll(() => requestCount).toBe(2); + await page.getByRole("button", { name: "Refresh Cron Triggers" }).click(); + releaseRefresh?.(); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-1"); + await expect + .poll(() => page.getByLabel("Cron expression").first().inputValue()) + .toBe("recovered-cron"); + expect(refreshHeaders).toContain("poll"); + + const workerSelector = page + .getByRole("combobox") + .filter({ hasText: "worker-1" }); + await workerSelector.click(); + await page.getByRole("option", { name: "worker-2" }).click(); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-2"); + await expect + .poll(() => page.getByLabel("Cron expression").first().inputValue()) + .toBe("peer-cron"); + }); + + test("canonicalizes Worker selection and retains it across metadata refreshes", async ({ + expect, + }) => { + const workerTwo: MockWorkerMetadata = { + name: "worker-2", + persistenceScope: "cron-project-2", + triggers: { crons: ["second-worker-cron"] }, + }; + const workers: MockWorkerMetadata[] = [ + { + isSelf: true, + name: "worker-1", + persistenceScope: "cron-project-1", + triggers: { crons: ["first-worker-cron"] }, + }, + workerTwo, + ]; + await mockWorkerMetadata(workers); + 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/configured", + 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/configured?worker=missing-worker", + viteUrl + ).toString() + ); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-1"); + expect(await page.getByLabel("Cron expression").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/configured?worker=worker-2", + viteUrl + ).toString() + ); + expect(await page.getByLabel("Cron expression").inputValue()).toBe( + "second-worker-cron" + ); + await page.getByRole("button", { name: "Trigger", exact: true }).click(); + await expect.poll(() => requestedWorkers).toEqual(["worker-2"]); + workerTwo.bindings = { + kv: [{ bindingName: "CACHE", id: "kv-namespace-id" }], + }; + await page.getByRole("button", { name: "Refresh Cron Triggers" }).click(); + await page.getByRole("link", { name: "CACHE" }).waitFor(); + expect(new URL(page.url()).searchParams.get("worker")).toBe("worker-2"); + + requestedWorkers.length = 0; + workers.splice(1, 1); + await page.getByRole("button", { name: "Refresh Cron Triggers" }).click(); + await page.getByText(/Existing rows may be stale/).waitFor(); + expect( + await page.getByRole("combobox").filter({ hasText: "worker-2" }).count() + ).toBe(1); + expect(await page.getByLabel("Cron expression").inputValue()).toBe( + "second-worker-cron" + ); + await page.getByRole("button", { name: "Trigger", exact: true }).click(); + await expect.poll(() => requestedWorkers).toEqual(["worker-2"]); + + workers.push({ + name: "worker-3", + persistenceScope: "cron-project-3", + triggers: { crons: ["third-worker-cron"] }, + }); + await page.getByRole("button", { name: "Refresh Cron Triggers" }).click(); + const workerSelector = page + .getByRole("combobox") + .filter({ hasText: "worker-2" }); + await workerSelector.click(); + await page.getByRole("option", { name: "worker-3" }).click(); + await expect + .poll(() => new URL(page.url()).searchParams.get("worker")) + .toBe("worker-3"); + await page.getByRole("button", { name: "Refresh Cron Triggers" }).click(); + const refreshedWorkerSelector = page + .getByRole("combobox") + .filter({ hasText: "worker-3" }); + await refreshedWorkerSelector.click(); + await expect + .poll(() => page.getByRole("option", { name: "worker-2" }).count()) + .toBe(0); + }); + + test("clears configured results when persistence scope changes", async ({ + expect, + }) => { + const worker: MockWorkerMetadata = { + isSelf: true, + name: "cron-worker", + persistenceScope: "project-a", + triggers: { crons: ["0 17 * * sun"] }, + }; + const workers = [worker]; + await mockWorkerMetadata(workers); + await page.route(SCHEDULED_ROUTE, async (route) => { + await route.fulfill({ + body: JSON.stringify({ + errors: [], + messages: [], + result: { noRetry: false, outcome: "project-a-result" }, + success: true, + }), + contentType: "application/json", + }); + }); + await openCronTriggers(); + await page.getByRole("button", { name: "Trigger", exact: true }).click(); + await page.getByText("project-a-result").waitFor(); + + worker.persistenceScope = "project-b"; + await page.getByRole("button", { name: "Refresh Cron Triggers" }).click(); + await expect.poll(() => page.getByText("project-a-result").count()).toBe(0); + }); + + test("uses full-width routed views and removes cron duplication", async ({ + expect, + }) => { + await page.setViewportSize({ height: 900, width: 1920 }); + await mockWorkers(["0 17 * * sun"]); + await openCronTriggers(); + + const configuredLink = page.getByRole("link", { + name: "Configured Crons", + }); + const adHocLink = page.getByRole("link", { name: "Ad-Hoc Triggers" }); + await configuredLink.waitFor(); + await expect + .poll(() => new URL(page.url()).pathname) + .toMatch(/\/cron-triggers\/configured$/); + const configuredPanel = page.getByRole("region", { + name: "Configured Crons", + }); + const panelBox = await configuredPanel.boundingBox(); + const rowListBox = await configuredPanel + .locator("[data-cron-row-list]") + .boundingBox(); + expect(Math.abs((panelBox?.width ?? 0) - (rowListBox?.width ?? 0))).toBe(0); + expect( + await page.getByRole("button", { name: "Duplicate cron" }).count() + ).toBe(0); + + await adHocLink.click(); + await expect + .poll(() => new URL(page.url()).pathname) + .toMatch(/\/cron-triggers\/ad-hoc$/); + const adHocPanel = page.getByRole("region", { name: "Ad-Hoc Triggers" }); + await adHocPanel.getByRole("button", { name: "Add trigger" }).click(); + const row = adHocPanel.locator("[data-row-id]"); + await row.getByLabel("Cron expression").fill("15 4 * * *"); + const copyButton = row.getByRole("button", { + name: "Copy expression", + }); + const [inputBox, triggerBox, copyBox, removeBox] = await Promise.all([ + row.getByLabel("Cron expression").boundingBox(), + row.getByRole("button", { name: "Trigger", exact: true }).boundingBox(), + copyButton.boundingBox(), + row.getByRole("button", { name: "Remove row" }).boundingBox(), + ]); + expect(Math.abs((inputBox?.y ?? 0) - (triggerBox?.y ?? 0))).toBeLessThan(2); + expect(triggerBox?.height).toBe(inputBox?.height); + expect(copyBox?.height).toBe(inputBox?.height); + expect(removeBox?.height).toBe(inputBox?.height); + expect(copyBox?.x ?? 0).toBeGreaterThan( + (triggerBox?.x ?? 0) + (triggerBox?.width ?? 0) + ); + expect(removeBox?.x ?? 0).toBeGreaterThan( + (copyBox?.x ?? 0) + (copyBox?.width ?? 0) + ); + await row.evaluate((element) => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: (text: string) => { + element.setAttribute("data-copied-cron", text); + return Promise.resolve(); + }, + }, + }); + }); + await copyButton.click(); + expect(await row.getAttribute("data-copied-cron")).toBe("15 4 * * *"); + await row.getByRole("button", { name: "Build expression" }).click(); + expect(await row.getByLabel("Cron expression").inputValue()).toBe( + "15 4 * * *" + ); + expect( + await row + .getByRole("button", { name: "Use generated expression" }) + .count() + ).toBe(0); + expect( + await row.getByText("Complete the builder before triggering.").count() + ).toBe(0); + const hourInput = row.getByRole("textbox", { name: "Hour" }); + await hourInput.fill("2"); + expect(await row.getByLabel("Cron expression").inputValue()).toBe( + "0 2 * * *" + ); + await hourInput.fill("25"); + const hourLabel = row.getByText("Hour", { exact: true }); + const hourError = row.getByText("must be between 0 and 23.", { + exact: true, + }); + const [hourLabelBox, hourErrorBox, hourLabelColor, hourErrorColor] = + await Promise.all([ + hourLabel.boundingBox(), + hourError.boundingBox(), + hourLabel.evaluate((element) => getComputedStyle(element).color), + hourError.evaluate((element) => getComputedStyle(element).color), + ]); + expect( + Math.abs( + (hourLabelBox?.y ?? 0) + + (hourLabelBox?.height ?? 0) / 2 - + ((hourErrorBox?.y ?? 0) + (hourErrorBox?.height ?? 0) / 2) + ) + ).toBeLessThan(2); + expect(hourLabelColor).not.toBe(hourErrorColor); + expect(await hourInput.getAttribute("aria-invalid")).toBe("true"); + await row + .getByLabel("Schedule") + .selectOption({ label: "Selected weekdays" }); + const weekdays = row.getByRole("group", { name: "Weekdays" }); + await weekdays.getByRole("checkbox", { name: "mon" }).uncheck(); + expect(await weekdays.getAttribute("aria-invalid")).toBe("true"); + const weekdayError = weekdays.getByText("Select at least one weekday."); + const [saturdayBox, weekdayErrorBox] = await Promise.all([ + weekdays.getByText("sat", { exact: true }).boundingBox(), + weekdayError.boundingBox(), + ]); + expect( + Math.abs( + (saturdayBox?.y ?? 0) + + (saturdayBox?.height ?? 0) / 2 - + ((weekdayErrorBox?.y ?? 0) + (weekdayErrorBox?.height ?? 0) / 2) + ) + ).toBeLessThan(2); + await row.getByRole("button", { name: "Cron builder help" }).hover(); + await page.getByText(/Step values start at the field minimum/).waitFor(); + expect( + await row.evaluate( + (element) => element.scrollWidth <= element.clientWidth + ) + ).toBe(true); + }); + + test("saves and uses UTC and epoch presets in both views", 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.getByText("Selected epoch: now").waitFor(); + const scheduledTimeSelect = page.getByRole("combobox", { + name: "Scheduled time", + }); + const addPreset = page.getByRole("button", { name: "Add preset" }); + const removePreset = page.getByRole("button", { name: "Remove preset" }); + const [selectBox, addBox, removeBox] = await Promise.all([ + scheduledTimeSelect.boundingBox(), + addPreset.boundingBox(), + removePreset.boundingBox(), + ]); + expect(addBox?.height).toBe(selectBox?.height); + expect(removeBox?.height).toBe(selectBox?.height); + expect(Math.abs((addBox?.y ?? 0) - (selectBox?.y ?? 0))).toBeLessThan(2); + expect(Math.abs((removeBox?.y ?? 0) - (selectBox?.y ?? 0))).toBeLessThan(2); + expect(await removePreset.textContent()).toBe(""); + expect(await removePreset.getAttribute("aria-disabled")).toBe("true"); + + await addPreset.click(); + const calendarInput = page.getByLabel("Date and time (UTC)"); + const cancelPreset = page.getByRole("button", { name: "Cancel" }); + const savePreset = page.getByRole("button", { name: "Save preset" }); + const [calendarBox, cancelBox, saveBox] = await Promise.all([ + calendarInput.boundingBox(), + cancelPreset.boundingBox(), + savePreset.boundingBox(), + ]); + expect(cancelBox?.height).toBe(calendarBox?.height); + expect(saveBox?.height).toBe(calendarBox?.height); + expect(Math.abs((cancelBox?.y ?? 0) - (calendarBox?.y ?? 0))).toBeLessThan( + 2 + ); + expect(Math.abs((saveBox?.y ?? 0) - (calendarBox?.y ?? 0))).toBeLessThan(2); + await page + .getByLabel("Date and time (UTC)") + .fill("2026-01-10T12:30:45.123"); + await savePreset.click(); + expect(await removePreset.getAttribute("aria-disabled")).toBe("false"); + 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("link", { name: "Ad-Hoc Triggers" }).click(); + await page.getByRole("combobox", { name: "Scheduled time" }).click(); + await page + .getByRole("option", { name: "2026-01-10T12:30:45.123Z" }) + .click(); + await page.getByRole("button", { name: "Add trigger" }).click(); + await page.getByLabel("Cron expression").fill("15 4 * * *"); + await page.getByRole("button", { name: "Trigger", exact: true }).click(); + await expect.poll(() => bodies.length).toBe(2); + expect(bodies[1]).toEqual({ + cron: "15 4 * * *", + scheduled_time: Date.parse("2026-01-10T12:30:45.123Z"), + }); + await page.getByRole("button", { name: "Build expression" }).click(); + await page.getByRole("textbox", { name: "Hour" }).fill(""); + await page.getByRole("button", { name: "Trigger", exact: true }).click(); + await expect.poll(() => bodies.length).toBe(3); + expect(bodies[2]).toEqual({ + cron: "15 4 * * *", + scheduled_time: Date.parse("2026-01-10T12:30:45.123Z"), + }); + + await page.getByRole("button", { name: "Add preset" }).click(); + await page.getByRole("button", { name: "Epoch milliseconds" }).click(); + await page.getByLabel("Epoch milliseconds").fill(""); + await expect + .poll(() => + page.getByRole("button", { name: "Save preset" }).isDisabled() + ) + .toBe(true); + await page.getByLabel("Epoch milliseconds").fill("-1"); + await expect.poll(() => savePreset.isEnabled()).toBe(true); + await savePreset.click(); + await page.getByRole("button", { name: "Trigger", exact: true }).click(); + await expect.poll(() => bodies.length).toBe(4); + expect(bodies[3]?.scheduled_time).toBe(-1); + + await removePreset.click(); + expect(await removePreset.getAttribute("aria-disabled")).toBe("true"); + await page.getByText("Selected epoch: now").waitFor(); + expect( + await page.getByRole("combobox", { name: "Scheduled time" }).textContent() + ).toContain("Now"); + await page.getByRole("combobox", { name: "Scheduled time" }).click(); + expect( + await page + .getByRole("option", { name: "1969-12-31T23:59:59.999Z" }) + .count() + ).toBe(0); + await page.keyboard.press("Escape"); + }); + + test("restores and retains ad-hoc drafts and time presets", async ({ + expect, + }) => { + const configuredCrons = ["0 17 * * sun"]; + await mockWorkers(configuredCrons); + 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: "Add preset" }).click(); + await page.getByRole("button", { name: "Epoch milliseconds" }).click(); + await page.getByLabel("Epoch milliseconds").fill("123456789"); + await page.getByRole("button", { name: "Save preset" }).click(); + await page.getByRole("link", { name: "Ad-Hoc Triggers" }).click(); + await page.getByRole("combobox", { name: "Scheduled time" }).click(); + await page + .getByRole("option", { name: "1970-01-02T10:17:36.789Z" }) + .click(); + await page.getByRole("button", { name: "Add trigger" }).click(); + await page.getByLabel("Cron expression").fill("15 4 * * *"); + await page.getByRole("button", { name: "Build expression" }).click(); + await page.getByRole("textbox", { name: "Hour" }).fill(""); + expect( + await page + .getByRole("button", { name: "Trigger", exact: true }) + .getAttribute("aria-disabled") + ).toBe("false"); + await expect + .poll(() => + page.evaluate( + (prefix) => + Object.keys(localStorage).filter((key) => key.startsWith(prefix)) + .length, + STORAGE_PREFIX + ) + ) + .toBe(2); + + await page.getByRole("link", { name: "Traces" }).click(); + await page.getByRole("link", { name: "Ad-Hoc Triggers" }).click(); + await page.getByLabel("Cron expression").waitFor(); + expect(await page.getByLabel("Cron expression").inputValue()).toBe( + "15 4 * * *" + ); + expect(await page.getByRole("textbox", { name: "Hour" }).inputValue()).toBe( + "" + ); + await page.getByRole("combobox", { name: "Scheduled time" }).click(); + await expect + .poll(() => + page.getByRole("option", { name: "1970-01-02T10:17:36.789Z" }).count() + ) + .toBe(1); + await page.keyboard.press("Escape"); + + await page.reload(); + await page.getByLabel("Cron expression").waitFor(); + expect(await page.getByLabel("Cron expression").inputValue()).toBe( + "15 4 * * *" + ); + await page.getByRole("combobox", { name: "Scheduled time" }).click(); + await expect + .poll(() => + page.getByRole("option", { name: "1970-01-02T10:17:36.789Z" }).count() + ) + .toBe(1); + await page.keyboard.press("Escape"); + expect(await page.getByText("Invocation is running…").count()).toBe(0); + + configuredCrons.length = 0; + await page.getByRole("button", { name: "Refresh Cron Triggers" }).click(); + expect( + await page + .getByRole("heading", { name: "No Cron Triggers configured" }) + .count() + ).toBe(0); + expect(await page.getByLabel("Cron expression").inputValue()).toBe( + "15 4 * * *" + ); + expect( + await page + .getByRole("button", { name: "Trigger", exact: true }) + .getAttribute("aria-disabled") + ).toBe("false"); + expect( + await page.getByRole("button", { name: "Add trigger" }).count() + ).toBe(1); + }); +}); 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..25a66f503d1 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/cron-triggers/cron-builder.test.ts @@ -0,0 +1,117 @@ +import { describe, it } from "vitest"; +import { + changeCronBuilderKind, + cronBuilderKinds, + generateCronExpression, + isCronBuilderDraft, +} from "../../components/cron-triggers/cron-builder"; + +describe("isCronBuilderDraft", () => { + it("accepts every builder shape", ({ expect }) => { + for (const kind of cronBuilderKinds) { + expect(isCronBuilderDraft(changeCronBuilderKind(kind.value))).toBe(true); + } + }); + + it("rejects unknown, incomplete, and malformed builder state", ({ + expect, + }) => { + expect(isCronBuilderDraft({ kind: "unknown" })).toBe(false); + expect(isCronBuilderDraft({ kind: "daily", hour: "0" })).toBe(false); + expect( + isCronBuilderDraft({ + kind: "daily", + hour: "0", + minute: "0", + unknown: true, + }) + ).toBe(false); + expect( + isCronBuilderDraft({ + kind: "weekdays", + weekdays: ["mon", "mon"], + hour: "0", + minute: "0", + }) + ).toBe(false); + expect( + isCronBuilderDraft({ + kind: "last-named-weekday", + weekday: "not-a-weekday", + hour: "0", + minute: "0", + }) + ).toBe(false); + }); +}); + +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..625d7e2edb7 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/cron-triggers/persistence.test.ts @@ -0,0 +1,230 @@ +import { describe, it } from "vitest"; +import { + CRON_CUSTOM_ROWS_STORAGE_PREFIX, + CRON_TIME_PRESETS_STORAGE_PREFIX, + cronCustomRowsStorageKey, + cronTimePresetsStorageKey, + readPersistedCronTimePresets, + readPersistedCustomCronRows, + writePersistedCronTimePresets, + writePersistedCustomCronRows, +} from "../../components/cron-triggers/persistence"; +import { createCronRow } from "../../components/cron-triggers/row-state"; + +class MemoryStorage implements Storage { + #values = new Map(); + writesBlocked = false; + + 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 { + if (this.writesBlocked) { + throw new Error("quota exceeded"); + } + 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 editable custom drafts and rebuilds transient state", ({ + expect, + }) => { + const storage = new MemoryStorage(); + 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", [custom]); + const raw = storage.getItem("key") ?? ""; + expect(raw).not.toContain(custom.id); + expect(raw).not.toContain("calendarValue"); + expect(raw).not.toContain("customEpochMs"); + expect(raw).not.toContain("request-id"); + expect(raw).not.toContain("source"); + expect(raw).not.toContain("timeMode"); + + const restored = readPersistedCustomCronRows(storage, "key"); + expect(restored).toHaveLength(1); + expect(restored[0]).toMatchObject({ + cron: "0 12 * * *", + source: "custom", + timeMode: "now", + }); + expect(restored[0]?.calendarValue).toBeUndefined(); + expect(restored[0]?.customEpochMs).toBeUndefined(); + expect(restored[0]?.id).not.toBe(custom.id); + expect(restored[0]?.invocation).toBeUndefined(); + }); + + it("accepts legacy per-row times without restoring them", ({ expect }) => { + const storage = new MemoryStorage(); + storage.setItem( + "key", + JSON.stringify([ + { + calendarValue: "2026-09-10T12:34:56.789", + cron: "legacy", + cronBuilder: { kind: "daily", hour: "", minute: "7" }, + cronInputMode: "builder", + customTimeInputMode: "epoch", + epochValue: "123456789", + timeMode: "custom", + }, + ]) + ); + + const restored = readPersistedCustomCronRows(storage, "key"); + expect(restored[0]).toMatchObject({ + cronBuilder: { kind: "daily", hour: "", minute: "7" }, + cronInputMode: "builder", + timeMode: "now", + }); + expect(restored[0]?.calendarValue).toBeUndefined(); + expect(restored[0]?.customEpochMs).toBeUndefined(); + expect(restored[0]?.epochValue).toBeUndefined(); + }); + + 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("removes empty state and tolerates storage failures", ({ expect }) => { + const storage = new MemoryStorage(); + storage.setItem("key", "old"); + expect(writePersistedCustomCronRows(storage, "key", [])).toBe(true); + expect(storage.getItem("key")).toBeNull(); + + const quotaStorage = new MemoryStorage(); + expect( + writePersistedCustomCronRows(quotaStorage, "key", [ + createCronRow("persisted"), + ]) + ).toBe(true); + const previous = quotaStorage.getItem("key"); + quotaStorage.writesBlocked = true; + expect( + writePersistedCustomCronRows(quotaStorage, "key", [ + createCronRow("not persisted"), + ]) + ).toBe(false); + expect(quotaStorage.getItem("key")).toBe(previous); + quotaStorage.writesBlocked = false; + expect( + writePersistedCustomCronRows(quotaStorage, "key", [ + createCronRow("retried"), + ]) + ).toBe(true); + expect(readPersistedCustomCronRows(quotaStorage, "key")[0]?.cron).toBe( + "retried" + ); + + 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")]) + ).toBe(false); + }); +}); + +describe("Cron Trigger time-preset persistence", () => { + it("uses a separate versioned project and encoded Worker key", ({ + expect, + }) => { + expect(cronTimePresetsStorageKey(undefined, "worker")).toBeUndefined(); + expect(cronTimePresetsStorageKey("project-scope", "a Worker/name")).toBe( + `${CRON_TIME_PRESETS_STORAGE_PREFIX}.project-scope.a%20Worker%2Fname` + ); + }); + + it("round trips valid scheduled-time presets", ({ expect }) => { + const storage = new MemoryStorage(); + writePersistedCronTimePresets(storage, "key", [-1, 0, 123456789]); + expect(readPersistedCronTimePresets(storage, "key")).toEqual([ + -1, 0, 123456789, + ]); + }); + + it("removes malformed presets and preserves the last successful write", ({ + expect, + }) => { + const malformed = new MemoryStorage(); + malformed.setItem("key", JSON.stringify([1, 1])); + expect(readPersistedCronTimePresets(malformed, "key")).toEqual([]); + expect(malformed.getItem("key")).toBeNull(); + + const quotaStorage = new MemoryStorage(); + expect(writePersistedCronTimePresets(quotaStorage, "key", [1])).toBe(true); + const previous = quotaStorage.getItem("key"); + quotaStorage.writesBlocked = true; + expect(writePersistedCronTimePresets(quotaStorage, "key", [2])).toBe(false); + expect(quotaStorage.getItem("key")).toBe(previous); + quotaStorage.writesBlocked = false; + expect(writePersistedCronTimePresets(quotaStorage, "key", [2])).toBe(true); + expect(readPersistedCronTimePresets(quotaStorage, "key")).toEqual([2]); + }); +}); 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..4f2c45c9ad7 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/cron-triggers/provider-state.test.ts @@ -0,0 +1,118 @@ +import { describe, it } from "vitest"; +import { + createCronStateFromSeed, + didPersistenceScopeChange, + reconcilePersistenceKeysForRefresh, + RefreshGenerationTracker, + selectCronFallbackWorker, +} from "../../components/cron-triggers/CronTriggersContext"; + +describe("Cron Triggers provider state", () => { + it("treats absent and explicit empty trigger metadata as authoritative empty arrays", ({ + expect, + }) => { + const state = createCronStateFromSeed( + [ + { isSelf: false, name: "absent" }, + { isSelf: false, name: "empty", triggers: { crons: [] } }, + ], + true + ); + expect(state.absent?.authoritative).toBe(true); + expect(state.absent?.crons).toEqual([]); + expect(state.absent?.configuredRows).toEqual([]); + expect(state.absent?.customRows).toEqual([]); + expect(state.empty?.crons).toEqual([]); + }); + + it("does not claim authority when the root request failed", ({ expect }) => { + expect( + createCronStateFromSeed( + [ + { + isSelf: true, + name: "worker", + triggers: { crons: ["0 0 * * *"] }, + }, + ], + false + ) + ).toEqual({}); + }); + + 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", + }, + [ + { + isSelf: false, + 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" }, + [{ isSelf: false, name: "returned" }] + ) + ).toEqual({}); + }); + + it("detects only explicit persistence scope changes", ({ expect }) => { + expect(didPersistenceScopeChange("scope-a", "scope-b")).toBe(true); + expect(didPersistenceScopeChange("scope-a", "scope-a")).toBe(false); + expect(didPersistenceScopeChange("scope-a", undefined)).toBe(false); + expect(didPersistenceScopeChange(undefined, "scope-a")).toBe(false); + expect(didPersistenceScopeChange(undefined, undefined)).toBe(false); + }); + + it("recovers a self-first fallback without exposing internal workers", ({ + expect, + }) => { + expect( + selectCronFallbackWorker([ + { isSelf: true, name: "__router-worker__" }, + { isSelf: false, 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("worker") ?? 0; + const newer = tracker.start("worker") ?? 0; + expect(tracker.isLatest(newer)).toBe(true); + expect(tracker.isLatest(older)).toBe(false); + expect(tracker.finish("worker", older)).toBe(false); + expect(tracker.finish("worker", newer)).toBe(true); + }); + + it("skips overlapping automatic refreshes for the same Worker", ({ + expect, + }) => { + const tracker = new RefreshGenerationTracker(); + const first = tracker.start("worker", true) ?? 0; + expect(tracker.start("worker", true)).toBeUndefined(); + expect(tracker.start("other-worker", true)).toBeDefined(); + expect(tracker.finish("worker", first)).toBe(true); + expect(tracker.start("worker", true)).toBeDefined(); + }); +}); 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..63b7dab31b8 --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/cron-triggers/row-state.test.ts @@ -0,0 +1,54 @@ +import { describe, it } from "vitest"; +import { + createCronRow, + 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("retains rows that were previously removed from configuration", ({ + expect, + }) => { + const removed = createCronRow("0 0 * * *", "no-longer-configured"); + removed.invocation = { + cron: removed.cron, + requestId: "request", + scheduledTime: 10, + status: "error", + error: "failed", + }; + const reconciled = reconcileConfiguredRows([removed], ["configured"]); + expect(reconciled[1]).toBe(removed); + }); +}); 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..6380f29be4e --- /dev/null +++ b/packages/local-explorer-ui/src/__tests__/cron-triggers/scheduled-time.test.ts @@ -0,0 +1,107 @@ +import { describe, it } from "vitest"; +import { createCronRow } from "../../components/cron-triggers/row-state"; +import { + enterCustomTimeMode, + formatUtcCalendarValue, + MAX_DATE_EPOCH_MS, + MIN_DATE_EPOCH_MS, + 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("enforces scheduled-time boundaries for UTC calendar values", ({ + expect, + }) => { + for (const epochMs of [ + MIN_DATE_EPOCH_MS, + MIN_DATE_EPOCH_MS + 1, + MAX_DATE_EPOCH_MS - 1, + MAX_DATE_EPOCH_MS, + ]) { + expect( + resolveUtcCalendarTime(formatUtcCalendarValue(epochMs)) + ).toMatchObject({ kind: "exact", epochMs }); + } + for (const epochMs of [MIN_DATE_EPOCH_MS - 1, MAX_DATE_EPOCH_MS + 1]) { + expect(resolveUtcCalendarTime(formatUtcCalendarValue(epochMs))).toEqual({ + error: "Date and time are outside the supported scheduled-time range.", + kind: "invalid", + }); + } + }); + + it("rejects calendar years and UTC dates outside supported values", ({ + expect, + }) => { + for (const value of [ + "0001-01-01T00:00:00.000", + "9999-12-31T23:59:59.999", + "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..e6b7ca01714 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,34 @@ export function AppSidebar({ ], title: "Email", }, + { + emptyLabel: "", + groupId: "cron-triggers" as const, + icon: ClockCountdownIcon, + items: [ + { + id: "configured-crons", + isActive: currentPath === "/cron-triggers/configured", + label: "Configured Crons", + link: { + params: {}, + search: workerSearch, + to: "/cron-triggers/configured", + }, + }, + { + id: "ad-hoc-triggers", + isActive: currentPath === "/cron-triggers/ad-hoc", + label: "Ad-Hoc Triggers", + link: { + params: {}, + search: workerSearch, + to: "/cron-triggers/ad-hoc", + }, + }, + ], + 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..a4e291589b7 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/CronRowCard.tsx @@ -0,0 +1,501 @@ +import { Button, Tooltip } from "@cloudflare/kumo"; +import { CopyIcon, InfoIcon, TrashIcon } from "@phosphor-icons/react"; +import { useEffect, useRef } from "react"; +import { + changeCronBuilderKind, + cronBuilderKinds, + cronWeekdays, + generateCronExpression, +} from "./cron-builder"; +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; + onFocusHandled: () => void; + onRemove: () => void; + onUpdate: (update: (row: CronRow) => CronRow) => void; + row: CronRow; + trigger: () => void; + triggerEnabled: boolean; +} + +export function CronRowCard({ + focusRequested, + onFocusHandled, + onRemove, + onUpdate, + row, + trigger, + triggerEnabled, +}: CronRowCardProps): JSX.Element { + const inputRef = useRef(null); + const actionRef = useRef(null); + const pending = row.invocation?.status === "pending"; + const cronValid = row.cron.trim() !== ""; + const canTrigger = triggerEnabled && !pending && cronValid; + + 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(); + } + + 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", + })) + } + ref={inputRef} + readOnly={row.source === "configured"} + value={row.cron} + /> + {!cronValid && row.cronInputMode !== "builder" ? ( + + Enter a non-empty cron expression. + + ) : null} +
+
+ + +
+
+ +
+ {row.source !== "configured" ? ( +
+ + onUpdate((current) => ({ + ...current, + 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: generated.expression ?? current.cron, + cronBuilder: draft, + }; + }) + } + rowId={row.id} + value={row.cronBuilder} + /> + ) : null} +
+ ) : null} + +
+
+ ); +} + +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, + rowId, + value, +}: { + disabled: boolean; + onUpdate: (draft: CronBuilderDraft) => 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]; + const errorSeparator = error?.indexOf(" must ") ?? -1; + const errorMessage = + error && errorSeparator >= 0 + ? `must ${error.slice(errorSeparator + " must ".length)}` + : error; + const inputId = `${rowId}-${field}-input`; + return ( +
+
+ + {errorMessage ? ( + + {errorMessage} + + ) : null} +
+ onChange(field, event.target.value)} + value={value} + /> +
+ ); +} + +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..6090b964364 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/CronTriggersContext.tsx @@ -0,0 +1,753 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from "react"; +import { localExplorerListWorkers } from "../../api"; +import { LOCAL_EXPLORER_API_PATH } from "../../constants"; +import { filterVisibleWorkers } from "../WorkerSelector"; +import { + cronCustomRowsStorageKey, + cronTimePresetsStorageKey, + readPersistedCronTimePresets, + readPersistedCustomCronRows, + writePersistedCronTimePresets, + writePersistedCustomCronRows, +} from "./persistence"; +import { createCronRow, reconcileConfiguredRows } from "./row-state"; +import { MAX_DATE_EPOCH_MS, MIN_DATE_EPOCH_MS } from "./scheduled-time"; +import type { LocalExplorerWorker } from "../../api"; +import type { + CronRow, + CronWorkerState, + CustomCronRow, + FetcherScheduledResult, +} from "./types"; +import type { PropsWithChildren } from "react"; + +const REFRESH_HEADER = "X-Miniflare-Explorer-Refresh"; +const POLL_INTERVAL_MS = 5_000; + +interface ScheduledEnvelope { + result?: unknown; + errors?: Array<{ message?: string }>; +} + +export interface CronTriggersContextValue { + activeWorkerName?: string; + addCustom(workerName: string): string; + addTimePreset(workerName: string, epochMs: number): void; + 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; + removeTimePreset(workerName: string, epochMs: number): void; + updateRow( + workerName: string, + rowId: string, + update: (row: CronRow) => CronRow + ): void; + visibleWorkerNames: string[]; +} + +const CronTriggersContext = createContext( + null +); + +function visibleCronWorkers( + metadata: LocalExplorerWorker[] +): LocalExplorerWorker[] { + return filterVisibleWorkers(metadata); +} + +export function selectCronFallbackWorker( + metadata: LocalExplorerWorker[] +): string { + const visible = visibleCronWorkers(metadata); + return ( + visible.find((worker) => worker.isSelf)?.name ?? visible[0]?.name ?? "" + ); +} + +export function createCronStateFromSeed( + seedWorkers: LocalExplorerWorker[], + authoritative: boolean +): Record { + if (!authoritative) { + return {}; + } + return Object.fromEntries( + seedWorkers.map((worker) => { + const crons = worker.triggers?.crons ?? []; + return [ + worker.name, + { + authoritative: true, + configuredRows: reconcileConfiguredRows([], crons), + crons, + customRows: [], + stale: false, + timePresets: [], + } satisfies CronWorkerState, + ]; + }) + ); +} + +function emptyState(): CronWorkerState { + return { + authoritative: false, + configuredRows: [], + customRows: [], + stale: true, + timePresets: [], + }; +} + +function localStorageIfAvailable(): Storage | undefined { + try { + return window.localStorage; + } catch { + return undefined; + } +} + +function persistenceKeysForMetadata( + metadata: LocalExplorerWorker[], + keyForWorker: ( + persistenceScope: string | undefined, + workerName: string + ) => string | undefined = cronCustomRowsStorageKey +): Record { + return Object.fromEntries( + metadata.flatMap((worker) => { + const key = keyForWorker(worker.persistenceScope, worker.name); + return key ? [[worker.name, key]] : []; + }) + ); +} + +export function reconcilePersistenceKeysForRefresh( + previousKeys: Record, + metadata: LocalExplorerWorker[], + keyForWorker: ( + persistenceScope: string | undefined, + workerName: string + ) => string | undefined = cronCustomRowsStorageKey +): Record { + const nextKeys = { ...previousKeys }; + const returnedKeys = persistenceKeysForMetadata(metadata, keyForWorker); + 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 didPersistenceScopeChange( + previousKey: string | undefined, + nextKey: string | undefined +): boolean { + return ( + previousKey !== undefined && + nextKey !== undefined && + previousKey !== nextKey + ); +} + +function hydrateInitialPersistence( + workers: Record, + customRowKeys: Record, + timePresetKeys: Record, + storage: Storage | undefined +): Record { + if (!storage) { + return workers; + } + return Object.fromEntries( + Object.entries(workers).map(([workerName, entry]) => { + const customRowKey = customRowKeys[workerName]; + const timePresetKey = timePresetKeys[workerName]; + return [ + workerName, + { + ...entry, + customRows: customRowKey + ? readPersistedCustomCronRows(storage, customRowKey) + : entry.customRows, + timePresets: timePresetKey + ? readPersistedCronTimePresets(storage, timePresetKey) + : [], + }, + ]; + }) + ); +} + +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({ + activeWorkerName, + bootstrapAuthoritative, + children, + onWorkerMetadata, + seedWorkers, +}: PropsWithChildren<{ + activeWorkerName?: string; + bootstrapAuthoritative: boolean; + onWorkerMetadata: (metadata: LocalExplorerWorker[]) => void; + seedWorkers: LocalExplorerWorker[]; +}>) { + const storage = useRef(localStorageIfAvailable()); + const [initialPersistence] = useState(() => { + const metadata = bootstrapAuthoritative ? seedWorkers : []; + const keys = persistenceKeysForMetadata(metadata); + const timePresetKeys = persistenceKeysForMetadata( + metadata, + cronTimePresetsStorageKey + ); + return { + keys, + timePresetKeys, + workers: hydrateInitialPersistence( + createCronStateFromSeed(seedWorkers, bootstrapAuthoritative), + keys, + timePresetKeys, + storage.current + ), + }; + }); + const [workers, setWorkers] = useState>( + initialPersistence.workers + ); + const [persistenceKeys, setPersistenceKeys] = useState( + initialPersistence.keys + ); + const [timePresetPersistenceKeys, setTimePresetPersistenceKeys] = useState( + initialPersistence.timePresetKeys + ); + const persistenceKeysRef = useRef(initialPersistence.keys); + const timePresetPersistenceKeysRef = useRef( + initialPersistence.timePresetKeys + ); + // Unlike the active persistence map, this retains a Worker's last known scope + // while a refresh temporarily reports no scope, so a later change is detected. + const lastKnownScopedPersistenceKeys = useRef(initialPersistence.keys); + const hydratedPersistenceKeys = useRef( + new Set(Object.values(initialPersistence.keys)) + ); + const lastPersistedCustomRows = useRef(new Map()); + const hydratedTimePresetPersistenceKeys = useRef( + new Set(Object.values(initialPersistence.timePresetKeys)) + ); + const lastPersistedTimePresets = useRef(new Map()); + const [fallbackWorkerName, setFallbackWorkerName] = useState(() => { + return selectCronFallbackWorker(seedWorkers); + }); + const [visibleWorkerNames, setVisibleWorkerNames] = useState(() => { + if (!bootstrapAuthoritative) { + return []; + } + return visibleCronWorkers(seedWorkers).map((worker) => worker.name); + }); + const [refreshingWorkers, setRefreshingWorkers] = useState>( + new Set() + ); + const generation = useRef(new RefreshGenerationTracker()); + const onWorkerMetadataRef = useRef(onWorkerMetadata); + const pendingRows = useRef(new Set()); + useEffect(() => { + onWorkerMetadataRef.current = onWorkerMetadata; + }, [onWorkerMetadata]); + + useEffect(() => { + if (!storage.current) { + return; + } + for (const [workerName, key] of Object.entries(persistenceKeys)) { + if (!hydratedPersistenceKeys.current.has(key)) { + continue; + } + const customRows = workers[workerName]?.customRows ?? []; + const previous = lastPersistedCustomRows.current.get(key); + if ( + previous && + previous.length === customRows.length && + previous.every((row, index) => row === customRows[index]) + ) { + continue; + } + if (writePersistedCustomCronRows(storage.current, key, customRows)) { + lastPersistedCustomRows.current.set(key, customRows); + } + } + }, [persistenceKeys, workers]); + + useEffect(() => { + if (!storage.current) { + return; + } + for (const [workerName, key] of Object.entries(timePresetPersistenceKeys)) { + if (!hydratedTimePresetPersistenceKeys.current.has(key)) { + continue; + } + const timePresets = workers[workerName]?.timePresets ?? []; + const previous = lastPersistedTimePresets.current.get(key); + if ( + previous && + previous.length === timePresets.length && + previous.every((preset, index) => preset === timePresets[index]) + ) { + continue; + } + if (writePersistedCronTimePresets(storage.current, key, timePresets)) { + lastPersistedTimePresets.current.set(key, timePresets); + } + } + }, [timePresetPersistenceKeys, workers]); + + const updateRow = useCallback( + (workerName: string, id: string, update: (row: CronRow) => CronRow) => { + setWorkers((current) => { + const entry = current[workerName] ?? emptyState(); + const configuredRows = entry.configuredRows.map((row) => { + if (row.id !== id) { + return row; + } + const updated = update(row); + return updated.source === "custom" ? row : updated; + }); + const customRows = entry.customRows.map((row) => { + if (row.id !== id) { + return row; + } + const updated = update(row); + return updated.source === "custom" ? updated : row; + }); + return { + ...current, + [workerName]: { + ...entry, + configuredRows, + customRows, + }, + }; + }); + }, + [] + ); + + const refresh = useCallback(async (workerName: string, automatic = false) => { + const requestGeneration = generation.current.start(workerName, automatic); + if (requestGeneration === undefined) { + return; + } + setRefreshingWorkers((current) => new Set(current).add(workerName)); + try { + const response = await localExplorerListWorkers({ + headers: automatic ? { [REFRESH_HEADER]: "poll" } : undefined, + }); + const metadata = response.data?.result; + if (!metadata) { + throw new Error("Refresh returned invalid Worker metadata."); + } + if (!generation.current.isLatest(requestGeneration)) { + return; + } + onWorkerMetadataRef.current(metadata); + const visibleWorkers = visibleCronWorkers(metadata); + setFallbackWorkerName(selectCronFallbackWorker(metadata)); + setVisibleWorkerNames(visibleWorkers.map((worker) => worker.name)); + const previousScopedPersistenceKeys = + lastKnownScopedPersistenceKeys.current; + const returnedPersistenceKeys = persistenceKeysForMetadata(metadata); + const returnedTimePresetPersistenceKeys = persistenceKeysForMetadata( + metadata, + cronTimePresetsStorageKey + ); + const nextPersistenceKeys = reconcilePersistenceKeysForRefresh( + persistenceKeysRef.current, + metadata + ); + const nextTimePresetPersistenceKeys = reconcilePersistenceKeysForRefresh( + timePresetPersistenceKeysRef.current, + metadata, + cronTimePresetsStorageKey + ); + const persistenceScopeChanged = new Map(); + const hydratedCustomRows = new Map(); + const hydratedTimePresets = new Map(); + for (const worker of metadata) { + const persistenceKey = returnedPersistenceKeys[worker.name]; + const timePresetPersistenceKey = + returnedTimePresetPersistenceKeys[worker.name]; + const scopeChanged = didPersistenceScopeChange( + previousScopedPersistenceKeys[worker.name], + persistenceKey + ); + persistenceScopeChanged.set(worker.name, scopeChanged); + if ( + persistenceKey && + (scopeChanged || !hydratedPersistenceKeys.current.has(persistenceKey)) + ) { + hydratedCustomRows.set( + worker.name, + storage.current + ? readPersistedCustomCronRows(storage.current, persistenceKey) + : [] + ); + hydratedPersistenceKeys.current.add(persistenceKey); + } + if ( + timePresetPersistenceKey && + (scopeChanged || + !hydratedTimePresetPersistenceKeys.current.has( + timePresetPersistenceKey + )) + ) { + hydratedTimePresets.set( + worker.name, + storage.current + ? readPersistedCronTimePresets( + storage.current, + timePresetPersistenceKey + ) + : [] + ); + hydratedTimePresetPersistenceKeys.current.add( + timePresetPersistenceKey + ); + } + } + setWorkers((current) => { + const next = { ...current }; + for (const worker of metadata) { + const entry = current[worker.name] ?? emptyState(); + const crons = worker.triggers?.crons ?? []; + const scopeChanged = + persistenceScopeChanged.get(worker.name) ?? false; + const restoredCustomRows = hydratedCustomRows.get(worker.name); + const customRows = restoredCustomRows + ? scopeChanged + ? restoredCustomRows + : [...entry.customRows, ...restoredCustomRows] + : scopeChanged + ? [] + : entry.customRows; + const restoredTimePresets = hydratedTimePresets.get(worker.name); + const timePresets = + restoredTimePresets ?? (scopeChanged ? [] : entry.timePresets); + next[worker.name] = { + authoritative: true, + configuredRows: reconcileConfiguredRows( + scopeChanged ? [] : entry.configuredRows, + crons + ), + crons, + customRows, + stale: false, + timePresets, + }; + } + if ( + workerName !== "" && + !metadata.some((worker) => worker.name === workerName) + ) { + const entry = current[workerName] ?? emptyState(); + next[workerName] = { ...entry, stale: true }; + } + return next; + }); + lastKnownScopedPersistenceKeys.current = { + ...previousScopedPersistenceKeys, + ...nextPersistenceKeys, + }; + persistenceKeysRef.current = nextPersistenceKeys; + setPersistenceKeys(nextPersistenceKeys); + timePresetPersistenceKeysRef.current = nextTimePresetPersistenceKeys; + setTimePresetPersistenceKeys(nextTimePresetPersistenceKeys); + } 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 (generation.current.finish(workerName, requestGeneration)) { + setRefreshingWorkers((current) => { + const next = new Set(current); + next.delete(workerName); + return next; + }); + } + } + }, []); + + useEffect(() => { + 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); + }; + }, [activeWorkerName, refresh]); + + const value = useMemo( + () => ({ + activeWorkerName, + addCustom(workerName) { + const row = createCronRow(""); + setWorkers((current) => { + const entry = current[workerName] ?? emptyState(); + return { + ...current, + [workerName]: { + ...entry, + customRows: [...entry.customRows, row], + }, + }; + }); + return row.id; + }, + addTimePreset(workerName, epochMs) { + if ( + !Number.isSafeInteger(epochMs) || + epochMs < MIN_DATE_EPOCH_MS || + epochMs > MAX_DATE_EPOCH_MS + ) { + return; + } + setWorkers((current) => { + const entry = current[workerName] ?? emptyState(); + if (entry.timePresets.includes(epochMs)) { + return current; + } + return { + ...current, + [workerName]: { + ...entry, + timePresets: [...entry.timePresets, epochMs], + }, + }; + }); + }, + entry: (workerName) => workers[workerName] ?? emptyState(), + fallbackWorkerName, + isRefreshing: (workerName) => refreshingWorkers.has(workerName), + async invoke(workerName, id, scheduledTime) { + const pendingKey = `${workerName}\u0000${id}`; + const entry = workers[workerName]; + const row = [ + ...(entry?.configuredRows ?? []), + ...(entry?.customRows ?? []), + ].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.configuredRows, ...entry.customRows].find( + (candidate) => candidate.id === id + ); + if (!row || row.invocation?.status === "pending") { + return current; + } + return { + ...current, + [workerName]: { + ...entry, + configuredRows: entry.configuredRows.filter( + (candidate) => candidate.id !== id + ), + customRows: entry.customRows.filter( + (candidate) => candidate.id !== id + ), + }, + }; + }); + }, + removeTimePreset(workerName, epochMs) { + setWorkers((current) => { + const entry = current[workerName] ?? emptyState(); + return { + ...current, + [workerName]: { + ...entry, + timePresets: entry.timePresets.filter( + (preset) => preset !== epochMs + ), + }, + }; + }); + }, + updateRow, + visibleWorkerNames, + }), + [ + activeWorkerName, + fallbackWorkerName, + refresh, + refreshingWorkers, + updateRow, + visibleWorkerNames, + 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; + #pendingByWorker = new Map(); + + start(workerName: string, automatic = false): number | undefined { + if (automatic && this.#pendingByWorker.has(workerName)) { + return undefined; + } + this.#latest += 1; + this.#pendingByWorker.set(workerName, this.#latest); + return this.#latest; + } + + finish(workerName: string, generation: number): boolean { + if (this.#pendingByWorker.get(workerName) !== generation) { + return false; + } + this.#pendingByWorker.delete(workerName); + return true; + } + + 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..0cab48064d6 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/CronTriggersPage.tsx @@ -0,0 +1,328 @@ +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 { + ScheduledTimeControl, + type ScheduledTimeSelection, +} from "./ScheduledTimeControl"; +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 type CronTriggersView = "configured" | "ad-hoc"; + +export function CronTriggersPage({ + view, +}: { + view: CronTriggersView; +}): JSX.Element { + const cron = useCronTriggers(); + const workerName = cron.activeWorkerName ?? cron.fallbackWorkerName; + const entry = cron.entry(workerName); + const [focusRow, setFocusRow] = useState(); + const [timeSelection, setTimeSelection] = + useState("now"); + const focusedRow = useRef(undefined); + const configured = entry.crons ?? []; + const configuredRows = entry.configuredRows; + const customRows = entry.customRows; + const rows = view === "configured" ? configuredRows : customRows; + const previousRows = useRef(rows.map((row) => row.id)); + const showNoConfiguration = entry.authoritative && configured.length === 0; + const showPureNoConfiguration = + view === "configured" && + showNoConfiguration && + !configuredRows.some((row) => row.source === "no-longer-configured"); + const title = view === "configured" ? "Configured Crons" : "Ad-Hoc Triggers"; + + useEffect(() => { + setTimeSelection("now"); + }, [workerName]); + + useEffect(() => { + setTimeSelection((current) => + current !== "now" && !entry.timePresets.includes(current) + ? "now" + : current + ); + }, [entry.timePresets]); + + useEffect(() => { + const nextRows = rows.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 && + !rows.some((row) => row.id === focused) && + activeRow === undefined && + (activeElement === document.body || activeElement === null) + ) { + const previousIndex = previousRows.current.indexOf(focused); + const target = + nextRows[previousIndex] ?? nextRows[Math.max(0, previousIndex - 1)]; + requestAnimationFrame(() => { + if (target) { + document + .querySelector(`[data-row-id="${target}"] button`) + ?.focus(); + } else { + focusView(view); + } + }); + } else if (activeRow !== focused) { + focusedRow.current = activeRow; + } + previousRows.current = nextRows; + }, [rows, view]); + + function focusSoon(rowId: string | undefined): void { + if (rowId) { + setFocusRow(rowId); + return; + } + requestAnimationFrame(() => { + focusView(view); + }); + } + + function renderRow(row: CronRow): JSX.Element { + return ( + setFocusRow(undefined)} + onRemove={() => { + const index = rows.findIndex((candidate) => candidate.id === row.id); + const nextFocus = rows[index + 1]?.id ?? rows[index - 1]?.id; + cron.removeRow(workerName, row.id); + focusSoon(nextFocus); + }} + onUpdate={(update) => cron.updateRow(workerName, row.id, update)} + row={row} + triggerEnabled={view === "ad-hoc" || !showNoConfiguration} + trigger={() => { + void cron.invoke( + workerName, + row.id, + timeSelection === "now" ? Date.now() : timeSelection + ); + }} + /> + ); + } + + function scheduledTimeControl(): JSX.Element { + return ( + cron.addTimePreset(workerName, epochMs)} + onRemovePreset={(epochMs) => cron.removeTimePreset(workerName, epochMs)} + onSelectionChange={setTimeSelection} + presets={entry.timePresets} + selection={timeSelection} + /> + ); + } + + return ( +
+ {title}]} + title={title} + > +
+ + + + 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)} /> + ) : showPureNoConfiguration ? ( + + ) : view === "configured" ? ( + + {showNoConfiguration ? ( + + ) : ( + scheduledTimeControl() + )} + {configuredRows.map(renderRow)} + + ) : ( + + {scheduledTimeControl()} +
+

+ Ad-hoc triggers are stored locally and do not modify your Worker + configuration. +

+ +
+ + {customRows.length === 0 ? ( +

+ Ad-hoc triggers you add appear here. +

+ ) : null} + {customRows.map(renderRow)} +
+
+ )} +
+
+ ); +} + +function CronView({ + children, + title, + view, +}: { + children: React.ReactNode; + title: string; + view: CronTriggersView; +}): JSX.Element { + return ( +
+ {children} +
+ ); +} + +function RowList({ children }: { children: React.ReactNode }): JSX.Element { + return ( +
+ {children} +
+ ); +} + +function focusView(view: CronTriggersView): void { + document + .querySelector( + `[data-cron-view="${view}"] [data-add-custom], [data-cron-view="${view}"] [data-row-id] button, [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/ScheduledTimeControl.tsx b/packages/local-explorer-ui/src/components/cron-triggers/ScheduledTimeControl.tsx new file mode 100644 index 00000000000..d524dc9de09 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/ScheduledTimeControl.tsx @@ -0,0 +1,227 @@ +import { Button, Select, Tooltip } from "@cloudflare/kumo"; +import { PlusIcon, TrashIcon } from "@phosphor-icons/react"; +import { useMemo, useState } from "react"; +import { + formatUtcCalendarValue, + parseEpochMilliseconds, + resolveUtcCalendarTime, +} from "./scheduled-time"; +import type { CustomTimeInputMode } from "./types"; +import type { JSX } from "react"; + +export type ScheduledTimeSelection = "now" | number; + +export function ScheduledTimeControl({ + onAddPreset, + onRemovePreset, + onSelectionChange, + presets, + selection, +}: { + onAddPreset: (epochMs: number) => void; + onRemovePreset: (epochMs: number) => void; + onSelectionChange: (selection: ScheduledTimeSelection) => void; + presets: number[]; + selection: ScheduledTimeSelection; +}): JSX.Element { + const [adding, setAdding] = useState(false); + const [inputMode, setInputMode] = useState("calendar"); + const [calendarValue, setCalendarValue] = useState(""); + const [epochValue, setEpochValue] = useState(""); + const calendar = useMemo( + () => (calendarValue ? resolveUtcCalendarTime(calendarValue) : undefined), + [calendarValue] + ); + const epoch = useMemo(() => parseEpochMilliseconds(epochValue), [epochValue]); + const candidate = + inputMode === "calendar" + ? calendar?.kind === "exact" + ? calendar.epochMs + : undefined + : epoch.epochMs; + + function beginAdding(): void { + const now = Date.now(); + setInputMode("calendar"); + setCalendarValue(formatUtcCalendarValue(now)); + setEpochValue(String(now)); + setAdding(true); + } + + function savePreset(): void { + if (candidate === undefined) { + return; + } + onAddPreset(candidate); + onSelectionChange(candidate); + setAdding(false); + } + + return ( +
+
+
+

+ Scheduled time +

+

+ Selected epoch:{" "} + {selection === "now" ? ( + "now" + ) : ( + <> + {selection} ms + + )} +

+
+
+ + + +
+
+ + {adding ? ( +
+ + Add scheduled time preset + +
+ {( + [ + ["calendar", "Date and time (UTC)"], + ["epoch", "Epoch milliseconds"], + ] as const + ).map(([value, label]) => ( + + ))} +
+ +
+ {inputMode === "calendar" ? ( + + ) : ( + + )} +
+ + +
+
+ {calendar?.kind === "invalid" && inputMode === "calendar" ? ( +

+ {calendar.error} +

+ ) : null} + {epoch.error && inputMode === "epoch" ? ( +

+ {epoch.error} +

+ ) : null} +
+ ) : null} +
+ ); +} 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..ce09b6ae08a --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/cron-builder.ts @@ -0,0 +1,283 @@ +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", + }; + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isCronBuilderKind(value: unknown): value is CronBuilderDraft["kind"] { + return ( + typeof value === "string" && + cronBuilderKinds.some((kind) => kind.value === value) + ); +} + +function isCronWeekday(value: unknown): value is CronWeekday { + return ( + typeof value === "string" && WEEKDAYS.some((weekday) => weekday === value) + ); +} + +/** Validate persisted builder state against the shapes used by the editor. */ +export function isCronBuilderDraft(value: unknown): value is CronBuilderDraft { + if (!isRecord(value) || !isCronBuilderKind(value.kind)) { + return false; + } + + const template: Record = changeCronBuilderKind(value.kind); + const expectedKeys = Object.keys(template); + const actualKeys = Object.keys(value); + if ( + actualKeys.length !== expectedKeys.length || + expectedKeys.some((key) => !actualKeys.includes(key)) + ) { + return false; + } + + return expectedKeys.every((key) => { + const fieldValue = value[key]; + if (key === "weekdays") { + return ( + Array.isArray(fieldValue) && + fieldValue.length <= WEEKDAYS.length && + fieldValue.every(isCronWeekday) && + new Set(fieldValue).size === fieldValue.length + ); + } + if (key === "weekday") { + return isCronWeekday(fieldValue); + } + return typeof fieldValue === typeof template[key]; + }); +} + +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..751f6c2d909 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/persistence.ts @@ -0,0 +1,208 @@ +import { isCronBuilderDraft } from "./cron-builder"; +import { createCronRow } from "./row-state"; +import { MAX_DATE_EPOCH_MS, MIN_DATE_EPOCH_MS } from "./scheduled-time"; +import type { CustomCronRow } from "./types"; + +export const CRON_CUSTOM_ROWS_STORAGE_PREFIX = + "local-explorer.cron-triggers.custom-rows.v1"; +export const CRON_TIME_PRESETS_STORAGE_PREFIX = + "local-explorer.cron-triggers.time-presets.v1"; + +type PersistedCustomRow = Pick< + CustomCronRow, + "cron" | "cronBuilder" | "cronInputMode" +>; + +const ACCEPTED_ROW_KEYS = new Set([ + // Accept fields written by the earlier per-row scheduled-time UI, but do not + // restore or write them now that scheduled time is selected at page level. + "calendarValue", + "cron", + "cronBuilder", + "cronInputMode", + "customTimeInputMode", + "epochValue", + "timeMode", +]); + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function parsePersistedCustomRow( + value: unknown +): PersistedCustomRow | undefined { + if ( + !isRecord(value) || + !("cron" in value) || + !("cronBuilder" in value) || + !("cronInputMode" in value) || + Object.keys(value).some((key) => !ACCEPTED_ROW_KEYS.has(key)) || + typeof value.cron !== "string" || + (value.cronInputMode !== "expression" && value.cronInputMode !== "builder") + ) { + return undefined; + } + if (!isCronBuilderDraft(value.cronBuilder)) { + return undefined; + } + return { + cron: value.cron, + cronBuilder: value.cronBuilder, + cronInputMode: value.cronInputMode, + }; +} + +function remove(storage: Storage, key: string): boolean { + try { + storage.removeItem(key); + return true; + } catch { + // Storage can be unavailable in privacy modes or restricted frames. + return false; + } +} + +export function cronCustomRowsStorageKey( + persistenceScope: string | undefined, + workerName: string +): string | undefined { + return persistenceScope + ? `${CRON_CUSTOM_ROWS_STORAGE_PREFIX}.${persistenceScope}.${encodeURIComponent(workerName)}` + : undefined; +} + +export function cronTimePresetsStorageKey( + persistenceScope: string | undefined, + workerName: string +): string | undefined { + return persistenceScope + ? `${CRON_TIME_PRESETS_STORAGE_PREFIX}.${persistenceScope}.${encodeURIComponent(workerName)}` + : undefined; +} + +function hydrateRow(draft: PersistedCustomRow): CustomCronRow { + return { + ...createCronRow(draft.cron), + ...draft, + }; +} + +/** Read validated custom drafts and recreate transient row state from scratch. */ +export function readPersistedCustomCronRows( + storage: Storage, + key: string +): CustomCronRow[] { + let raw: string | null; + try { + raw = storage.getItem(key); + } catch { + return []; + } + if (raw === null) { + return []; + } + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + remove(storage, key); + return []; + } + if (!Array.isArray(value) || value.length === 0) { + remove(storage, key); + return []; + } + const drafts = value.map(parsePersistedCustomRow); + if ( + !drafts.every((draft): draft is PersistedCustomRow => draft !== undefined) + ) { + remove(storage, key); + return []; + } + return drafts.map(hydrateRow); +} + +function persistedDraft(row: CustomCronRow): PersistedCustomRow { + return { + cron: row.cron, + cronBuilder: row.cronBuilder, + cronInputMode: row.cronInputMode, + }; +} + +/** Persist editable custom-row drafts, returning whether storage was updated. */ +export function writePersistedCustomCronRows( + storage: Storage, + key: string, + rows: CustomCronRow[] +): boolean { + if (rows.length === 0) { + return remove(storage, key); + } + const raw = JSON.stringify(rows.map(persistedDraft)); + try { + storage.setItem(key, raw); + return true; + } catch { + // Quota, privacy, and security errors must not break the editor. + return false; + } +} + +/** Read validated, worker-scoped scheduled-time presets. */ +export function readPersistedCronTimePresets( + storage: Storage, + key: string +): number[] { + let raw: string | null; + try { + raw = storage.getItem(key); + } catch { + return []; + } + if (raw === null) { + return []; + } + let value: unknown; + try { + value = JSON.parse(raw); + } catch { + remove(storage, key); + return []; + } + if ( + !Array.isArray(value) || + value.length === 0 || + value.some( + (preset) => + typeof preset !== "number" || + !Number.isSafeInteger(preset) || + preset < MIN_DATE_EPOCH_MS || + preset > MAX_DATE_EPOCH_MS + ) || + new Set(value).size !== value.length + ) { + remove(storage, key); + return []; + } + return value; +} + +/** Persist scheduled-time presets, returning whether storage was updated. */ +export function writePersistedCronTimePresets( + storage: Storage, + key: string, + presets: number[] +): boolean { + if (presets.length === 0) { + return remove(storage, key); + } + try { + storage.setItem(key, JSON.stringify(presets)); + return true; + } catch { + // Quota, privacy, and security errors must not break the editor. + return false; + } +} 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..a30e062cf3e --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/row-state.ts @@ -0,0 +1,78 @@ +import { createDefaultCronBuilderDraft } from "./cron-builder"; +import type { + ConfiguredCronRow, + CronRow, + CronRowSource, + CustomCronRow, +} 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): CustomCronRow; +export function createCronRow( + cron: string, + source: ConfiguredCronRow["source"] +): ConfiguredCronRow; +export function createCronRow( + cron: string, + source: CronRowSource = "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: ConfiguredCronRow[], + crons: string[] +): ConfiguredCronRow[] { + 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 previouslyRemoved = rows.filter( + (row) => row.source === "no-longer-configured" + ); + return [...configured, ...previouslyRemoved, ...staleSettled]; +} + +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..268e04500b1 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/scheduled-time.ts @@ -0,0 +1,108 @@ +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(); + if (epochMs < MIN_DATE_EPOCH_MS || epochMs > MAX_DATE_EPOCH_MS) { + return { + kind: "invalid", + error: "Date and time are outside the supported scheduled-time range.", + }; + } + 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..757353915f6 --- /dev/null +++ b/packages/local-explorer-ui/src/components/cron-triggers/types.ts @@ -0,0 +1,101 @@ +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; +} + +interface CronRowBase { + id: string; + cron: string; + cronInputMode: CronInputMode; + cronBuilder: CronBuilderDraft; + timeMode: TimeMode; + customTimeInputMode: CustomTimeInputMode; + customEpochMs?: number; + epochValue?: string; + calendarValue?: string; + invocation?: InvocationSnapshot; +} + +export interface ConfiguredCronRow extends CronRowBase { + source: "configured" | "no-longer-configured"; +} + +export interface CustomCronRow extends CronRowBase { + source: "custom"; +} + +export type CronRow = ConfiguredCronRow | CustomCronRow; + +export interface CronWorkerState { + authoritative: boolean; + configuredRows: ConfiguredCronRow[]; + crons?: string[]; + customRows: CustomCronRow[]; + stale: boolean; + timePresets: number[]; +} diff --git a/packages/local-explorer-ui/src/routeTree.gen.ts b/packages/local-explorer-ui/src/routeTree.gen.ts index 37c1a0258d7..069576bd674 100644 --- a/packages/local-explorer-ui/src/routeTree.gen.ts +++ b/packages/local-explorer-ui/src/routeTree.gen.ts @@ -10,8 +10,10 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as EmailRouteImport } from './routes/email' +import { Route as CronTriggersRouteImport } from './routes/cron-triggers' 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' @@ -20,6 +22,8 @@ import { Route as EmailSendingRouteImport } from './routes/email/sending' import { Route as EmailRoutingRouteImport } from './routes/email/routing' import { Route as DoClassNameRouteImport } from './routes/do/$className' import { Route as D1DatabaseIdRouteImport } from './routes/d1/$databaseId' +import { Route as CronTriggersConfiguredRouteImport } from './routes/cron-triggers/configured' +import { Route as CronTriggersAdHocRouteImport } from './routes/cron-triggers/ad-hoc' import { Route as WorkflowsWorkflowNameIndexRouteImport } from './routes/workflows/$workflowName/index' import { Route as R2BucketNameIndexRouteImport } from './routes/r2/$bucketName/index' import { Route as EmailRoutingIndexRouteImport } from './routes/email/routing/index' @@ -34,6 +38,11 @@ const EmailRoute = EmailRouteImport.update({ path: '/email', getParentRoute: () => rootRouteImport, } as any) +const CronTriggersRoute = CronTriggersRouteImport.update({ + id: '/cron-triggers', + path: '/cron-triggers', + getParentRoute: () => rootRouteImport, +} as any) const IndexRoute = IndexRouteImport.update({ id: '/', path: '/', @@ -44,6 +53,11 @@ const ObservabilityIndexRoute = ObservabilityIndexRouteImport.update({ path: '/observability/', getParentRoute: () => rootRouteImport, } as any) +const CronTriggersIndexRoute = CronTriggersIndexRouteImport.update({ + id: '/', + path: '/', + getParentRoute: () => CronTriggersRoute, +} as any) const WorkflowsWorkflowNameRoute = WorkflowsWorkflowNameRouteImport.update({ id: '/workflows/$workflowName', path: '/workflows/$workflowName', @@ -84,6 +98,16 @@ const D1DatabaseIdRoute = D1DatabaseIdRouteImport.update({ path: '/d1/$databaseId', getParentRoute: () => rootRouteImport, } as any) +const CronTriggersConfiguredRoute = CronTriggersConfiguredRouteImport.update({ + id: '/configured', + path: '/configured', + getParentRoute: () => CronTriggersRoute, +} as any) +const CronTriggersAdHocRoute = CronTriggersAdHocRouteImport.update({ + id: '/ad-hoc', + path: '/ad-hoc', + getParentRoute: () => CronTriggersRoute, +} as any) const WorkflowsWorkflowNameIndexRoute = WorkflowsWorkflowNameIndexRouteImport.update({ id: '/', @@ -129,7 +153,10 @@ const R2BucketNameObjectSplatRoute = R2BucketNameObjectSplatRouteImport.update({ export interface FileRoutesByFullPath { '/': typeof IndexRoute + '/cron-triggers': typeof CronTriggersRouteWithChildren '/email': typeof EmailRouteWithChildren + '/cron-triggers/ad-hoc': typeof CronTriggersAdHocRoute + '/cron-triggers/configured': typeof CronTriggersConfiguredRoute '/d1/$databaseId': typeof D1DatabaseIdRoute '/do/$className': typeof DoClassNameRouteWithChildren '/email/routing': typeof EmailRoutingRouteWithChildren @@ -138,6 +165,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/$captureId': typeof EmailRoutingCaptureIdRoute @@ -151,10 +179,13 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/email': typeof EmailRouteWithChildren + '/cron-triggers/ad-hoc': typeof CronTriggersAdHocRoute + '/cron-triggers/configured': typeof CronTriggersConfiguredRoute '/d1/$databaseId': typeof D1DatabaseIdRoute '/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/$captureId': typeof EmailRoutingCaptureIdRoute @@ -168,7 +199,10 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/': typeof IndexRoute + '/cron-triggers': typeof CronTriggersRouteWithChildren '/email': typeof EmailRouteWithChildren + '/cron-triggers/ad-hoc': typeof CronTriggersAdHocRoute + '/cron-triggers/configured': typeof CronTriggersConfiguredRoute '/d1/$databaseId': typeof D1DatabaseIdRoute '/do/$className': typeof DoClassNameRouteWithChildren '/email/routing': typeof EmailRoutingRouteWithChildren @@ -177,6 +211,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/$captureId': typeof EmailRoutingCaptureIdRoute @@ -191,7 +226,10 @@ export interface FileRouteTypes { fileRoutesByFullPath: FileRoutesByFullPath fullPaths: | '/' + | '/cron-triggers' | '/email' + | '/cron-triggers/ad-hoc' + | '/cron-triggers/configured' | '/d1/$databaseId' | '/do/$className' | '/email/routing' @@ -200,6 +238,7 @@ export interface FileRouteTypes { | '/observability/events' | '/r2/$bucketName' | '/workflows/$workflowName' + | '/cron-triggers/' | '/observability/' | '/do/$className/$objectId' | '/email/routing/$captureId' @@ -213,10 +252,13 @@ export interface FileRouteTypes { to: | '/' | '/email' + | '/cron-triggers/ad-hoc' + | '/cron-triggers/configured' | '/d1/$databaseId' | '/email/sending' | '/kv/$namespaceId' | '/observability/events' + | '/cron-triggers' | '/observability' | '/do/$className/$objectId' | '/email/routing/$captureId' @@ -229,7 +271,10 @@ export interface FileRouteTypes { id: | '__root__' | '/' + | '/cron-triggers' | '/email' + | '/cron-triggers/ad-hoc' + | '/cron-triggers/configured' | '/d1/$databaseId' | '/do/$className' | '/email/routing' @@ -238,6 +283,7 @@ export interface FileRouteTypes { | '/observability/events' | '/r2/$bucketName' | '/workflows/$workflowName' + | '/cron-triggers/' | '/observability/' | '/do/$className/$objectId' | '/email/routing/$captureId' @@ -251,6 +297,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { IndexRoute: typeof IndexRoute + CronTriggersRoute: typeof CronTriggersRouteWithChildren EmailRoute: typeof EmailRouteWithChildren D1DatabaseIdRoute: typeof D1DatabaseIdRoute DoClassNameRoute: typeof DoClassNameRouteWithChildren @@ -270,6 +317,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof EmailRouteImport parentRoute: typeof rootRouteImport } + '/cron-triggers': { + id: '/cron-triggers' + path: '/cron-triggers' + fullPath: '/cron-triggers' + preLoaderRoute: typeof CronTriggersRouteImport + parentRoute: typeof rootRouteImport + } '/': { id: '/' path: '/' @@ -284,6 +338,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ObservabilityIndexRouteImport parentRoute: typeof rootRouteImport } + '/cron-triggers/': { + id: '/cron-triggers/' + path: '/' + fullPath: '/cron-triggers/' + preLoaderRoute: typeof CronTriggersIndexRouteImport + parentRoute: typeof CronTriggersRoute + } '/workflows/$workflowName': { id: '/workflows/$workflowName' path: '/workflows/$workflowName' @@ -340,6 +401,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof D1DatabaseIdRouteImport parentRoute: typeof rootRouteImport } + '/cron-triggers/configured': { + id: '/cron-triggers/configured' + path: '/configured' + fullPath: '/cron-triggers/configured' + preLoaderRoute: typeof CronTriggersConfiguredRouteImport + parentRoute: typeof CronTriggersRoute + } + '/cron-triggers/ad-hoc': { + id: '/cron-triggers/ad-hoc' + path: '/ad-hoc' + fullPath: '/cron-triggers/ad-hoc' + preLoaderRoute: typeof CronTriggersAdHocRouteImport + parentRoute: typeof CronTriggersRoute + } '/workflows/$workflowName/': { id: '/workflows/$workflowName/' path: '/' @@ -399,6 +474,22 @@ declare module '@tanstack/react-router' { } } +interface CronTriggersRouteChildren { + CronTriggersAdHocRoute: typeof CronTriggersAdHocRoute + CronTriggersConfiguredRoute: typeof CronTriggersConfiguredRoute + CronTriggersIndexRoute: typeof CronTriggersIndexRoute +} + +const CronTriggersRouteChildren: CronTriggersRouteChildren = { + CronTriggersAdHocRoute: CronTriggersAdHocRoute, + CronTriggersConfiguredRoute: CronTriggersConfiguredRoute, + CronTriggersIndexRoute: CronTriggersIndexRoute, +} + +const CronTriggersRouteWithChildren = CronTriggersRoute._addFileChildren( + CronTriggersRouteChildren, +) + interface EmailRoutingRouteChildren { EmailRoutingCaptureIdRoute: typeof EmailRoutingCaptureIdRoute EmailRoutingIndexRoute: typeof EmailRoutingIndexRoute @@ -470,6 +561,7 @@ const WorkflowsWorkflowNameRouteWithChildren = const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, + CronTriggersRoute: CronTriggersRouteWithChildren, EmailRoute: EmailRouteWithChildren, D1DatabaseIdRoute: D1DatabaseIdRoute, DoClassNameRoute: DoClassNameRouteWithChildren, diff --git a/packages/local-explorer-ui/src/routes/__root.tsx b/packages/local-explorer-ui/src/routes/__root.tsx index aefb293e337..9649fcb05ee 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.tsx b/packages/local-explorer-ui/src/routes/cron-triggers.tsx new file mode 100644 index 00000000000..005a6a6fb21 --- /dev/null +++ b/packages/local-explorer-ui/src/routes/cron-triggers.tsx @@ -0,0 +1,166 @@ +import { + createFileRoute, + getRouteApi, + Outlet, + redirect, + useRouter, +} from "@tanstack/react-router"; +import { useCallback, useEffect } from "react"; +import { + CronTriggersProvider, + useCronTriggers, +} from "../components/cron-triggers/CronTriggersContext"; +import { + filterVisibleWorkers, + getSelectedWorker, +} from "../components/WorkerSelector"; +import { isEqual } from "../utils/is-equal"; +import type { LocalExplorerWorker } from "../api"; +import type { JSX } from "react"; + +export const Route = createFileRoute("/cron-triggers")({ + beforeLoad: ({ location, search }) => { + if (/\/cron-triggers\/?$/.test(location.pathname)) { + throw redirect({ + replace: true, + search, + to: "/cron-triggers/configured", + }); + } + }, + component: CronTriggersLayout, + validateSearch: (search: Record): { worker?: string } => ({ + worker: + typeof search.worker === "string" && search.worker !== "" + ? search.worker + : undefined, + }), +}); + +const rootRoute = getRouteApi("__root__"); + +function CronTriggersLayout(): JSX.Element { + const loaderData = rootRoute.useLoaderData(); + const navigate = Route.useNavigate(); + const rootMatchId = rootRoute.useMatch({ select: (match) => match.id }); + const router = useRouter(); + 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; + const handleWorkerMetadata = useCallback( + (metadata: LocalExplorerWorker[]) => { + const visibleMetadata = filterVisibleWorkers(metadata); + if (visibleMetadata.length === 0 && !loaderData.bootstrapAuthoritative) { + return; + } + const knownWorkers = new Map( + loaderData.workers.map((worker) => [worker.name, worker]) + ); + const refreshedNames = new Set(metadata.map((worker) => worker.name)); + const selectedWorkerMetadata = activeWorkerName + ? knownWorkers.get(activeWorkerName) + : undefined; + const workers = + selectedWorkerMetadata && + !refreshedNames.has(selectedWorkerMetadata.name) + ? [...metadata, selectedWorkerMetadata] + : metadata; + const metadataChanged = !isEqual(loaderData.workers, workers); + if (loaderData.bootstrapAuthoritative && !metadataChanged) { + return; + } + router.updateMatch(rootMatchId, (match) => ({ + ...match, + loaderData: { bootstrapAuthoritative: true, workers }, + })); + }, + [ + activeWorkerName, + loaderData.bootstrapAuthoritative, + loaderData.workers, + rootMatchId, + router, + ] + ); + + 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 ( + + + + ); +} + +function CronTriggersContent({ + bootstrapAuthoritative, +}: { + bootstrapAuthoritative: boolean; +}): JSX.Element { + const cron = useCronTriggers(); + const navigate = Route.useNavigate(); + const search = Route.useSearch(); + + useEffect(() => { + if (bootstrapAuthoritative || cron.visibleWorkerNames.length === 0) { + return; + } + const selectedWorker = + search.worker && cron.visibleWorkerNames.includes(search.worker) + ? search.worker + : cron.fallbackWorkerName; + const canonicalWorker = + cron.visibleWorkerNames.length > 1 ? selectedWorker : undefined; + if (search.worker === canonicalWorker) { + return; + } + void navigate({ + replace: true, + search: (previous) => ({ ...previous, worker: canonicalWorker }), + }); + }, [ + bootstrapAuthoritative, + cron.fallbackWorkerName, + cron.visibleWorkerNames, + navigate, + search.worker, + ]); + + return ; +} diff --git a/packages/local-explorer-ui/src/routes/cron-triggers/ad-hoc.tsx b/packages/local-explorer-ui/src/routes/cron-triggers/ad-hoc.tsx new file mode 100644 index 00000000000..ca33ffa9281 --- /dev/null +++ b/packages/local-explorer-ui/src/routes/cron-triggers/ad-hoc.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { CronTriggersPage } from "../../components/cron-triggers/CronTriggersPage"; +import type { JSX } from "react"; + +export const Route = createFileRoute("/cron-triggers/ad-hoc")({ + component: AdHocTriggersView, +}); + +function AdHocTriggersView(): JSX.Element { + return ; +} diff --git a/packages/local-explorer-ui/src/routes/cron-triggers/configured.tsx b/packages/local-explorer-ui/src/routes/cron-triggers/configured.tsx new file mode 100644 index 00000000000..0c2deb8d9ee --- /dev/null +++ b/packages/local-explorer-ui/src/routes/cron-triggers/configured.tsx @@ -0,0 +1,11 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { CronTriggersPage } from "../../components/cron-triggers/CronTriggersPage"; +import type { JSX } from "react"; + +export const Route = createFileRoute("/cron-triggers/configured")({ + component: ConfiguredCronsView, +}); + +function ConfiguredCronsView(): JSX.Element { + return ; +} 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..1cb2fcfa51d --- /dev/null +++ b/packages/local-explorer-ui/src/routes/cron-triggers/index.tsx @@ -0,0 +1,11 @@ +import { createFileRoute, redirect } from "@tanstack/react-router"; + +export const Route = createFileRoute("/cron-triggers/")({ + beforeLoad: ({ search }) => { + throw redirect({ + replace: true, + search, + to: "/cron-triggers/configured", + }); + }, +}); 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, }; /**