Skip to content

Commit 77c3b14

Browse files
authored
Web UI: add full cron edit parity, all-jobs run history, and compact filters (#24155) thanks @Takhoffman
Verified: - pnpm install --frozen-lockfile - pnpm build - pnpm check - pnpm test:macmini Co-authored-by: Takhoffman <781889+Takhoffman@users.noreply.github.com> Co-authored-by: Tak Hoffman <781889+Takhoffman@users.noreply.github.com>
1 parent 610863e commit 77c3b14

23 files changed

Lines changed: 3767 additions & 342 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai
88

99
### Changes
1010

11+
- Control UI/Cron: add full web cron edit parity (including clone and richer validation/help text), plus all-jobs run history with pagination/search/sort/multi-filter controls and improved cron page layout for cleaner scheduling and failure triage workflows.
1112
- Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc.
1213
- Update/Core: add an optional built-in auto-updater for package installs (`update.auto.*`), default-off, with stable rollout delay+jitter and beta hourly cadence.
1314
- CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting.

docs/web/control-ui.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ you revoke it with `openclaw devices revoke --device <id> --role <role>`. See
6767
- Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
6868
- Instances: presence list + refresh (`system-presence`)
6969
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
70-
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
70+
- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`)
7171
- Skills: status, enable/disable, install, API key updates (`skills.*`)
7272
- Nodes: list + caps (`node.list`)
7373
- Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`)
@@ -85,6 +85,9 @@ Cron jobs panel notes:
8585
- Channel/target fields appear when announce is selected.
8686
- Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL.
8787
- For main-session jobs, webhook and none delivery modes are available.
88+
- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options,
89+
agent model/thinking overrides, and best-effort delivery toggles.
90+
- Form validation is inline with field-level errors; invalid values disable the save button until fixed.
8891
- Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header.
8992
- Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated.
9093

src/cron/run-log.ts

Lines changed: 216 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,35 @@ export type CronRunLogEntry = {
1919
nextRunAtMs?: number;
2020
} & CronRunTelemetry;
2121

