diff --git a/ui/src/test-helpers/control-ui-e2e.ts b/ui/src/test-helpers/control-ui-e2e.ts index 089fd6e6da22..41b1426b05ae 100644 --- a/ui/src/test-helpers/control-ui-e2e.ts +++ b/ui/src/test-helpers/control-ui-e2e.ts @@ -10,6 +10,23 @@ import { PROTOCOL_VERSION } from "../../../src/gateway/protocol/version.js"; const require = createRequire(import.meta.url); const json5EsmPath = require.resolve("json5/dist/index.mjs"); +const commonJsOptimizeDeps = [ + "highlight.js/lib/core", + "highlight.js/lib/languages/bash", + "highlight.js/lib/languages/cpp", + "highlight.js/lib/languages/css", + "highlight.js/lib/languages/diff", + "highlight.js/lib/languages/go", + "highlight.js/lib/languages/java", + "highlight.js/lib/languages/javascript", + "highlight.js/lib/languages/json", + "highlight.js/lib/languages/markdown", + "highlight.js/lib/languages/python", + "highlight.js/lib/languages/rust", + "highlight.js/lib/languages/typescript", + "highlight.js/lib/languages/xml", + "highlight.js/lib/languages/yaml", +] as const; export type MockGatewayRequest = { id: string; @@ -82,7 +99,12 @@ export async function startControlUiE2eServer(): Promise { }, logLevel: "error", optimizeDeps: { - include: ["ipaddr.js", "lit/directives/repeat.js", "markdown-it-task-lists"], + include: [ + "ipaddr.js", + "lit/directives/repeat.js", + "markdown-it-task-lists", + ...commonJsOptimizeDeps, + ], }, publicDir: path.join(uiRoot, "public"), resolve: { diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 6bfd48920aa4..87c14e13e3f8 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -358,6 +358,8 @@ export type AppViewState = { | "cronQuickCreateStep" | "cronQuickCreateDraft" | "cronJobsLoadingMore" + | "cronJobsReloadPending" + | "cronJobsReloadPendingTableFilters" | "cronJobs" | "cronJobsTotal" | "cronJobsHasMore" diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 64eb98946fc2..4de659b7d721 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -522,6 +522,8 @@ export class OpenClawApp extends LitElement { | import("./views/cron-quick-create.ts").CronQuickCreateDraft | null = null; @state() cronJobsLoadingMore = false; + cronJobsReloadPending = false; + cronJobsReloadPendingTableFilters = false; @state() cronJobs: CronJob[] = []; @state() cronJobsTotal = 0; @state() cronJobsHasMore = false; diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index eb502d7f5eb2..476cfefd6598 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -11,6 +11,7 @@ import { runCronJob, startCronEdit, startCronClone, + updateCronJobsFilter, validateCronForm, type CronState, } from "./cron.ts"; @@ -24,6 +25,8 @@ function createState(overrides: Partial = {}): CronState { cronQuickCreateStep: "what", cronQuickCreateDraft: null, cronJobsLoadingMore: false, + cronJobsReloadPending: false, + cronJobsReloadPendingTableFilters: false, cronJobs: [], cronJobsTotal: 0, cronJobsHasMore: false, @@ -99,6 +102,13 @@ function requestPatch(call: readonly [method: string, payload?: unknown]) { return requireRecord(requestPayload(call).patch, `${call[0]} patch`); } +type EmptyCronListResponse = { + jobs: []; + total: number; + hasMore: boolean; + nextOffset: null; +}; + describe("cron controller", () => { it("loads model suggestions from the configured model view", async () => { const request = vi.fn(async () => ({ @@ -1323,6 +1333,131 @@ describe("cron controller", () => { ); }); + it("reloads cron jobs after filters change during an in-flight table load", async () => { + let resolveFirst!: (value: EmptyCronListResponse) => void; + const firstResponse = new Promise((resolve) => { + resolveFirst = resolve; + }); + const request = vi.fn(async (method: string, payload?: unknown) => { + if (method !== "cron.list") { + return {}; + } + if (request.mock.calls.length === 1) { + return firstResponse; + } + expectRecordFields(requireRecord(payload, "pending cron.list payload"), { + scheduleKind: "cron", + lastRunStatus: "unknown", + }); + return { jobs: [], total: 0, hasMore: false, nextOffset: null }; + }); + const state = createState({ + client: { request } as unknown as CronState["client"], + }); + + const firstLoad = loadCronJobsPage(state, { tableFilters: true }); + updateCronJobsFilter(state, { + cronJobsScheduleKindFilter: "cron", + cronJobsLastStatusFilter: "unknown", + }); + await loadCronJobsPage(state, { tableFilters: true }); + resolveFirst({ jobs: [], total: 0, hasMore: false, nextOffset: null }); + await firstLoad; + + expect(request).toHaveBeenCalledTimes(2); + expect(state.cronJobsReloadPending).toBe(false); + expect(state.cronJobsReloadPendingTableFilters).toBe(false); + }); + + it("reloads cron jobs after filters change during an in-flight append load", async () => { + let resolveAppend!: (value: EmptyCronListResponse) => void; + const appendResponse = new Promise((resolve) => { + resolveAppend = resolve; + }); + const request = vi.fn(async (method: string, payload?: unknown) => { + if (method !== "cron.list") { + return {}; + } + if (request.mock.calls.length === 1) { + expectRecordFields(requireRecord(payload, "append cron.list payload"), { + offset: 1, + }); + return appendResponse; + } + expectRecordFields(requireRecord(payload, "pending append cron.list payload"), { + offset: 0, + scheduleKind: "cron", + lastRunStatus: "unknown", + }); + return { jobs: [], total: 0, hasMore: false, nextOffset: null }; + }); + const state = createState({ + client: { request } as unknown as CronState["client"], + cronJobs: [ + { + id: "existing", + name: "Existing", + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + }, + ], + cronJobsHasMore: true, + cronJobsNextOffset: 1, + }); + + const appendLoad = loadCronJobsPage(state, { append: true, tableFilters: true }); + updateCronJobsFilter(state, { + cronJobsScheduleKindFilter: "cron", + cronJobsLastStatusFilter: "unknown", + }); + await loadCronJobsPage(state, { tableFilters: true }); + resolveAppend({ jobs: [], total: 0, hasMore: false, nextOffset: null }); + await appendLoad; + + expect(request).toHaveBeenCalledTimes(2); + expect(state.cronJobsReloadPending).toBe(false); + expect(state.cronJobsReloadPendingTableFilters).toBe(false); + }); + + it("uses the latest queued cron jobs table-filter mode", async () => { + let resolveFirst!: (value: EmptyCronListResponse) => void; + const firstResponse = new Promise((resolve) => { + resolveFirst = resolve; + }); + const request = vi.fn(async (method: string, payload?: unknown) => { + if (method !== "cron.list") { + return {}; + } + if (request.mock.calls.length === 1) { + return firstResponse; + } + const pendingPayload = requireRecord(payload, "latest pending cron.list payload"); + expect(pendingPayload).not.toHaveProperty("scheduleKind"); + expect(pendingPayload).not.toHaveProperty("lastRunStatus"); + return { jobs: [], total: 0, hasMore: false, nextOffset: null }; + }); + const state = createState({ + client: { request } as unknown as CronState["client"], + cronJobsScheduleKindFilter: "cron", + cronJobsLastStatusFilter: "unknown", + }); + + const firstLoad = loadCronJobsPage(state); + await loadCronJobsPage(state, { tableFilters: true }); + await loadCronJobsPage(state); + resolveFirst({ jobs: [], total: 0, hasMore: false, nextOffset: null }); + await firstLoad; + + expect(request).toHaveBeenCalledTimes(2); + expect(state.cronJobsReloadPending).toBe(false); + expect(state.cronJobsReloadPendingTableFilters).toBe(false); + }); + it("drops malformed cron jobs before they enter UI state", async () => { const request = vi.fn(async (method: string) => { if (method === "cron.list") { diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index acc582d0eec5..f3501efef84e 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -55,6 +55,8 @@ export type CronState = { cronQuickCreateStep: import("../views/cron-quick-create.ts").CronQuickCreateStep; cronQuickCreateDraft: import("../views/cron-quick-create.ts").CronQuickCreateDraft | null; cronJobsLoadingMore: boolean; + cronJobsReloadPending: boolean; + cronJobsReloadPendingTableFilters: boolean; cronJobs: CronJob[]; cronJobsTotal: number; cronJobsHasMore: boolean; @@ -276,6 +278,16 @@ function normalizeCronPageMeta(params: { return { total, hasMore, nextOffset }; } +async function drainPendingCronJobsReload(state: CronState) { + if (!state.cronJobsReloadPending) { + return; + } + const tableFilters = state.cronJobsReloadPendingTableFilters; + state.cronJobsReloadPending = false; + state.cronJobsReloadPendingTableFilters = false; + await loadCronJobsPage(state, { tableFilters }); +} + export async function loadCronJobsPage( state: CronState, opts?: { append?: boolean; tableFilters?: boolean }, @@ -283,10 +295,14 @@ export async function loadCronJobsPage( if (!state.client || !state.connected) { return; } + const append = opts?.append === true; if (state.cronLoading || state.cronJobsLoadingMore) { + if (!append) { + state.cronJobsReloadPending = true; + state.cronJobsReloadPendingTableFilters = opts?.tableFilters === true; + } return; } - const append = opts?.append === true; if (append && !state.cronJobsHasMore) { return; } @@ -340,6 +356,7 @@ export async function loadCronJobsPage( } else { state.cronLoading = false; } + await drainPendingCronJobsReload(state); } } diff --git a/ui/src/ui/e2e/cron-filters.e2e.test.ts b/ui/src/ui/e2e/cron-filters.e2e.test.ts index ba35cd48c705..315aba32a14c 100644 --- a/ui/src/ui/e2e/cron-filters.e2e.test.ts +++ b/ui/src/ui/e2e/cron-filters.e2e.test.ts @@ -71,10 +71,43 @@ async function waitForCronListRequest( throw new Error(`No matching cron.list request found: ${JSON.stringify(requests)}`); } +type PageDiagnostics = { + consoleMessages: string[]; + pageErrors: string[]; +}; + function jobTitle(page: Page, name: string) { return page.locator(".cron-job .list-title", { hasText: new RegExp(`^${name}$`, "u") }); } +async function waitForJobTitle( + page: Page, + gateway: MockGatewayControls, + diagnostics: PageDiagnostics, + name: string, +) { + try { + await jobTitle(page, name).waitFor({ timeout: 10_000 }); + } catch (err) { + const requests = await gateway.getRequests(); + const bodyText = await page.locator("body").textContent({ timeout: 1_000 }).catch(String); + const content = await page.content().catch(String); + throw new Error( + [ + `Timed out waiting for cron job title: ${name}`, + `URL: ${page.url()}`, + `Gateway requests: ${JSON.stringify(requests)}`, + `Page errors: ${JSON.stringify(diagnostics.pageErrors)}`, + `Console: ${JSON.stringify(diagnostics.consoleMessages)}`, + `Page text: ${bodyText}`, + `Page content: ${content.slice(0, 1000)}`, + `Original error: ${String(err)}`, + ].join("\n"), + { cause: err }, + ); + } +} + describeControlUiE2e("Control UI cron mocked Gateway E2E", () => { beforeAll(async () => { if (!chromiumAvailable) { @@ -111,6 +144,10 @@ describeControlUiE2e("Control UI cron mocked Gateway E2E", () => { viewport: { height: 900, width: 1280 }, }); const page = await context.newPage(); + const pageErrors: string[] = []; + const consoleMessages: string[] = []; + page.on("pageerror", (err) => pageErrors.push(String(err))); + page.on("console", (msg) => consoleMessages.push(`${msg.type()}: ${msg.text()}`)); const gateway = await installMockGateway(page, { methodResponses: { "cron.list": { @@ -143,9 +180,10 @@ describeControlUiE2e("Control UI cron mocked Gateway E2E", () => { }); try { - await page.goto(`${server.baseUrl}cron`); - await jobTitle(page, "Digest every minute").waitFor({ timeout: 10_000 }); - await jobTitle(page, "Nightly cron pending").waitFor({ timeout: 10_000 }); + const response = await page.goto(`${server.baseUrl}cron`); + expect(response?.status()).toBe(200); + await waitForJobTitle(page, gateway, { consoleMessages, pageErrors }, "Digest every minute"); + await waitForJobTitle(page, gateway, { consoleMessages, pageErrors }, "Nightly cron pending"); const initialRequest = await waitForCronListRequest( gateway, @@ -180,7 +218,7 @@ describeControlUiE2e("Control UI cron mocked Gateway E2E", () => { sortBy: "nextRunAtMs", sortDir: "asc", }); - await jobTitle(page, "Nightly cron pending").waitFor({ timeout: 10_000 }); + await waitForJobTitle(page, gateway, { consoleMessages, pageErrors }, "Nightly cron pending"); await expect.poll(async () => jobTitle(page, "Digest every minute").count()).toBe(0); } finally { await context.close(); diff --git a/ui/vite.config.ts b/ui/vite.config.ts index 0c6d4d316851..70365aa9a38f 100644 --- a/ui/vite.config.ts +++ b/ui/vite.config.ts @@ -11,6 +11,23 @@ const repoRoot = path.resolve(here, ".."); const outDir = path.resolve(here, "../dist/control-ui"); const require = createRequire(import.meta.url); const json5EsmPath = require.resolve("json5/dist/index.mjs"); +const commonJsOptimizeDeps = [ + "highlight.js/lib/core", + "highlight.js/lib/languages/bash", + "highlight.js/lib/languages/cpp", + "highlight.js/lib/languages/css", + "highlight.js/lib/languages/diff", + "highlight.js/lib/languages/go", + "highlight.js/lib/languages/java", + "highlight.js/lib/languages/javascript", + "highlight.js/lib/languages/json", + "highlight.js/lib/languages/markdown", + "highlight.js/lib/languages/python", + "highlight.js/lib/languages/rust", + "highlight.js/lib/languages/typescript", + "highlight.js/lib/languages/xml", + "highlight.js/lib/languages/yaml", +] as const; function normalizeBase(input: string): string { const trimmed = input.trim(); @@ -96,7 +113,12 @@ export default defineConfig(() => { }, publicDir: path.resolve(here, "public"), optimizeDeps: { - include: ["ipaddr.js", "lit/directives/repeat.js", "markdown-it-task-lists"], + include: [ + "ipaddr.js", + "lit/directives/repeat.js", + "markdown-it-task-lists", + ...commonJsOptimizeDeps, + ], }, resolve: { alias: {