From e3ba59dc71af3f71150049f2c9c332b371d846e2 Mon Sep 17 00:00:00 2001 From: Xu Gu <53551744+guxu11@users.noreply.github.com> Date: Sun, 1 Mar 2026 06:49:11 -0800 Subject: [PATCH] Control UI: add cron jobs schedule/status filters with reset (#9510) Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> --- CHANGELOG.md | 1 + ui/src/i18n/locales/en.ts | 3 + ui/src/ui/app-render.ts | 24 ++++++- ui/src/ui/app-view-state.ts | 8 ++- ui/src/ui/app.ts | 4 ++ ui/src/ui/controllers/cron-filters.test.ts | 81 ++++++++++++++++++++++ ui/src/ui/controllers/cron.test.ts | 2 + ui/src/ui/controllers/cron.ts | 38 +++++++++- ui/src/ui/views/cron.test.ts | 55 +++++++++++++++ ui/src/ui/views/cron.ts | 64 ++++++++++++++++- 10 files changed, 276 insertions(+), 4 deletions(-) create mode 100644 ui/src/ui/controllers/cron-filters.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 1150cd12238..a7fac9e5c19 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -85,6 +85,7 @@ Docs: https://docs.openclaw.ai - Cron/Isolated model defaults: resolve isolated cron `subagents.model` (including object-form `primary`) through allowlist-aware model selection so isolated cron runs honor subagent model defaults unless explicitly overridden by job payload model. (#11474) Thanks @AnonO6. - Cron/Isolated sessions list: persist the intended pre-run model/provider on isolated cron session entries so `sessions_list` reflects payload/session model overrides even when runs fail before post-run telemetry persistence. (#21279) Thanks @altaywtf. - Web UI/Chat sessions: add a cron-session visibility toggle in the session selector, fix cron-key detection across `cron:*` and `agent:*:cron:*` formats, and localize the new control labels/tooltips. (#26976) Thanks @ianderrington. +- Web UI/Cron jobs: add schedule-kind and last-run-status filters to the Jobs list, with reset control and client-side filtering over loaded results. (#9510) Thanks @guxu11. - Cron/One-shot reliability: retry transient one-shot failures with bounded backoff and configurable retry policy before disabling. (#24435) Thanks . - Gateway/Cron auditability: add gateway info logs for successful cron create, update, and remove operations. (#25090) Thanks . - Cron/Schedule errors: notify users when a job is auto-disabled after repeated schedule computation failures. (#29098) Thanks . diff --git a/ui/src/i18n/locales/en.ts b/ui/src/i18n/locales/en.ts index 342ca8c85a5..8d3ef85a44b 100644 --- a/ui/src/i18n/locales/en.ts +++ b/ui/src/i18n/locales/en.ts @@ -140,6 +140,8 @@ export const en: TranslationMap = { searchJobs: "Search jobs", searchPlaceholder: "Name, description, or agent", enabled: "Enabled", + schedule: "Schedule", + lastRun: "Last run", all: "All", sort: "Sort", nextRun: "Next run", @@ -148,6 +150,7 @@ export const en: TranslationMap = { direction: "Direction", ascending: "Ascending", descending: "Descending", + reset: "Reset", noMatching: "No matching jobs.", loading: "Loading...", loadMore: "Load more jobs", diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 0ab9e6875eb..e7958ea3b8e 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -34,6 +34,7 @@ import { validateCronForm, hasCronFormErrors, normalizeCronFormState, + getVisibleCronJobs, updateCronJobsFilter, updateCronRunsFilter, } from "./controllers/cron.ts"; @@ -191,6 +192,7 @@ export function renderApp(state: AppViewState) { ].filter(Boolean), ), ).toSorted((a, b) => a.localeCompare(b)); + const visibleCronJobs = getVisibleCronJobs(state); const selectedDeliveryChannel = state.cronForm.deliveryChannel && state.cronForm.deliveryChannel.trim() ? state.cronForm.deliveryChannel.trim() @@ -444,11 +446,13 @@ export function renderApp(state: AppViewState) { loading: state.cronLoading, jobsLoadingMore: state.cronJobsLoadingMore, status: state.cronStatus, - jobs: state.cronJobs, + jobs: visibleCronJobs, jobsTotal: state.cronJobsTotal, jobsHasMore: state.cronJobsHasMore, jobsQuery: state.cronJobsQuery, jobsEnabledFilter: state.cronJobsEnabledFilter, + jobsScheduleKindFilter: state.cronJobsScheduleKindFilter, + jobsLastStatusFilter: state.cronJobsLastStatusFilter, jobsSortBy: state.cronJobsSortBy, jobsSortDir: state.cronJobsSortDir, error: state.cronError, @@ -497,6 +501,24 @@ export function renderApp(state: AppViewState) { onLoadMoreJobs: () => loadMoreCronJobs(state), onJobsFiltersChange: async (patch) => { updateCronJobsFilter(state, patch); + const shouldReload = + typeof patch.cronJobsQuery === "string" || + Boolean(patch.cronJobsEnabledFilter) || + Boolean(patch.cronJobsSortBy) || + Boolean(patch.cronJobsSortDir); + if (shouldReload) { + await reloadCronJobs(state); + } + }, + onJobsFiltersReset: async () => { + updateCronJobsFilter(state, { + cronJobsQuery: "", + cronJobsEnabledFilter: "all", + cronJobsScheduleKindFilter: "all", + cronJobsLastStatusFilter: "all", + cronJobsSortBy: "nextRunAtMs", + cronJobsSortDir: "asc", + }); await reloadCronJobs(state); }, onLoadMoreRuns: () => loadMoreCronRuns(state), diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index 362a9e332c3..7d173518612 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -1,6 +1,10 @@ import type { EventLogEntry } from "./app-events.ts"; import type { CompactionStatus, FallbackStatus } from "./app-tool-stream.ts"; -import type { CronFieldErrors } from "./controllers/cron.ts"; +import type { + CronFieldErrors, + CronJobsLastStatusFilter, + CronJobsScheduleKindFilter, +} from "./controllers/cron.ts"; import type { DevicePairingList } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts"; @@ -208,6 +212,8 @@ export type AppViewState = { cronJobsLimit: number; cronJobsQuery: string; cronJobsEnabledFilter: CronJobsEnabledFilter; + cronJobsScheduleKindFilter: CronJobsScheduleKindFilter; + cronJobsLastStatusFilter: CronJobsLastStatusFilter; cronJobsSortBy: CronJobsSortBy; cronJobsSortDir: CronSortDir; cronStatus: CronStatus | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 409697e785f..3b50922bdfc 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -311,6 +311,10 @@ export class OpenClawApp extends LitElement { @state() cronJobsLimit = 50; @state() cronJobsQuery = ""; @state() cronJobsEnabledFilter: import("./types.js").CronJobsEnabledFilter = "all"; + @state() cronJobsScheduleKindFilter: import("./controllers/cron.js").CronJobsScheduleKindFilter = + "all"; + @state() cronJobsLastStatusFilter: import("./controllers/cron.js").CronJobsLastStatusFilter = + "all"; @state() cronJobsSortBy: import("./types.js").CronJobsSortBy = "nextRunAtMs"; @state() cronJobsSortDir: import("./types.js").CronSortDir = "asc"; @state() cronStatus: CronStatus | null = null; diff --git a/ui/src/ui/controllers/cron-filters.test.ts b/ui/src/ui/controllers/cron-filters.test.ts new file mode 100644 index 00000000000..318c59ef66b --- /dev/null +++ b/ui/src/ui/controllers/cron-filters.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from "vitest"; +import type { CronJob } from "../types.ts"; +import { getVisibleCronJobs } from "./cron.ts"; + +function job(id: string, overrides: Partial = {}): CronJob { + return { + id, + name: `Job ${id}`, + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "every", everyMs: 60_000 }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "test" }, + ...overrides, + }; +} + +describe("getVisibleCronJobs", () => { + it("returns all jobs when no client-side filters are active", () => { + const jobs = [job("a"), job("b", { schedule: { kind: "cron", expr: "0 9 * * *" } })]; + const visible = getVisibleCronJobs({ + cronJobs: jobs, + cronJobsScheduleKindFilter: "all", + cronJobsLastStatusFilter: "all", + }); + expect(visible).toHaveLength(2); + }); + + it("filters by schedule kind", () => { + const jobs = [ + job("a", { schedule: { kind: "at", at: "2026-03-01T08:00:00Z" } }), + job("b", { schedule: { kind: "every", everyMs: 60_000 } }), + job("c", { schedule: { kind: "cron", expr: "0 9 * * *" } }), + ]; + const visible = getVisibleCronJobs({ + cronJobs: jobs, + cronJobsScheduleKindFilter: "cron", + cronJobsLastStatusFilter: "all", + }); + expect(visible.map((entry) => entry.id)).toEqual(["c"]); + }); + + it("filters by last status", () => { + const jobs = [ + job("ok", { state: { lastStatus: "ok", lastRunAtMs: 1 } }), + job("error", { state: { lastStatus: "error", lastRunAtMs: 2 } }), + job("unknown"), + ]; + const visible = getVisibleCronJobs({ + cronJobs: jobs, + cronJobsScheduleKindFilter: "all", + cronJobsLastStatusFilter: "error", + }); + expect(visible.map((entry) => entry.id)).toEqual(["error"]); + }); + + it("combines schedule and last-status filters", () => { + const jobs = [ + job("a", { + schedule: { kind: "cron", expr: "0 9 * * *" }, + state: { lastStatus: "ok", lastRunAtMs: 1 }, + }), + job("b", { + schedule: { kind: "cron", expr: "0 10 * * *" }, + state: { lastStatus: "error", lastRunAtMs: 2 }, + }), + job("c", { + schedule: { kind: "every", everyMs: 60_000 }, + state: { lastStatus: "error", lastRunAtMs: 3 }, + }), + ]; + const visible = getVisibleCronJobs({ + cronJobs: jobs, + cronJobsScheduleKindFilter: "cron", + cronJobsLastStatusFilter: "error", + }); + expect(visible.map((entry) => entry.id)).toEqual(["b"]); + }); +}); diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index 576ba873b8f..5133994b6db 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -26,6 +26,8 @@ function createState(overrides: Partial = {}): CronState { cronJobsLimit: 50, cronJobsQuery: "", cronJobsEnabledFilter: "all", + cronJobsScheduleKindFilter: "all", + cronJobsLastStatusFilter: "all", cronJobsSortBy: "nextRunAtMs", cronJobsSortDir: "asc", cronStatus: null, diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 1de4f0ec9f2..775b4cb650c 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -35,6 +35,9 @@ export type CronFieldKey = export type CronFieldErrors = Partial>; +export type CronJobsScheduleKindFilter = "all" | "at" | "every" | "cron"; +export type CronJobsLastStatusFilter = "all" | "ok" | "error" | "skipped"; + export type CronState = { client: GatewayBrowserClient | null; connected: boolean; @@ -47,6 +50,8 @@ export type CronState = { cronJobsLimit: number; cronJobsQuery: string; cronJobsEnabledFilter: CronJobsEnabledFilter; + cronJobsScheduleKindFilter: CronJobsScheduleKindFilter; + cronJobsLastStatusFilter: CronJobsLastStatusFilter; cronJobsSortBy: CronJobsSortBy; cronJobsSortDir: CronSortDir; cronStatus: CronStatus | null; @@ -316,7 +321,12 @@ export function updateCronJobsFilter( patch: Partial< Pick< CronState, - "cronJobsQuery" | "cronJobsEnabledFilter" | "cronJobsSortBy" | "cronJobsSortDir" + | "cronJobsQuery" + | "cronJobsEnabledFilter" + | "cronJobsScheduleKindFilter" + | "cronJobsLastStatusFilter" + | "cronJobsSortBy" + | "cronJobsSortDir" > >, ) { @@ -326,6 +336,12 @@ export function updateCronJobsFilter( if (patch.cronJobsEnabledFilter) { state.cronJobsEnabledFilter = patch.cronJobsEnabledFilter; } + if (patch.cronJobsScheduleKindFilter) { + state.cronJobsScheduleKindFilter = patch.cronJobsScheduleKindFilter; + } + if (patch.cronJobsLastStatusFilter) { + state.cronJobsLastStatusFilter = patch.cronJobsLastStatusFilter; + } if (patch.cronJobsSortBy) { state.cronJobsSortBy = patch.cronJobsSortBy; } @@ -334,6 +350,26 @@ export function updateCronJobsFilter( } } +export function getVisibleCronJobs( + state: Pick, +): CronJob[] { + return state.cronJobs.filter((job) => { + if ( + state.cronJobsScheduleKindFilter !== "all" && + job.schedule.kind !== state.cronJobsScheduleKindFilter + ) { + return false; + } + if ( + state.cronJobsLastStatusFilter !== "all" && + job.state?.lastStatus !== state.cronJobsLastStatusFilter + ) { + return false; + } + return true; + }); +} + function clearCronEditState(state: CronState) { state.cronEditingJobId = null; } diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 18ac9129b83..95509b5f380 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -29,6 +29,8 @@ function createProps(overrides: Partial = {}): CronProps { jobsHasMore: false, jobsQuery: "", jobsEnabledFilter: "all", + jobsScheduleKindFilter: "all", + jobsLastStatusFilter: "all", jobsSortBy: "nextRunAtMs", jobsSortDir: "asc", error: null, @@ -67,6 +69,7 @@ function createProps(overrides: Partial = {}): CronProps { onLoadRuns: () => undefined, onLoadMoreJobs: () => undefined, onJobsFiltersChange: () => undefined, + onJobsFiltersReset: () => undefined, onLoadMoreRuns: () => undefined, onRunsFiltersChange: () => undefined, ...overrides, @@ -246,6 +249,58 @@ describe("cron view", () => { expect(container.textContent).not.toContain("Next 13"); }); + it("calls onJobsFiltersChange when schedule filter changes", () => { + const container = document.createElement("div"); + const onJobsFiltersChange = vi.fn(); + render(renderCron(createProps({ onJobsFiltersChange })), container); + + const select = container.querySelector('select[data-test-id="cron-jobs-schedule-filter"]'); + expect(select).not.toBeNull(); + if (!(select instanceof HTMLSelectElement)) { + return; + } + select.value = "cron"; + select.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onJobsFiltersChange).toHaveBeenCalledWith({ cronJobsScheduleKindFilter: "cron" }); + }); + + it("calls onJobsFiltersChange when last-run filter changes", () => { + const container = document.createElement("div"); + const onJobsFiltersChange = vi.fn(); + render(renderCron(createProps({ onJobsFiltersChange })), container); + + const select = container.querySelector('select[data-test-id="cron-jobs-last-status-filter"]'); + expect(select).not.toBeNull(); + if (!(select instanceof HTMLSelectElement)) { + return; + } + select.value = "error"; + select.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onJobsFiltersChange).toHaveBeenCalledWith({ cronJobsLastStatusFilter: "error" }); + }); + + it("calls onJobsFiltersReset when reset button is clicked", () => { + const container = document.createElement("div"); + const onJobsFiltersReset = vi.fn(); + render( + renderCron( + createProps({ + jobsQuery: "digest", + onJobsFiltersReset, + }), + ), + container, + ); + + const reset = container.querySelector('button[data-test-id="cron-jobs-filters-reset"]'); + expect(reset).not.toBeNull(); + reset?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onJobsFiltersReset).toHaveBeenCalledTimes(1); + }); + it("shows webhook delivery option in the form", () => { const container = document.createElement("div"); render( diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index a9606cd6fbd..b13929f9ce0 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -1,7 +1,12 @@ import { html, nothing } from "lit"; import { ifDefined } from "lit/directives/if-defined.js"; import { t } from "../../i18n/index.ts"; -import type { CronFieldErrors, CronFieldKey } from "../controllers/cron.ts"; +import type { + CronFieldErrors, + CronFieldKey, + CronJobsLastStatusFilter, + CronJobsScheduleKindFilter, +} from "../controllers/cron.ts"; import { formatRelativeTimestamp, formatMs } from "../format.ts"; import { pathForTab } from "../navigation.ts"; import { formatCronSchedule, formatNextRun } from "../presenter.ts"; @@ -27,6 +32,8 @@ export type CronProps = { jobsHasMore: boolean; jobsQuery: string; jobsEnabledFilter: CronJobsEnabledFilter; + jobsScheduleKindFilter: CronJobsScheduleKindFilter; + jobsLastStatusFilter: CronJobsLastStatusFilter; jobsSortBy: CronJobsSortBy; jobsSortDir: CronSortDir; error: string | null; @@ -68,9 +75,12 @@ export type CronProps = { onJobsFiltersChange: (patch: { cronJobsQuery?: string; cronJobsEnabledFilter?: CronJobsEnabledFilter; + cronJobsScheduleKindFilter?: CronJobsScheduleKindFilter; + cronJobsLastStatusFilter?: CronJobsLastStatusFilter; cronJobsSortBy?: CronJobsSortBy; cronJobsSortDir?: CronSortDir; }) => void | Promise; + onJobsFiltersReset: () => void | Promise; onLoadMoreRuns: () => void; onRunsFiltersChange: (patch: { cronRunsScope?: CronRunScope; @@ -366,6 +376,13 @@ export function renderCron(props: CronProps) { props.form.deliveryMode === "announce" && !supportsAnnounce ? "none" : props.form.deliveryMode; const blockingFields = collectBlockingFields(props.fieldErrors, props.form, selectedDeliveryMode); const blockedByValidation = !props.busy && blockingFields.length > 0; + const hasActiveJobsFilters = + props.jobsQuery.trim().length > 0 || + props.jobsEnabledFilter !== "all" || + props.jobsScheduleKindFilter !== "all" || + props.jobsLastStatusFilter !== "all" || + props.jobsSortBy !== "nextRunAtMs" || + props.jobsSortDir !== "asc"; const submitDisabledReason = blockedByValidation && !props.canSubmit ? blockingFields.length === 1 @@ -446,6 +463,40 @@ export function renderCron(props: CronProps) { + + + ${ props.jobs.length === 0