22+
export type CronRunLogSortDir = "asc" | "desc";
23+
export type CronRunLogStatusFilter = "all" | "ok" | "error" | "skipped";
24+
25+
export type ReadCronRunLogPageOptions = {
26+
limit?: number;
27+
offset?: number;
28+
jobId?: string;
29+
status?: CronRunLogStatusFilter;
30+
statuses?: CronRunStatus[];
31+
deliveryStatus?: CronDeliveryStatus;
32+
deliveryStatuses?: CronDeliveryStatus[];
33+
query?: string;
34+
sortDir?: CronRunLogSortDir;
35+
};
36+
37+
export type CronRunLogPageResult = {
38+
entries: CronRunLogEntry[];
39+
total: number;
40+
offset: number;
41+
limit: number;
42+
hasMore: boolean;
43+
nextOffset: number | null;
44+
};
45+
46+
type ReadCronRunLogAllPageOptions = Omit<ReadCronRunLogPageOptions, "jobId"> & {
47+
storePath: string;
48+
jobNameById?: Record<string, string>;
49+
};
50+
2251
function assertSafeCronRunLogJobId(jobId: string): string {
2352
const trimmed = jobId.trim();
2453
if (!trimmed) {
@@ -98,14 +127,78 @@ export async function readCronRunLogEntries(
98127
opts?: { limit?: number; jobId?: string },
99128
): Promise<CronRunLogEntry[]> {
100129
const limit = Math.max(1, Math.min(5000, Math.floor(opts?.limit ?? 200)));
130+
const page = await readCronRunLogEntriesPage(filePath, {
131+
jobId: opts?.jobId,
132+
limit,
133+
offset: 0,
134+
status: "all",
135+
sortDir: "desc",
136+
});
137+
return page.entries.toReversed();
138+
}
139+
140+
function normalizeRunStatusFilter(status?: string): CronRunLogStatusFilter {
141+
if (status === "ok" || status === "error" || status === "skipped" || status === "all") {
142+
return status;
143+
}
144+
return "all";
145+
}
146+
147+
function normalizeRunStatuses(opts?: {
148+
statuses?: CronRunStatus[];
149+
status?: CronRunLogStatusFilter;
150+
}): CronRunStatus[] | null {
151+
if (Array.isArray(opts?.statuses) && opts.statuses.length > 0) {
152+
const filtered = opts.statuses.filter(
153+
(status): status is CronRunStatus =>
154+
status === "ok" || status === "error" || status === "skipped",
155+
);
156+
if (filtered.length > 0) {
157+
return Array.from(new Set(filtered));
158+
}
159+
}
160+
const status = normalizeRunStatusFilter(opts?.status);
161+
if (status === "all") {
162+
return null;
163+
}
164+
return [status];
165+
}
166+
167+
function normalizeDeliveryStatuses(opts?: {
168+
deliveryStatuses?: CronDeliveryStatus[];
169+
deliveryStatus?: CronDeliveryStatus;
170+
}): CronDeliveryStatus[] | null {
171+
if (Array.isArray(opts?.deliveryStatuses) && opts.deliveryStatuses.length > 0) {
172+
const filtered = opts.deliveryStatuses.filter(
173+
(status): status is CronDeliveryStatus =>
174+
status === "delivered" ||
175+
status === "not-delivered" ||
176+
status === "unknown" ||
177+
status === "not-requested",
178+
);
179+
if (filtered.length > 0) {
180+
return Array.from(new Set(filtered));
181+
}
182+
}
183+
if (
184+
opts?.deliveryStatus === "delivered" ||
185+
opts?.deliveryStatus === "not-delivered" ||
186+
opts?.deliveryStatus === "unknown" ||
187+
opts?.deliveryStatus === "not-requested"
188+
) {
189+
return [opts.deliveryStatus];
190+
}
191+
return null;
192+
}
193+
194+
function parseAllRunLogEntries(raw: string, opts?: { jobId?: string }): CronRunLogEntry[] {
101195
const jobId = opts?.jobId?.trim() || undefined;
102-
const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => "");
103196
if (!raw.trim()) {
104197
return [];
105198
}
106199
const parsed: CronRunLogEntry[] = [];
107200
const lines = raw.split("\n");
108-
for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i--) {
201+
for (let i = 0; i < lines.length; i++) {
109202
const line = lines[i]?.trim();
110203
if (!line) {
111204
continue;
@@ -182,5 +275,125 @@ export async function readCronRunLogEntries(
182275
// ignore invalid lines
183276
}
184277
}
185-
return parsed.toReversed();
278+
return parsed;
279+
}
280+
281+
export async function readCronRunLogEntriesPage(
282+
filePath: string,
283+
opts?: ReadCronRunLogPageOptions,
284+
): Promise<CronRunLogPageResult> {
285+
const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? 50)));
286+
const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => "");
287+
const statuses = normalizeRunStatuses(opts);
288+
const deliveryStatuses = normalizeDeliveryStatuses(opts);
289+
const query = opts?.query?.trim().toLowerCase() ?? "";
290+
const sortDir: CronRunLogSortDir = opts?.sortDir === "asc" ? "asc" : "desc";
291+
const all = parseAllRunLogEntries(raw, { jobId: opts?.jobId });
292+
const filtered = all.filter((entry) => {
293+
if (statuses && (!entry.status || !statuses.includes(entry.status))) {
294+
return false;
295+
}
296+
if (deliveryStatuses) {
297+
const deliveryStatus = entry.deliveryStatus ?? "not-requested";
298+
if (!deliveryStatuses.includes(deliveryStatus)) {
299+
return false;
300+
}
301+
}
302+
if (!query) {
303+
return true;
304+
}
305+
const haystack = [entry.summary ?? "", entry.error ?? "", entry.jobId].join(" ").toLowerCase();
306+
return haystack.includes(query);
307+
});
308+
const sorted =
309+
sortDir === "asc"
310+
? filtered.toSorted((a, b) => a.ts - b.ts)
311+
: filtered.toSorted((a, b) => b.ts - a.ts);
312+
const total = sorted.length;
313+
const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0)));
314+
const entries = sorted.slice(offset, offset + limit);
315+
const nextOffset = offset + entries.length;
316+
return {
317+
entries,
318+
total,
319+
offset,
320+
limit,
321+
hasMore: nextOffset < total,
322+
nextOffset: nextOffset < total ? nextOffset : null,
323+
};
324+
}
325+
326+
export async function readCronRunLogEntriesPageAll(
327+
opts: ReadCronRunLogAllPageOptions,
328+
): Promise<CronRunLogPageResult> {
329+
const limit = Math.max(1, Math.min(200, Math.floor(opts.limit ?? 50)));
330+
const statuses = normalizeRunStatuses(opts);
331+
const deliveryStatuses = normalizeDeliveryStatuses(opts);
332+
const query = opts.query?.trim().toLowerCase() ?? "";
333+
const sortDir: CronRunLogSortDir = opts.sortDir === "asc" ? "asc" : "desc";
334+
const runsDir = path.resolve(path.dirname(path.resolve(opts.storePath)), "runs");
335+
const files = await fs.readdir(runsDir, { withFileTypes: true }).catch(() => []);
336+
const jsonlFiles = files
337+
.filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl"))
338+
.map((entry) => path.join(runsDir, entry.name));
339+
if (jsonlFiles.length === 0) {
340+
return {
341+
entries: [],
342+
total: 0,
343+
offset: 0,
344+
limit,
345+
hasMore: false,
346+
nextOffset: null,
347+
};
348+
}
349+
const chunks = await Promise.all(
350+
jsonlFiles.map(async (filePath) => {
351+
const raw = await fs.readFile(filePath, "utf-8").catch(() => "");
352+
return parseAllRunLogEntries(raw);
353+
}),
354+
);
355+
const all = chunks.flat();
356+
const filtered = all.filter((entry) => {
357+
if (statuses && (!entry.status || !statuses.includes(entry.status))) {
358+
return false;
359+
}
360+
if (deliveryStatuses) {
361+
const deliveryStatus = entry.deliveryStatus ?? "not-requested";
362+
if (!deliveryStatuses.includes(deliveryStatus)) {
363+
return false;
364+
}
365+
}
366+
if (!query) {
367+
return true;
368+
}
369+
const jobName = opts.jobNameById?.[entry.jobId] ?? "";
370+
const haystack = [entry.summary ?? "", entry.error ?? "", entry.jobId, jobName]
371+
.join(" ")
372+
.toLowerCase();
373+
return haystack.includes(query);
374+
});
375+
const sorted =
376+
sortDir === "asc"
377+
? filtered.toSorted((a, b) => a.ts - b.ts)
378+
: filtered.toSorted((a, b) => b.ts - a.ts);
379+
const total = sorted.length;
380+
const offset = Math.max(0, Math.min(total, Math.floor(opts.offset ?? 0)));
381+
const entries = sorted.slice(offset, offset + limit);
382+
if (opts.jobNameById) {
383+
for (const entry of entries) {
384+
const jobName = opts.jobNameById[entry.jobId];
385+
if (jobName) {
386+
(entry as CronRunLogEntry & { jobName?: string }).jobName = jobName;
387+
}
388+
}
389+
}
390+
const nextOffset = offset + entries.length;
391+
return {
392+
entries,
393+
total,
394+
offset,
395+
limit,
396+
hasMore: nextOffset < total,
397+
nextOffset: nextOffset < total ? nextOffset : null,
398+
};
186399
}

