Control UI: add cron jobs schedule/status filters with reset (#9510)

Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
This commit is contained in:
Xu Gu
2026-03-01 06:49:11 -08:00
committed by GitHub
parent 59fd394bfe
commit e3ba59dc71
10 changed files with 276 additions and 4 deletions

View File

@@ -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",

View File

@@ -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),

View File

@@ -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;

View File

@@ -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;

View File

@@ -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> = {}): 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"]);
});
});

View File

@@ -26,6 +26,8 @@ function createState(overrides: Partial<CronState> = {}): CronState {
cronJobsLimit: 50,
cronJobsQuery: "",
cronJobsEnabledFilter: "all",
cronJobsScheduleKindFilter: "all",
cronJobsLastStatusFilter: "all",
cronJobsSortBy: "nextRunAtMs",
cronJobsSortDir: "asc",
cronStatus: null,

View File

@@ -35,6 +35,9 @@ export type CronFieldKey =
export type CronFieldErrors = Partial<Record<CronFieldKey, string>>;
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<CronState, "cronJobs" | "cronJobsScheduleKindFilter" | "cronJobsLastStatusFilter">,
): 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;
}

View File

@@ -29,6 +29,8 @@ function createProps(overrides: Partial<CronProps> = {}): 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> = {}): 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(

View File

@@ -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<void>;
onJobsFiltersReset: () => void | Promise<void>;
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) {
<option value="disabled">${t("common.disabled")}</option>
</select>
</label>
<label class="field">
<span>${t("cron.jobs.schedule")}</span>
<select
data-test-id="cron-jobs-schedule-filter"
.value=${props.jobsScheduleKindFilter}
@change=${(e: Event) =>
props.onJobsFiltersChange({
cronJobsScheduleKindFilter: (e.target as HTMLSelectElement)
.value as CronJobsScheduleKindFilter,
})}
>
<option value="all">${t("cron.jobs.all")}</option>
<option value="at">${t("cron.form.at")}</option>
<option value="every">${t("cron.form.every")}</option>
<option value="cron">${t("cron.form.cronOption")}</option>
</select>
</label>
<label class="field">
<span>${t("cron.jobs.lastRun")}</span>
<select
data-test-id="cron-jobs-last-status-filter"
.value=${props.jobsLastStatusFilter}
@change=${(e: Event) =>
props.onJobsFiltersChange({
cronJobsLastStatusFilter: (e.target as HTMLSelectElement)
.value as CronJobsLastStatusFilter,
})}
>
<option value="all">${t("cron.jobs.all")}</option>
<option value="ok">${t("cron.runs.runStatusOk")}</option>
<option value="error">${t("cron.runs.runStatusError")}</option>
<option value="skipped">${t("cron.runs.runStatusSkipped")}</option>
</select>
</label>
<label class="field">
<span>${t("cron.jobs.sort")}</span>
<select
@@ -473,6 +524,17 @@ export function renderCron(props: CronProps) {
<option value="desc">${t("cron.jobs.descending")}</option>
</select>
</label>
<label class="field">
<span>${t("cron.jobs.reset")}</span>
<button
class="btn"
data-test-id="cron-jobs-filters-reset"
?disabled=${!hasActiveJobsFilters}
@click=${props.onJobsFiltersReset}
>
${t("cron.jobs.reset")}
</button>
</label>
</div>
${
props.jobs.length === 0