mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
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:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
81
ui/src/ui/controllers/cron-filters.test.ts
Normal file
81
ui/src/ui/controllers/cron-filters.test.ts
Normal 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"]);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user