src/cron/service.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export class CronService {
2626
return await ops.list(this.state, opts);
2727
}
2828

29+
async listPage(opts?: ops.CronListPageOptions) {
30+
return await ops.listPage(this.state, opts);
31+
}
32+
2933
async add(input: CronJobCreate) {
3034
return await ops.add(this.state, input);
3135
}

src/cron/service/ops.ts

Lines changed: 98 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CronJobCreate, CronJobPatch } from "../types.js";
1+
import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js";
22
import {
33
applyJobPatch,
44
computeJobNextRunAtMs,
@@ -22,6 +22,29 @@ import {
2222
wake,
2323
} from "./timer.js";
2424

25+
type CronJobsEnabledFilter = "all" | "enabled" | "disabled";
26+
type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name";
27+
type CronSortDir = "asc" | "desc";
28+
29+
export type CronListPageOptions = {
30+
includeDisabled?: boolean;
31+
limit?: number;
32+
offset?: number;
33+
query?: string;
34+
enabled?: CronJobsEnabledFilter;
35+
sortBy?: CronJobsSortBy;
36+
sortDir?: CronSortDir;
37+
};
38+
39+
export type CronListPageResult = {
40+
jobs: ReturnType<typeof sortJobs>;
41+
total: number;
42+
offset: number;
43+
limit: number;
44+
hasMore: boolean;
45+
nextOffset: number | null;
46+
};
47+
2548
async function ensureLoadedForRead(state: CronServiceState) {
2649
await ensureLoaded(state, { skipRecompute: true });
2750
if (!state.store) {
@@ -101,6 +124,80 @@ export async function list(state: CronServiceState, opts?: { includeDisabled?: b
101124
});
102125
}
103126

127+
function resolveEnabledFilter(opts?: CronListPageOptions): CronJobsEnabledFilter {
128+
if (opts?.enabled === "all" || opts?.enabled === "enabled" || opts?.enabled === "disabled") {
129+
return opts.enabled;
130+
}
131+
return opts?.includeDisabled ? "all" : "enabled";
132+
}
133+
134+
function sortJobs(jobs: CronJob[], sortBy: CronJobsSortBy, sortDir: CronSortDir) {
135+
const dir = sortDir === "desc" ? -1 : 1;
136+
return jobs.toSorted((a, b) => {
137+
let cmp = 0;
138+
if (sortBy === "name") {
139+
cmp = a.name.localeCompare(b.name, undefined, { sensitivity: "base" });
140+
} else if (sortBy === "updatedAtMs") {
141+
cmp = a.updatedAtMs - b.updatedAtMs;
142+
} else {
143+
const aNext = a.state.nextRunAtMs;
144+
const bNext = b.state.nextRunAtMs;
145+
if (typeof aNext === "number" && typeof bNext === "number") {
146+
cmp = aNext - bNext;
147+
} else if (typeof aNext === "number") {
148+
cmp = -1;
149+
} else if (typeof bNext === "number") {
150+
cmp = 1;
151+
} else {
152+
cmp = 0;
153+
}
154+
}
155+
if (cmp !== 0) {
156+
return cmp * dir;
157+
}
158+
return a.id.localeCompare(b.id);
159+
});
160+
}
161+
162+
export async function listPage(state: CronServiceState, opts?: CronListPageOptions) {
163+
return await locked(state, async () => {
164+
await ensureLoadedForRead(state);
165+
const query = opts?.query?.trim().toLowerCase() ?? "";
166+
const enabledFilter = resolveEnabledFilter(opts);
167+
const sortBy = opts?.sortBy ?? "nextRunAtMs";
168+
const sortDir = opts?.sortDir ?? "asc";
169+
const source = state.store?.jobs ?? [];
170+
const filtered = source.filter((job) => {
171+
if (enabledFilter === "enabled" && !job.enabled) {
172+
return false;
173+
}
174+
if (enabledFilter === "disabled" && job.enabled) {
175+
return false;
176+
}
177+
if (!query) {
178+
return true;
179+
}
180+
const haystack = [job.name, job.description ?? "", job.agentId ?? ""].join(" ").toLowerCase();
181+
return haystack.includes(query);
182+
});
183+
const sorted = sortJobs(filtered, sortBy, sortDir);
184+
const total = sorted.length;
185+
const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0)));
186+
const defaultLimit = total === 0 ? 50 : total;
187+
const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? defaultLimit)));
188+
const jobs = sorted.slice(offset, offset + limit);
189+
const nextOffset = offset + jobs.length;
190+
return {
191+
jobs,
192+
total,
193+
offset,
194+
limit,
195+
hasMore: nextOffset < total,
196+
nextOffset: nextOffset < total ? nextOffset : null,
197+
} satisfies CronListPageResult;
198+
});
199+
}
200+
104201
export async function add(state: CronServiceState, input: CronJobCreate) {
105202
return await locked(state, async () => {
106203
warnIfDisabled(state, "add");

0 commit comments

Comments
 (0)