From 77c3b142a96631b1be411fb7032f61d2d74d6f5e Mon Sep 17 00:00:00 2001 From: Tak Hoffman <781889+Takhoffman@users.noreply.github.com> Date: Sun, 22 Feb 2026 23:05:42 -0600 Subject: [PATCH] Web UI: add full cron edit parity, all-jobs run history, and compact filters (openclaw#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> --- CHANGELOG.md | 1 + docs/web/control-ui.md | 5 +- src/cron/run-log.ts | 219 ++- src/cron/service.ts | 4 + src/cron/service/ops.ts | 99 +- src/gateway/protocol/cron-validators.test.ts | 49 + src/gateway/protocol/schema/cron.ts | 83 +- src/gateway/server-methods/cron.ts | 81 +- src/gateway/server.cron.test.ts | 11 + test/ui.presenter-next-run.test.ts | 17 + ui/src/styles/components.css | 435 ++++++ ui/src/ui/app-defaults.ts | 8 + ui/src/ui/app-render.ts | 149 +- ui/src/ui/app-settings.ts | 20 +- ui/src/ui/app-view-state.ts | 31 + ui/src/ui/app.ts | 24 + ui/src/ui/controllers/cron.test.ts | 409 ++++- ui/src/ui/controllers/cron.ts | 584 +++++++- ui/src/ui/presenter.ts | 3 +- ui/src/ui/types.ts | 46 +- ui/src/ui/ui-types.ts | 8 + ui/src/ui/views/cron.test.ts | 435 +++++- ui/src/ui/views/cron.ts | 1392 ++++++++++++++---- 23 files changed, 3769 insertions(+), 344 deletions(-) create mode 100644 test/ui.presenter-next-run.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index d07e9868b63..79a16b88f52 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ Docs: https://docs.openclaw.ai ### Changes +- 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. - Provider/Mistral: add support for the Mistral provider, including memory embeddings and voice support. (#23845) Thanks @vincentkoc. - 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. - CLI/Update: add `openclaw update --dry-run` to preview channel/tag/target/restart actions without mutating config, installing, syncing plugins, or restarting. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 9ff05572ca0..ebaad5aef90 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -67,7 +67,7 @@ you revoke it with `openclaw devices revoke --device --role `. See - Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`) - Instances: presence list + refresh (`system-presence`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) -- Cron jobs: list/add/run/enable/disable + run history (`cron.*`) +- Cron jobs: list/add/edit/run/enable/disable + run history (`cron.*`) - Skills: status, enable/disable, install, API key updates (`skills.*`) - Nodes: list + caps (`node.list`) - Exec approvals: edit gateway or node allowlists + ask policy for `exec host=gateway/node` (`exec.approvals.*`) @@ -85,6 +85,9 @@ Cron jobs panel notes: - Channel/target fields appear when announce is selected. - Webhook mode uses `delivery.mode = "webhook"` with `delivery.to` set to a valid HTTP(S) webhook URL. - For main-session jobs, webhook and none delivery modes are available. +- Advanced edit controls include delete-after-run, clear agent override, cron exact/stagger options, + agent model/thinking overrides, and best-effort delivery toggles. +- Form validation is inline with field-level errors; invalid values disable the save button until fixed. - Set `cron.webhookToken` to send a dedicated bearer token, if omitted the webhook is sent without an auth header. - Deprecated fallback: stored legacy jobs with `notify: true` can still use `cron.webhook` until migrated. diff --git a/src/cron/run-log.ts b/src/cron/run-log.ts index 3dd5c279091..426c4279a21 100644 --- a/src/cron/run-log.ts +++ b/src/cron/run-log.ts @@ -19,6 +19,35 @@ export type CronRunLogEntry = { nextRunAtMs?: number; } & CronRunTelemetry; +export type CronRunLogSortDir = "asc" | "desc"; +export type CronRunLogStatusFilter = "all" | "ok" | "error" | "skipped"; + +export type ReadCronRunLogPageOptions = { + limit?: number; + offset?: number; + jobId?: string; + status?: CronRunLogStatusFilter; + statuses?: CronRunStatus[]; + deliveryStatus?: CronDeliveryStatus; + deliveryStatuses?: CronDeliveryStatus[]; + query?: string; + sortDir?: CronRunLogSortDir; +}; + +export type CronRunLogPageResult = { + entries: CronRunLogEntry[]; + total: number; + offset: number; + limit: number; + hasMore: boolean; + nextOffset: number | null; +}; + +type ReadCronRunLogAllPageOptions = Omit & { + storePath: string; + jobNameById?: Record; +}; + function assertSafeCronRunLogJobId(jobId: string): string { const trimmed = jobId.trim(); if (!trimmed) { @@ -98,14 +127,78 @@ export async function readCronRunLogEntries( opts?: { limit?: number; jobId?: string }, ): Promise { const limit = Math.max(1, Math.min(5000, Math.floor(opts?.limit ?? 200))); + const page = await readCronRunLogEntriesPage(filePath, { + jobId: opts?.jobId, + limit, + offset: 0, + status: "all", + sortDir: "desc", + }); + return page.entries.toReversed(); +} + +function normalizeRunStatusFilter(status?: string): CronRunLogStatusFilter { + if (status === "ok" || status === "error" || status === "skipped" || status === "all") { + return status; + } + return "all"; +} + +function normalizeRunStatuses(opts?: { + statuses?: CronRunStatus[]; + status?: CronRunLogStatusFilter; +}): CronRunStatus[] | null { + if (Array.isArray(opts?.statuses) && opts.statuses.length > 0) { + const filtered = opts.statuses.filter( + (status): status is CronRunStatus => + status === "ok" || status === "error" || status === "skipped", + ); + if (filtered.length > 0) { + return Array.from(new Set(filtered)); + } + } + const status = normalizeRunStatusFilter(opts?.status); + if (status === "all") { + return null; + } + return [status]; +} + +function normalizeDeliveryStatuses(opts?: { + deliveryStatuses?: CronDeliveryStatus[]; + deliveryStatus?: CronDeliveryStatus; +}): CronDeliveryStatus[] | null { + if (Array.isArray(opts?.deliveryStatuses) && opts.deliveryStatuses.length > 0) { + const filtered = opts.deliveryStatuses.filter( + (status): status is CronDeliveryStatus => + status === "delivered" || + status === "not-delivered" || + status === "unknown" || + status === "not-requested", + ); + if (filtered.length > 0) { + return Array.from(new Set(filtered)); + } + } + if ( + opts?.deliveryStatus === "delivered" || + opts?.deliveryStatus === "not-delivered" || + opts?.deliveryStatus === "unknown" || + opts?.deliveryStatus === "not-requested" + ) { + return [opts.deliveryStatus]; + } + return null; +} + +function parseAllRunLogEntries(raw: string, opts?: { jobId?: string }): CronRunLogEntry[] { const jobId = opts?.jobId?.trim() || undefined; - const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => ""); if (!raw.trim()) { return []; } const parsed: CronRunLogEntry[] = []; const lines = raw.split("\n"); - for (let i = lines.length - 1; i >= 0 && parsed.length < limit; i--) { + for (let i = 0; i < lines.length; i++) { const line = lines[i]?.trim(); if (!line) { continue; @@ -182,5 +275,125 @@ export async function readCronRunLogEntries( // ignore invalid lines } } - return parsed.toReversed(); + return parsed; +} + +export async function readCronRunLogEntriesPage( + filePath: string, + opts?: ReadCronRunLogPageOptions, +): Promise { + const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? 50))); + const raw = await fs.readFile(path.resolve(filePath), "utf-8").catch(() => ""); + const statuses = normalizeRunStatuses(opts); + const deliveryStatuses = normalizeDeliveryStatuses(opts); + const query = opts?.query?.trim().toLowerCase() ?? ""; + const sortDir: CronRunLogSortDir = opts?.sortDir === "asc" ? "asc" : "desc"; + const all = parseAllRunLogEntries(raw, { jobId: opts?.jobId }); + const filtered = all.filter((entry) => { + if (statuses && (!entry.status || !statuses.includes(entry.status))) { + return false; + } + if (deliveryStatuses) { + const deliveryStatus = entry.deliveryStatus ?? "not-requested"; + if (!deliveryStatuses.includes(deliveryStatus)) { + return false; + } + } + if (!query) { + return true; + } + const haystack = [entry.summary ?? "", entry.error ?? "", entry.jobId].join(" ").toLowerCase(); + return haystack.includes(query); + }); + const sorted = + sortDir === "asc" + ? filtered.toSorted((a, b) => a.ts - b.ts) + : filtered.toSorted((a, b) => b.ts - a.ts); + const total = sorted.length; + const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0))); + const entries = sorted.slice(offset, offset + limit); + const nextOffset = offset + entries.length; + return { + entries, + total, + offset, + limit, + hasMore: nextOffset < total, + nextOffset: nextOffset < total ? nextOffset : null, + }; +} + +export async function readCronRunLogEntriesPageAll( + opts: ReadCronRunLogAllPageOptions, +): Promise { + const limit = Math.max(1, Math.min(200, Math.floor(opts.limit ?? 50))); + const statuses = normalizeRunStatuses(opts); + const deliveryStatuses = normalizeDeliveryStatuses(opts); + const query = opts.query?.trim().toLowerCase() ?? ""; + const sortDir: CronRunLogSortDir = opts.sortDir === "asc" ? "asc" : "desc"; + const runsDir = path.resolve(path.dirname(path.resolve(opts.storePath)), "runs"); + const files = await fs.readdir(runsDir, { withFileTypes: true }).catch(() => []); + const jsonlFiles = files + .filter((entry) => entry.isFile() && entry.name.endsWith(".jsonl")) + .map((entry) => path.join(runsDir, entry.name)); + if (jsonlFiles.length === 0) { + return { + entries: [], + total: 0, + offset: 0, + limit, + hasMore: false, + nextOffset: null, + }; + } + const chunks = await Promise.all( + jsonlFiles.map(async (filePath) => { + const raw = await fs.readFile(filePath, "utf-8").catch(() => ""); + return parseAllRunLogEntries(raw); + }), + ); + const all = chunks.flat(); + const filtered = all.filter((entry) => { + if (statuses && (!entry.status || !statuses.includes(entry.status))) { + return false; + } + if (deliveryStatuses) { + const deliveryStatus = entry.deliveryStatus ?? "not-requested"; + if (!deliveryStatuses.includes(deliveryStatus)) { + return false; + } + } + if (!query) { + return true; + } + const jobName = opts.jobNameById?.[entry.jobId] ?? ""; + const haystack = [entry.summary ?? "", entry.error ?? "", entry.jobId, jobName] + .join(" ") + .toLowerCase(); + return haystack.includes(query); + }); + const sorted = + sortDir === "asc" + ? filtered.toSorted((a, b) => a.ts - b.ts) + : filtered.toSorted((a, b) => b.ts - a.ts); + const total = sorted.length; + const offset = Math.max(0, Math.min(total, Math.floor(opts.offset ?? 0))); + const entries = sorted.slice(offset, offset + limit); + if (opts.jobNameById) { + for (const entry of entries) { + const jobName = opts.jobNameById[entry.jobId]; + if (jobName) { + (entry as CronRunLogEntry & { jobName?: string }).jobName = jobName; + } + } + } + const nextOffset = offset + entries.length; + return { + entries, + total, + offset, + limit, + hasMore: nextOffset < total, + nextOffset: nextOffset < total ? nextOffset : null, + }; } diff --git a/src/cron/service.ts b/src/cron/service.ts index 50d5f40b6e2..7ccc1cc59e0 100644 --- a/src/cron/service.ts +++ b/src/cron/service.ts @@ -26,6 +26,10 @@ export class CronService { return await ops.list(this.state, opts); } + async listPage(opts?: ops.CronListPageOptions) { + return await ops.listPage(this.state, opts); + } + async add(input: CronJobCreate) { return await ops.add(this.state, input); } diff --git a/src/cron/service/ops.ts b/src/cron/service/ops.ts index 68789790207..ca2f8d1a946 100644 --- a/src/cron/service/ops.ts +++ b/src/cron/service/ops.ts @@ -1,4 +1,4 @@ -import type { CronJobCreate, CronJobPatch } from "../types.js"; +import type { CronJob, CronJobCreate, CronJobPatch } from "../types.js"; import { applyJobPatch, computeJobNextRunAtMs, @@ -22,6 +22,29 @@ import { wake, } from "./timer.js"; +type CronJobsEnabledFilter = "all" | "enabled" | "disabled"; +type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name"; +type CronSortDir = "asc" | "desc"; + +export type CronListPageOptions = { + includeDisabled?: boolean; + limit?: number; + offset?: number; + query?: string; + enabled?: CronJobsEnabledFilter; + sortBy?: CronJobsSortBy; + sortDir?: CronSortDir; +}; + +export type CronListPageResult = { + jobs: ReturnType; + total: number; + offset: number; + limit: number; + hasMore: boolean; + nextOffset: number | null; +}; + async function ensureLoadedForRead(state: CronServiceState) { await ensureLoaded(state, { skipRecompute: true }); if (!state.store) { @@ -101,6 +124,80 @@ export async function list(state: CronServiceState, opts?: { includeDisabled?: b }); } +function resolveEnabledFilter(opts?: CronListPageOptions): CronJobsEnabledFilter { + if (opts?.enabled === "all" || opts?.enabled === "enabled" || opts?.enabled === "disabled") { + return opts.enabled; + } + return opts?.includeDisabled ? "all" : "enabled"; +} + +function sortJobs(jobs: CronJob[], sortBy: CronJobsSortBy, sortDir: CronSortDir) { + const dir = sortDir === "desc" ? -1 : 1; + return jobs.toSorted((a, b) => { + let cmp = 0; + if (sortBy === "name") { + cmp = a.name.localeCompare(b.name, undefined, { sensitivity: "base" }); + } else if (sortBy === "updatedAtMs") { + cmp = a.updatedAtMs - b.updatedAtMs; + } else { + const aNext = a.state.nextRunAtMs; + const bNext = b.state.nextRunAtMs; + if (typeof aNext === "number" && typeof bNext === "number") { + cmp = aNext - bNext; + } else if (typeof aNext === "number") { + cmp = -1; + } else if (typeof bNext === "number") { + cmp = 1; + } else { + cmp = 0; + } + } + if (cmp !== 0) { + return cmp * dir; + } + return a.id.localeCompare(b.id); + }); +} + +export async function listPage(state: CronServiceState, opts?: CronListPageOptions) { + return await locked(state, async () => { + await ensureLoadedForRead(state); + const query = opts?.query?.trim().toLowerCase() ?? ""; + const enabledFilter = resolveEnabledFilter(opts); + const sortBy = opts?.sortBy ?? "nextRunAtMs"; + const sortDir = opts?.sortDir ?? "asc"; + const source = state.store?.jobs ?? []; + const filtered = source.filter((job) => { + if (enabledFilter === "enabled" && !job.enabled) { + return false; + } + if (enabledFilter === "disabled" && job.enabled) { + return false; + } + if (!query) { + return true; + } + const haystack = [job.name, job.description ?? "", job.agentId ?? ""].join(" ").toLowerCase(); + return haystack.includes(query); + }); + const sorted = sortJobs(filtered, sortBy, sortDir); + const total = sorted.length; + const offset = Math.max(0, Math.min(total, Math.floor(opts?.offset ?? 0))); + const defaultLimit = total === 0 ? 50 : total; + const limit = Math.max(1, Math.min(200, Math.floor(opts?.limit ?? defaultLimit))); + const jobs = sorted.slice(offset, offset + limit); + const nextOffset = offset + jobs.length; + return { + jobs, + total, + offset, + limit, + hasMore: nextOffset < total, + nextOffset: nextOffset < total ? nextOffset : null, + } satisfies CronListPageResult; + }); +} + export async function add(state: CronServiceState, input: CronJobCreate) { return await locked(state, async () => { warnIfDisabled(state, "add"); diff --git a/src/gateway/protocol/cron-validators.test.ts b/src/gateway/protocol/cron-validators.test.ts index e3e9de03e13..33df9d478e9 100644 --- a/src/gateway/protocol/cron-validators.test.ts +++ b/src/gateway/protocol/cron-validators.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { validateCronAddParams, + validateCronListParams, validateCronRemoveParams, validateCronRunParams, validateCronRunsParams, @@ -40,6 +41,21 @@ describe("cron protocol validators", () => { expect(validateCronRunParams({ jobId: "job-2", mode: "due" })).toBe(true); }); + it("accepts list paging/filter/sort params", () => { + expect( + validateCronListParams({ + includeDisabled: true, + limit: 50, + offset: 0, + query: "daily", + enabled: "all", + sortBy: "nextRunAtMs", + sortDir: "asc", + }), + ).toBe(true); + expect(validateCronListParams({ offset: -1 })).toBe(false); + }); + it("enforces runs limit minimum for id and jobId selectors", () => { expect(validateCronRunsParams({ id: "job-1", limit: 1 })).toBe(true); expect(validateCronRunsParams({ jobId: "job-2", limit: 1 })).toBe(true); @@ -53,4 +69,37 @@ describe("cron protocol validators", () => { expect(validateCronRunsParams({ jobId: "..\\job-2" })).toBe(false); expect(validateCronRunsParams({ jobId: "nested\\job-2" })).toBe(false); }); + + it("accepts runs paging/filter/sort params", () => { + expect( + validateCronRunsParams({ + id: "job-1", + limit: 50, + offset: 0, + status: "error", + query: "timeout", + sortDir: "desc", + }), + ).toBe(true); + expect(validateCronRunsParams({ id: "job-1", offset: -1 })).toBe(false); + }); + + it("accepts all-scope runs with multi-select filters", () => { + expect( + validateCronRunsParams({ + scope: "all", + limit: 25, + statuses: ["ok", "error"], + deliveryStatuses: ["delivered", "not-requested"], + query: "fail", + sortDir: "desc", + }), + ).toBe(true); + expect( + validateCronRunsParams({ + scope: "job", + statuses: [], + }), + ).toBe(false); + }); }); diff --git a/src/gateway/protocol/schema/cron.ts b/src/gateway/protocol/schema/cron.ts index 6f74ff24ea9..dae3b340d7e 100644 --- a/src/gateway/protocol/schema/cron.ts +++ b/src/gateway/protocol/schema/cron.ts @@ -26,6 +26,28 @@ const CronRunStatusSchema = Type.Union([ Type.Literal("error"), Type.Literal("skipped"), ]); +const CronSortDirSchema = Type.Union([Type.Literal("asc"), Type.Literal("desc")]); +const CronJobsEnabledFilterSchema = Type.Union([ + Type.Literal("all"), + Type.Literal("enabled"), + Type.Literal("disabled"), +]); +const CronJobsSortBySchema = Type.Union([ + Type.Literal("nextRunAtMs"), + Type.Literal("updatedAtMs"), + Type.Literal("name"), +]); +const CronRunsStatusFilterSchema = Type.Union([ + Type.Literal("all"), + Type.Literal("ok"), + Type.Literal("error"), + Type.Literal("skipped"), +]); +const CronRunsStatusValueSchema = Type.Union([ + Type.Literal("ok"), + Type.Literal("error"), + Type.Literal("skipped"), +]); const CronDeliveryStatusSchema = Type.Union([ Type.Literal("delivered"), Type.Literal("not-delivered"), @@ -65,25 +87,6 @@ const CronRunLogJobIdSchema = Type.String({ pattern: "^[^/\\\\]+$", }); -function cronRunsIdOrJobIdParams(extraFields: Record) { - return Type.Union([ - Type.Object( - { - id: CronRunLogJobIdSchema, - ...extraFields, - }, - { additionalProperties: false }, - ), - Type.Object( - { - jobId: CronRunLogJobIdSchema, - ...extraFields, - }, - { additionalProperties: false }, - ), - ]); -} - export const CronScheduleSchema = Type.Union([ Type.Object( { @@ -223,6 +226,12 @@ export const CronJobSchema = Type.Object( export const CronListParamsSchema = Type.Object( { includeDisabled: Type.Optional(Type.Boolean()), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })), + offset: Type.Optional(Type.Integer({ minimum: 0 })), + query: Type.Optional(Type.String()), + enabled: Type.Optional(CronJobsEnabledFilterSchema), + sortBy: Type.Optional(CronJobsSortBySchema), + sortDir: Type.Optional(CronSortDirSchema), }, { additionalProperties: false }, ); @@ -266,9 +275,24 @@ export const CronRunParamsSchema = cronIdOrJobIdParams({ mode: Type.Optional(Type.Union([Type.Literal("due"), Type.Literal("force")])), }); -export const CronRunsParamsSchema = cronRunsIdOrJobIdParams({ - limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 5000 })), -}); +export const CronRunsParamsSchema = Type.Object( + { + scope: Type.Optional(Type.Union([Type.Literal("job"), Type.Literal("all")])), + id: Type.Optional(CronRunLogJobIdSchema), + jobId: Type.Optional(CronRunLogJobIdSchema), + limit: Type.Optional(Type.Integer({ minimum: 1, maximum: 200 })), + offset: Type.Optional(Type.Integer({ minimum: 0 })), + statuses: Type.Optional(Type.Array(CronRunsStatusValueSchema, { minItems: 1, maxItems: 3 })), + status: Type.Optional(CronRunsStatusFilterSchema), + deliveryStatuses: Type.Optional( + Type.Array(CronDeliveryStatusSchema, { minItems: 1, maxItems: 4 }), + ), + deliveryStatus: Type.Optional(CronDeliveryStatusSchema), + query: Type.Optional(Type.String()), + sortDir: Type.Optional(CronSortDirSchema), + }, + { additionalProperties: false }, +); export const CronRunLogEntrySchema = Type.Object( { @@ -286,6 +310,21 @@ export const CronRunLogEntrySchema = Type.Object( runAtMs: Type.Optional(Type.Integer({ minimum: 0 })), durationMs: Type.Optional(Type.Integer({ minimum: 0 })), nextRunAtMs: Type.Optional(Type.Integer({ minimum: 0 })), + model: Type.Optional(Type.String()), + provider: Type.Optional(Type.String()), + usage: Type.Optional( + Type.Object( + { + input_tokens: Type.Optional(Type.Number()), + output_tokens: Type.Optional(Type.Number()), + total_tokens: Type.Optional(Type.Number()), + cache_read_tokens: Type.Optional(Type.Number()), + cache_write_tokens: Type.Optional(Type.Number()), + }, + { additionalProperties: false }, + ), + ), + jobName: Type.Optional(Type.String()), }, { additionalProperties: false }, ); diff --git a/src/gateway/server-methods/cron.ts b/src/gateway/server-methods/cron.ts index 0f73184c47c..dd6bfc42e77 100644 --- a/src/gateway/server-methods/cron.ts +++ b/src/gateway/server-methods/cron.ts @@ -1,5 +1,9 @@ import { normalizeCronJobCreate, normalizeCronJobPatch } from "../../cron/normalize.js"; -import { readCronRunLogEntries, resolveCronRunLogPath } from "../../cron/run-log.js"; +import { + readCronRunLogEntriesPage, + readCronRunLogEntriesPageAll, + resolveCronRunLogPath, +} from "../../cron/run-log.js"; import type { CronJobCreate, CronJobPatch } from "../../cron/types.js"; import { validateScheduleTimestamp } from "../../cron/validate-timestamp.js"; import { @@ -49,11 +53,25 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const p = params as { includeDisabled?: boolean }; - const jobs = await context.cron.list({ + const p = params as { + includeDisabled?: boolean; + limit?: number; + offset?: number; + query?: string; + enabled?: "all" | "enabled" | "disabled"; + sortBy?: "nextRunAtMs" | "updatedAtMs" | "name"; + sortDir?: "asc" | "desc"; + }; + const page = await context.cron.listPage({ includeDisabled: p.includeDisabled, + limit: p.limit, + offset: p.offset, + query: p.query, + enabled: p.enabled, + sortBy: p.sortBy, + sortDir: p.sortDir, }); - respond(true, { jobs }, undefined); + respond(true, page, undefined); }, "cron.status": async ({ params, respond, context }) => { if (!validateCronStatusParams(params)) { @@ -204,9 +222,23 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const p = params as { id?: string; jobId?: string; limit?: number }; + const p = params as { + scope?: "job" | "all"; + id?: string; + jobId?: string; + limit?: number; + offset?: number; + statuses?: Array<"ok" | "error" | "skipped">; + status?: "all" | "ok" | "error" | "skipped"; + deliveryStatuses?: Array<"delivered" | "not-delivered" | "unknown" | "not-requested">; + deliveryStatus?: "delivered" | "not-delivered" | "unknown" | "not-requested"; + query?: string; + sortDir?: "asc" | "desc"; + }; + const explicitScope = p.scope; const jobId = p.id ?? p.jobId; - if (!jobId) { + const scope: "job" | "all" = explicitScope ?? (jobId ? "job" : "all"); + if (scope === "job" && !jobId) { respond( false, undefined, @@ -214,11 +246,33 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } + if (scope === "all") { + const jobs = await context.cron.list({ includeDisabled: true }); + const jobNameById = Object.fromEntries( + jobs + .filter((job) => typeof job.id === "string" && typeof job.name === "string") + .map((job) => [job.id, job.name]), + ); + const page = await readCronRunLogEntriesPageAll({ + storePath: context.cronStorePath, + limit: p.limit, + offset: p.offset, + statuses: p.statuses, + status: p.status, + deliveryStatuses: p.deliveryStatuses, + deliveryStatus: p.deliveryStatus, + query: p.query, + sortDir: p.sortDir, + jobNameById, + }); + respond(true, page, undefined); + return; + } let logPath: string; try { logPath = resolveCronRunLogPath({ storePath: context.cronStorePath, - jobId, + jobId: jobId as string, }); } catch { respond( @@ -228,10 +282,17 @@ export const cronHandlers: GatewayRequestHandlers = { ); return; } - const entries = await readCronRunLogEntries(logPath, { + const page = await readCronRunLogEntriesPage(logPath, { limit: p.limit, - jobId, + offset: p.offset, + jobId: jobId as string, + statuses: p.statuses, + status: p.status, + deliveryStatuses: p.deliveryStatuses, + deliveryStatus: p.deliveryStatus, + query: p.query, + sortDir: p.sortDir, }); - respond(true, { entries }, undefined); + respond(true, page, undefined); }, }; diff --git a/src/gateway/server.cron.test.ts b/src/gateway/server.cron.test.ts index ed924f7920e..94d6afbae5e 100644 --- a/src/gateway/server.cron.test.ts +++ b/src/gateway/server.cron.test.ts @@ -424,6 +424,17 @@ describe("gateway server cron", () => { expect((entries as Array<{ deliveryStatus?: unknown }>).at(-1)?.deliveryStatus).toBe( "not-requested", ); + const allRunsRes = await rpcReq(ws, "cron.runs", { + scope: "all", + limit: 50, + statuses: ["ok"], + }); + expect(allRunsRes.ok).toBe(true); + const allEntries = (allRunsRes.payload as { entries?: unknown } | null)?.entries; + expect(Array.isArray(allEntries)).toBe(true); + expect( + (allEntries as Array<{ jobId?: unknown }>).some((entry) => entry.jobId === jobId), + ).toBe(true); const statusRes = await rpcReq(ws, "cron.status", {}); expect(statusRes.ok).toBe(true); diff --git a/test/ui.presenter-next-run.test.ts b/test/ui.presenter-next-run.test.ts new file mode 100644 index 00000000000..12c2ed4d80d --- /dev/null +++ b/test/ui.presenter-next-run.test.ts @@ -0,0 +1,17 @@ +import { describe, expect, it } from "vitest"; +import { formatNextRun } from "../ui/src/ui/presenter.ts"; + +describe("formatNextRun", () => { + it("returns n/a for nullish values", () => { + expect(formatNextRun(null)).toBe("n/a"); + expect(formatNextRun(undefined)).toBe("n/a"); + }); + + it("includes weekday and relative time", () => { + const ts = Date.UTC(2026, 1, 23, 15, 0, 0); + const out = formatNextRun(ts); + expect(out).toMatch(/^[A-Za-z]{3}, /); + expect(out).toContain("("); + expect(out).toContain(")"); + }); +}); diff --git a/ui/src/styles/components.css b/ui/src/styles/components.css index 09b89d9c270..428f5f9a9d5 100644 --- a/ui/src/styles/components.css +++ b/ui/src/styles/components.css @@ -526,6 +526,441 @@ grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); } +/* =========================================== + Cron Form + =========================================== */ + +.cron-summary-strip { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 12px 18px; + padding: 14px 16px; +} + +.cron-summary-strip__left { + display: grid; + gap: 8px 14px; + grid-template-columns: repeat(3, minmax(0, 1fr)); + flex: 1 1 auto; + min-width: 0; +} + +.cron-summary-item { + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + padding: 10px 12px; + min-height: 62px; + display: grid; + gap: 6px; +} + +.cron-summary-item--wide { + grid-column: span 1; +} + +.cron-summary-label { + color: var(--muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.cron-summary-value { + color: var(--text-strong); + font-size: 15px; + font-weight: 600; + line-height: 1.3; + display: flex; + align-items: center; + gap: 8px; +} + +.cron-summary-strip__actions { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 10px; + min-width: 0; +} + +.cron-workspace { + margin-top: 16px; + display: grid; + grid-template-columns: minmax(0, 1.2fr) minmax(340px, 0.8fr); + gap: 16px; + align-items: start; +} + +.cron-workspace-main { + display: grid; + gap: 16px; +} + +.cron-workspace-form { + position: sticky; + top: 74px; +} + +.cron-form { + margin-top: 16px; + display: grid; + gap: 14px; +} + +.cron-form-section { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 14px; + background: var(--bg-elevated); + display: grid; + gap: 12px; +} + +.cron-form-section__title { + font-size: 13px; + font-weight: 600; + letter-spacing: -0.01em; + color: var(--text-strong); +} + +.cron-form-section__sub { + color: var(--muted); + font-size: 12px; + line-height: 1.45; +} + +.cron-form-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 14px 16px; +} + +.cron-help { + color: var(--muted); + font-size: 12px; + line-height: 1.45; + margin-top: 2px; +} + +.cron-error { + color: var(--danger-color); +} + +.cron-required-legend { + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.cron-required-marker { + color: var(--danger-color); + font-weight: 700; + margin-left: 3px; +} + +.cron-required-sr { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.field input[aria-invalid="true"], +.field textarea[aria-invalid="true"], +.field select[aria-invalid="true"] { + border-color: var(--danger); + box-shadow: + inset 0 1px 0 var(--card-highlight), + 0 0 0 1px rgba(239, 68, 68, 0.2); +} + +.cron-form-status { + margin-top: 4px; + border: 1px solid var(--danger-subtle); + background: var(--danger-subtle); + border-radius: var(--radius-md); + padding: 10px 12px; +} + +.cron-form-status__title { + color: var(--text-strong); + font-size: 13px; + font-weight: 600; + margin-bottom: 6px; +} + +.cron-form-status__list { + margin: 8px 0 0; + padding: 0; + list-style: none; + display: grid; + gap: 6px; +} + +.cron-form-status__link { + border: 0; + background: transparent; + color: var(--text); + cursor: pointer; + font-size: 12px; + line-height: 1.4; + padding: 0; + text-align: left; + text-decoration: underline; + text-underline-offset: 2px; +} + +.cron-form-status__link:hover { + color: var(--text-strong); +} + +.cron-span-2 { + grid-column: 1 / -1; +} + +.cron-checkbox { + align-items: center; + grid-template-columns: 16px minmax(0, 1fr); + column-gap: 10px; +} + +.cron-checkbox input[type="checkbox"] { + margin: 2px 0 0; + width: 16px; + height: 16px; + accent-color: var(--accent); +} + +.cron-checkbox .field-checkbox__label { + color: var(--text-strong); + font-size: 13px; + font-weight: 500; +} + +.cron-checkbox .cron-help { + grid-column: 2; +} + +.cron-checkbox-inline { + align-content: start; + align-items: start; + padding-top: 28px; +} + +.cron-advanced { + border: 1px solid var(--border); + border-radius: var(--radius-md); + padding: 12px; + background: var(--bg-elevated); + display: grid; + gap: 10px; +} + +.cron-advanced__summary { + cursor: pointer; + color: var(--muted); + font-size: 13px; + font-weight: 500; +} + +.cron-stagger-group { + display: grid; + grid-template-columns: minmax(0, 1fr) 180px; + gap: 14px 16px; + align-items: start; +} + +.cron-form-actions { + margin-top: 14px; + justify-content: flex-start; + align-items: center; + gap: 10px 14px; + flex-wrap: wrap; +} + +.cron-submit-reason { + color: var(--muted); + font-size: 12px; + line-height: 1.4; +} + +.cron-filter-search { + flex: 1 1 320px; + min-width: 280px; +} + +.cron-workspace .filters .field { + min-width: 160px; +} + +.cron-run-filters { + margin-top: 12px; + display: grid; + gap: 12px; +} + +.cron-run-filters__row { + display: grid; + gap: 12px; +} + +.cron-run-filters__row--primary { + grid-template-columns: minmax(160px, 220px) minmax(240px, 1fr) minmax(160px, 220px); +} + +.cron-run-filters__row--secondary { + grid-template-columns: repeat(2, minmax(220px, 1fr)); +} + +.cron-run-filter-search { + min-width: 0; +} + +.cron-filter-dropdown { + min-width: 0; +} + +.cron-filter-dropdown__details { + position: relative; +} + +.cron-filter-dropdown__details > summary { + list-style: none; +} + +.cron-filter-dropdown__details > summary::-webkit-details-marker { + display: none; +} + +.cron-filter-dropdown__trigger { + width: 100%; + justify-content: space-between; + text-align: left; +} + +.cron-filter-dropdown__panel { + position: absolute; + z-index: 30; + top: calc(100% + 8px); + left: 0; + width: min(360px, calc(100vw - 48px)); + border: 1px solid var(--border); + border-radius: var(--radius-md); + background: var(--bg-elevated); + padding: 10px; + display: grid; + gap: 10px; + box-shadow: var(--shadow-card); +} + +.cron-filter-dropdown__list { + display: grid; + gap: 6px; +} + +.cron-filter-dropdown__option { + display: grid; + grid-template-columns: 16px minmax(0, 1fr); + gap: 8px; + align-items: center; + color: var(--text); + font-size: 13px; +} + +.cron-filter-dropdown__option input[type="checkbox"] { + width: 16px; + height: 16px; + margin: 0; + accent-color: var(--accent); +} + +.cron-run-entry { + align-items: start; +} + +.cron-run-entry__meta { + text-align: right; + min-width: 220px; +} + +.cron-run-entry__summary { + white-space: pre-wrap; + line-height: 1.45; +} + +@media (max-width: 1100px) { + .cron-summary-strip { + flex-direction: column; + } + + .cron-summary-strip__left { + grid-template-columns: repeat(2, minmax(0, 1fr)); + width: 100%; + } + + .cron-summary-strip__actions { + width: 100%; + justify-content: flex-start; + flex-wrap: wrap; + } + + .cron-workspace { + grid-template-columns: 1fr; + } + + .cron-workspace-form { + position: static; + order: -1; + } + + .cron-form-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .cron-span-2 { + grid-column: auto; + } + + .cron-checkbox-inline { + padding-top: 0; + } + + .cron-stagger-group { + grid-template-columns: 1fr; + gap: 12px; + } + + .cron-filter-search { + min-width: 0; + flex: 1 1 100%; + } + + .cron-run-filters__row--primary, + .cron-run-filters__row--secondary { + grid-template-columns: 1fr; + } + + .cron-filter-dropdown__panel { + width: 100%; + max-width: none; + position: static; + margin-top: 8px; + } + + .cron-run-entry__meta { + min-width: 0; + text-align: left; + } +} + :root[data-theme="light"] .field input, :root[data-theme="light"] .field textarea, :root[data-theme="light"] .field select { diff --git a/ui/src/ui/app-defaults.ts b/ui/src/ui/app-defaults.ts index 89bdaf11d1b..ba8edc45106 100644 --- a/ui/src/ui/app-defaults.ts +++ b/ui/src/ui/app-defaults.ts @@ -14,19 +14,27 @@ export const DEFAULT_CRON_FORM: CronFormState = { name: "", description: "", agentId: "", + clearAgent: false, enabled: true, + deleteAfterRun: true, scheduleKind: "every", scheduleAt: "", everyAmount: "30", everyUnit: "minutes", cronExpr: "0 7 * * *", cronTz: "", + scheduleExact: false, + staggerAmount: "", + staggerUnit: "seconds", sessionTarget: "isolated", wakeMode: "now", payloadKind: "agentTurn", payloadText: "", + payloadModel: "", + payloadThinking: "", deliveryMode: "announce", deliveryChannel: "last", deliveryTo: "", + deliveryBestEffort: false, timeoutSeconds: "", }; diff --git a/ui/src/ui/app-render.ts b/ui/src/ui/app-render.ts index 8e441e9dcdc..56b266fb17b 100644 --- a/ui/src/ui/app-render.ts +++ b/ui/src/ui/app-render.ts @@ -21,11 +21,21 @@ import { } from "./controllers/config.ts"; import { loadCronRuns, + loadMoreCronJobs, + loadMoreCronRuns, + reloadCronJobs, toggleCronJob, runCronJob, removeCronJob, addCronJob, + startCronEdit, + startCronClone, + cancelCronEdit, + validateCronForm, + hasCronFormErrors, normalizeCronFormState, + updateCronJobsFilter, + updateCronRunsFilter, } from "./controllers/cron.ts"; import { loadDebug, callDebugMethod } from "./controllers/debug.ts"; import { @@ -71,6 +81,43 @@ import { renderSkills } from "./views/skills.ts"; const AVATAR_DATA_RE = /^data:/i; const AVATAR_HTTP_RE = /^https?:\/\//i; +const CRON_THINKING_SUGGESTIONS = ["off", "minimal", "low", "medium", "high"]; +const CRON_TIMEZONE_SUGGESTIONS = [ + "UTC", + "America/Los_Angeles", + "America/Denver", + "America/Chicago", + "America/New_York", + "Europe/London", + "Europe/Berlin", + "Asia/Tokyo", +]; + +function isHttpUrl(value: string): boolean { + return /^https?:\/\//i.test(value.trim()); +} + +function normalizeSuggestionValue(value: unknown): string { + return typeof value === "string" ? value.trim() : ""; +} + +function uniquePreserveOrder(values: string[]): string[] { + const seen = new Set(); + const output: string[] = []; + for (const value of values) { + const normalized = value.trim(); + if (!normalized) { + continue; + } + const key = normalized.toLowerCase(); + if (seen.has(key)) { + continue; + } + seen.add(key); + output.push(normalized); + } + return output; +} function resolveAssistantAvatarUrl(state: AppViewState): string | undefined { const list = state.agentsList?.agents ?? []; @@ -106,6 +153,56 @@ export function renderApp(state: AppViewState) { state.agentsList?.defaultId ?? state.agentsList?.agents?.[0]?.id ?? null; + const cronAgentSuggestions = Array.from( + new Set( + [ + ...(state.agentsList?.agents?.map((entry) => entry.id.trim()) ?? []), + ...state.cronJobs + .map((job) => (typeof job.agentId === "string" ? job.agentId.trim() : "")) + .filter(Boolean), + ].filter(Boolean), + ), + ).toSorted((a, b) => a.localeCompare(b)); + const cronModelSuggestions = Array.from( + new Set( + [ + ...state.cronModelSuggestions, + ...state.cronJobs + .map((job) => { + if (job.payload.kind !== "agentTurn" || typeof job.payload.model !== "string") { + return ""; + } + return job.payload.model.trim(); + }) + .filter(Boolean), + ].filter(Boolean), + ), + ).toSorted((a, b) => a.localeCompare(b)); + const selectedDeliveryChannel = + state.cronForm.deliveryChannel && state.cronForm.deliveryChannel.trim() + ? state.cronForm.deliveryChannel.trim() + : "last"; + const jobToSuggestions = state.cronJobs + .map((job) => normalizeSuggestionValue(job.delivery?.to)) + .filter(Boolean); + const accountToSuggestions = ( + selectedDeliveryChannel === "last" + ? Object.values(state.channelsSnapshot?.channelAccounts ?? {}).flat() + : (state.channelsSnapshot?.channelAccounts?.[selectedDeliveryChannel] ?? []) + ) + .flatMap((account) => [ + normalizeSuggestionValue(account.accountId), + normalizeSuggestionValue(account.name), + ]) + .filter(Boolean); + const rawDeliveryToSuggestions = uniquePreserveOrder([ + ...jobToSuggestions, + ...accountToSuggestions, + ]); + const deliveryToSuggestions = + state.cronForm.deliveryMode === "webhook" + ? rawDeliveryToSuggestions.filter((value) => isHttpUrl(value)) + : rawDeliveryToSuggestions; return html`
@@ -327,11 +424,21 @@ export function renderApp(state: AppViewState) { ? renderCron({ basePath: state.basePath, loading: state.cronLoading, + jobsLoadingMore: state.cronJobsLoadingMore, status: state.cronStatus, jobs: state.cronJobs, + jobsTotal: state.cronJobsTotal, + jobsHasMore: state.cronJobsHasMore, + jobsQuery: state.cronJobsQuery, + jobsEnabledFilter: state.cronJobsEnabledFilter, + jobsSortBy: state.cronJobsSortBy, + jobsSortDir: state.cronJobsSortDir, error: state.cronError, busy: state.cronBusy, form: state.cronForm, + fieldErrors: state.cronFieldErrors, + canSubmit: !hasCronFormErrors(state.cronFieldErrors), + editingJobId: state.cronEditingJobId, channels: state.channelsSnapshot?.channelMeta?.length ? state.channelsSnapshot.channelMeta.map((entry) => entry.id) : (state.channelsSnapshot?.channelOrder ?? []), @@ -339,14 +446,50 @@ export function renderApp(state: AppViewState) { channelMeta: state.channelsSnapshot?.channelMeta ?? [], runsJobId: state.cronRunsJobId, runs: state.cronRuns, - onFormChange: (patch) => - (state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch })), + runsTotal: state.cronRunsTotal, + runsHasMore: state.cronRunsHasMore, + runsLoadingMore: state.cronRunsLoadingMore, + runsScope: state.cronRunsScope, + runsStatuses: state.cronRunsStatuses, + runsDeliveryStatuses: state.cronRunsDeliveryStatuses, + runsStatusFilter: state.cronRunsStatusFilter, + runsQuery: state.cronRunsQuery, + runsSortDir: state.cronRunsSortDir, + agentSuggestions: cronAgentSuggestions, + modelSuggestions: cronModelSuggestions, + thinkingSuggestions: CRON_THINKING_SUGGESTIONS, + timezoneSuggestions: CRON_TIMEZONE_SUGGESTIONS, + deliveryToSuggestions, + onFormChange: (patch) => { + state.cronForm = normalizeCronFormState({ ...state.cronForm, ...patch }); + state.cronFieldErrors = validateCronForm(state.cronForm); + }, onRefresh: () => state.loadCron(), onAdd: () => addCronJob(state), + onEdit: (job) => startCronEdit(state, job), + onClone: (job) => startCronClone(state, job), + onCancelEdit: () => cancelCronEdit(state), onToggle: (job, enabled) => toggleCronJob(state, job, enabled), onRun: (job) => runCronJob(state, job), onRemove: (job) => removeCronJob(state, job), - onLoadRuns: (jobId) => loadCronRuns(state, jobId), + onLoadRuns: async (jobId) => { + updateCronRunsFilter(state, { cronRunsScope: "job" }); + await loadCronRuns(state, jobId); + }, + onLoadMoreJobs: () => loadMoreCronJobs(state), + onJobsFiltersChange: async (patch) => { + updateCronJobsFilter(state, patch); + await reloadCronJobs(state); + }, + onLoadMoreRuns: () => loadMoreCronRuns(state), + onRunsFiltersChange: async (patch) => { + updateCronRunsFilter(state, patch); + if (state.cronRunsScope === "all") { + await loadCronRuns(state, null); + return; + } + await loadCronRuns(state, state.cronRunsJobId); + }, }) : nothing } diff --git a/ui/src/ui/app-settings.ts b/ui/src/ui/app-settings.ts index 7415e468e0b..5828a13ea9b 100644 --- a/ui/src/ui/app-settings.ts +++ b/ui/src/ui/app-settings.ts @@ -12,7 +12,12 @@ import { loadAgentSkills } from "./controllers/agent-skills.ts"; import { loadAgents } from "./controllers/agents.ts"; import { loadChannels } from "./controllers/channels.ts"; import { loadConfig, loadConfigSchema } from "./controllers/config.ts"; -import { loadCronJobs, loadCronStatus } from "./controllers/cron.ts"; +import { + loadCronJobs, + loadCronModelSuggestions, + loadCronRuns, + loadCronStatus, +} from "./controllers/cron.ts"; import { loadDebug } from "./controllers/debug.ts"; import { loadDevices } from "./controllers/devices.ts"; import { loadExecApprovals } from "./controllers/exec-approvals.ts"; @@ -421,9 +426,18 @@ export async function loadChannelsTab(host: SettingsHost) { } export async function loadCron(host: SettingsHost) { + const cronHost = host as unknown as OpenClawApp; await Promise.all([ loadChannels(host as unknown as OpenClawApp, false), - loadCronStatus(host as unknown as OpenClawApp), - loadCronJobs(host as unknown as OpenClawApp), + loadCronStatus(cronHost), + loadCronJobs(cronHost), + loadCronModelSuggestions(cronHost), ]); + if (cronHost.cronRunsScope === "all") { + await loadCronRuns(cronHost, null); + return; + } + if (cronHost.cronRunsJobId) { + await loadCronRuns(cronHost, cronHost.cronRunsJobId); + } } diff --git a/ui/src/ui/app-view-state.ts b/ui/src/ui/app-view-state.ts index e8fcad4de74..1dcc0abeec6 100644 --- a/ui/src/ui/app-view-state.ts +++ b/ui/src/ui/app-view-state.ts @@ -1,5 +1,6 @@ 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 { DevicePairingList } from "./controllers/devices.ts"; import type { ExecApprovalRequest } from "./controllers/exec-approval.ts"; import type { ExecApprovalsFile, ExecApprovalsSnapshot } from "./controllers/exec-approvals.ts"; @@ -17,6 +18,13 @@ import type { ConfigSnapshot, ConfigUiHints, CronJob, + CronJobsEnabledFilter, + CronJobsSortBy, + CronDeliveryStatus, + CronRunScope, + CronSortDir, + CronRunsStatusValue, + CronRunsStatusFilter, CronRunLogEntry, CronStatus, HealthSnapshot, @@ -187,12 +195,35 @@ export type AppViewState = { usageLogFilterHasTools: boolean; usageLogFilterQuery: string; cronLoading: boolean; + cronJobsLoadingMore: boolean; cronJobs: CronJob[]; + cronJobsTotal: number; + cronJobsHasMore: boolean; + cronJobsNextOffset: number | null; + cronJobsLimit: number; + cronJobsQuery: string; + cronJobsEnabledFilter: CronJobsEnabledFilter; + cronJobsSortBy: CronJobsSortBy; + cronJobsSortDir: CronSortDir; cronStatus: CronStatus | null; cronError: string | null; cronForm: CronFormState; + cronFieldErrors: CronFieldErrors; + cronEditingJobId: string | null; cronRunsJobId: string | null; + cronRunsLoadingMore: boolean; cronRuns: CronRunLogEntry[]; + cronRunsTotal: number; + cronRunsHasMore: boolean; + cronRunsNextOffset: number | null; + cronRunsLimit: number; + cronRunsScope: CronRunScope; + cronRunsStatuses: CronRunsStatusValue[]; + cronRunsDeliveryStatuses: CronDeliveryStatus[]; + cronRunsStatusFilter: CronRunsStatusFilter; + cronRunsQuery: string; + cronRunsSortDir: CronSortDir; + cronModelSuggestions: string[]; cronBusy: boolean; skillsLoading: boolean; skillsReport: SkillStatusReport | null; diff --git a/ui/src/ui/app.ts b/ui/src/ui/app.ts index 7d2cbd0e343..ae3e5e507e2 100644 --- a/ui/src/ui/app.ts +++ b/ui/src/ui/app.ts @@ -53,6 +53,7 @@ import { import type { AppViewState } from "./app-view-state.ts"; import { normalizeAssistantIdentity } from "./assistant-identity.ts"; import { loadAssistantIdentity as loadAssistantIdentityInternal } from "./controllers/assistant-identity.ts"; +import type { CronFieldErrors } 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"; @@ -297,12 +298,35 @@ export class OpenClawApp extends LitElement { usageQueryDebounceTimer: number | null = null; @state() cronLoading = false; + @state() cronJobsLoadingMore = false; @state() cronJobs: CronJob[] = []; + @state() cronJobsTotal = 0; + @state() cronJobsHasMore = false; + @state() cronJobsNextOffset: number | null = null; + @state() cronJobsLimit = 50; + @state() cronJobsQuery = ""; + @state() cronJobsEnabledFilter: import("./types.js").CronJobsEnabledFilter = "all"; + @state() cronJobsSortBy: import("./types.js").CronJobsSortBy = "nextRunAtMs"; + @state() cronJobsSortDir: import("./types.js").CronSortDir = "asc"; @state() cronStatus: CronStatus | null = null; @state() cronError: string | null = null; @state() cronForm: CronFormState = { ...DEFAULT_CRON_FORM }; + @state() cronFieldErrors: CronFieldErrors = {}; + @state() cronEditingJobId: string | null = null; @state() cronRunsJobId: string | null = null; + @state() cronRunsLoadingMore = false; @state() cronRuns: CronRunLogEntry[] = []; + @state() cronRunsTotal = 0; + @state() cronRunsHasMore = false; + @state() cronRunsNextOffset: number | null = null; + @state() cronRunsLimit = 50; + @state() cronRunsScope: import("./types.js").CronRunScope = "all"; + @state() cronRunsStatuses: import("./types.js").CronRunsStatusValue[] = []; + @state() cronRunsDeliveryStatuses: import("./types.js").CronDeliveryStatus[] = []; + @state() cronRunsStatusFilter: import("./types.js").CronRunsStatusFilter = "all"; + @state() cronRunsQuery = ""; + @state() cronRunsSortDir: import("./types.js").CronSortDir = "desc"; + @state() cronModelSuggestions: string[] = []; @state() cronBusy = false; @state() updateAvailable: import("./types.js").UpdateAvailable | null = null; diff --git a/ui/src/ui/controllers/cron.test.ts b/ui/src/ui/controllers/cron.test.ts index 66d05286a3b..ee2bab887cd 100644 --- a/ui/src/ui/controllers/cron.test.ts +++ b/ui/src/ui/controllers/cron.test.ts @@ -1,18 +1,51 @@ import { describe, expect, it, vi } from "vitest"; import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; -import { addCronJob, normalizeCronFormState, type CronState } from "./cron.ts"; +import { + addCronJob, + cancelCronEdit, + loadCronJobsPage, + loadCronRuns, + loadMoreCronRuns, + normalizeCronFormState, + startCronEdit, + startCronClone, + validateCronForm, + type CronState, +} from "./cron.ts"; function createState(overrides: Partial = {}): CronState { return { client: null, connected: true, cronLoading: false, + cronJobsLoadingMore: false, cronJobs: [], + cronJobsTotal: 0, + cronJobsHasMore: false, + cronJobsNextOffset: null, + cronJobsLimit: 50, + cronJobsQuery: "", + cronJobsEnabledFilter: "all", + cronJobsSortBy: "nextRunAtMs", + cronJobsSortDir: "asc", cronStatus: null, cronError: null, cronForm: { ...DEFAULT_CRON_FORM }, + cronFieldErrors: {}, + cronEditingJobId: null, cronRunsJobId: null, + cronRunsLoadingMore: false, cronRuns: [], + cronRunsTotal: 0, + cronRunsHasMore: false, + cronRunsNextOffset: null, + cronRunsLimit: 50, + cronRunsScope: "all", + cronRunsStatuses: [], + cronRunsDeliveryStatuses: [], + cronRunsStatusFilter: "all", + cronRunsQuery: "", + cronRunsSortDir: "desc", cronBusy: false, ...overrides, }; @@ -127,4 +160,378 @@ describe("cron controller", () => { expect((addCall?.[1] as { delivery?: unknown } | undefined)?.delivery).toBeUndefined(); expect(state.cronForm.deliveryMode).toBe("none"); }); + + it("submits cron.update when editing an existing job", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "cron.update") { + return { id: "job-1" }; + } + if (method === "cron.list") { + return { jobs: [{ id: "job-1" }] }; + } + if (method === "cron.status") { + return { enabled: true, jobs: 1, nextWakeAtMs: null }; + } + return {}; + }); + + const state = createState({ + client: { + request, + } as unknown as CronState["client"], + cronEditingJobId: "job-1", + cronForm: { + ...DEFAULT_CRON_FORM, + name: "edited job", + description: "", + clearAgent: true, + deleteAfterRun: false, + scheduleKind: "cron", + cronExpr: "0 8 * * *", + scheduleExact: true, + payloadKind: "systemEvent", + payloadText: "updated", + deliveryMode: "none", + }, + }); + + await addCronJob(state); + + const updateCall = request.mock.calls.find(([method]) => method === "cron.update"); + expect(updateCall).toBeDefined(); + expect(updateCall?.[1]).toMatchObject({ + id: "job-1", + patch: { + name: "edited job", + description: "", + agentId: null, + deleteAfterRun: false, + schedule: { kind: "cron", expr: "0 8 * * *", staggerMs: 0 }, + payload: { kind: "systemEvent", text: "updated" }, + }, + }); + expect(state.cronEditingJobId).toBeNull(); + }); + + it("maps a cron job into editable form fields", () => { + const state = createState(); + const job = { + id: "job-9", + name: "Weekly report", + description: "desc", + enabled: false, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "every" as const, everyMs: 7_200_000 }, + sessionTarget: "isolated" as const, + wakeMode: "next-heartbeat" as const, + payload: { kind: "agentTurn" as const, message: "ship it", timeoutSeconds: 45 }, + delivery: { mode: "announce" as const, channel: "telegram", to: "123" }, + state: {}, + }; + + startCronEdit(state, job); + + expect(state.cronEditingJobId).toBe("job-9"); + expect(state.cronRunsJobId).toBe("job-9"); + expect(state.cronForm.name).toBe("Weekly report"); + expect(state.cronForm.enabled).toBe(false); + expect(state.cronForm.scheduleKind).toBe("every"); + expect(state.cronForm.everyAmount).toBe("2"); + expect(state.cronForm.everyUnit).toBe("hours"); + expect(state.cronForm.payloadKind).toBe("agentTurn"); + expect(state.cronForm.payloadText).toBe("ship it"); + expect(state.cronForm.timeoutSeconds).toBe("45"); + expect(state.cronForm.deliveryMode).toBe("announce"); + expect(state.cronForm.deliveryChannel).toBe("telegram"); + expect(state.cronForm.deliveryTo).toBe("123"); + }); + + it("includes model/thinking/stagger/bestEffort in cron.update patch", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "cron.update") { + return { id: "job-2" }; + } + if (method === "cron.list") { + return { jobs: [{ id: "job-2" }] }; + } + if (method === "cron.status") { + return { enabled: true, jobs: 1, nextWakeAtMs: null }; + } + return {}; + }); + const state = createState({ + client: { request } as unknown as CronState["client"], + cronEditingJobId: "job-2", + cronForm: { + ...DEFAULT_CRON_FORM, + name: "advanced edit", + scheduleKind: "cron", + cronExpr: "0 9 * * *", + staggerAmount: "30", + staggerUnit: "seconds", + payloadKind: "agentTurn", + payloadText: "run it", + payloadModel: "opus", + payloadThinking: "low", + deliveryMode: "announce", + deliveryBestEffort: true, + }, + }); + + await addCronJob(state); + + const updateCall = request.mock.calls.find(([method]) => method === "cron.update"); + expect(updateCall).toBeDefined(); + expect(updateCall?.[1]).toMatchObject({ + id: "job-2", + patch: { + schedule: { kind: "cron", expr: "0 9 * * *", staggerMs: 30_000 }, + payload: { + kind: "agentTurn", + message: "run it", + model: "opus", + thinking: "low", + }, + delivery: { mode: "announce", bestEffort: true }, + }, + }); + }); + + it("maps cron stagger, model, thinking, and best effort into form", () => { + const state = createState(); + const job = { + id: "job-10", + name: "Advanced job", + enabled: true, + deleteAfterRun: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron" as const, expr: "0 7 * * *", tz: "UTC", staggerMs: 60_000 }, + sessionTarget: "isolated" as const, + wakeMode: "now" as const, + payload: { + kind: "agentTurn" as const, + message: "hi", + model: "opus", + thinking: "high", + }, + delivery: { mode: "announce" as const, bestEffort: true }, + state: {}, + }; + startCronEdit(state, job); + + expect(state.cronForm.deleteAfterRun).toBe(true); + expect(state.cronForm.scheduleKind).toBe("cron"); + expect(state.cronForm.scheduleExact).toBe(false); + expect(state.cronForm.staggerAmount).toBe("1"); + expect(state.cronForm.staggerUnit).toBe("minutes"); + expect(state.cronForm.payloadModel).toBe("opus"); + expect(state.cronForm.payloadThinking).toBe("high"); + expect(state.cronForm.deliveryBestEffort).toBe(true); + }); + + it("validates key cron form errors", () => { + const errors = validateCronForm({ + ...DEFAULT_CRON_FORM, + name: "", + scheduleKind: "cron", + cronExpr: "", + payloadKind: "agentTurn", + payloadText: "", + timeoutSeconds: "0", + deliveryMode: "webhook", + deliveryTo: "ftp://bad", + }); + expect(errors.name).toBeDefined(); + expect(errors.cronExpr).toBeDefined(); + expect(errors.payloadText).toBeDefined(); + expect(errors.timeoutSeconds).toBe("If set, timeout must be greater than 0 seconds."); + expect(errors.deliveryTo).toBeDefined(); + }); + + it("blocks add/update submit when validation errors exist", async () => { + const request = vi.fn(async () => ({})); + const state = createState({ + client: { request } as unknown as CronState["client"], + cronForm: { + ...DEFAULT_CRON_FORM, + name: "", + payloadText: "", + }, + }); + await addCronJob(state); + expect(request).not.toHaveBeenCalled(); + expect(state.cronFieldErrors.name).toBeDefined(); + expect(state.cronFieldErrors.payloadText).toBeDefined(); + }); + + it("canceling edit resets form to defaults and clears edit mode", () => { + const state = createState(); + const job = { + id: "job-cancel", + name: "Editable", + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron" as const, expr: "0 6 * * *" }, + sessionTarget: "isolated" as const, + wakeMode: "now" as const, + payload: { kind: "agentTurn" as const, message: "run" }, + delivery: { mode: "announce" as const, to: "123" }, + state: {}, + }; + startCronEdit(state, job); + state.cronForm.name = "changed"; + state.cronFieldErrors = { name: "Name is required." }; + + cancelCronEdit(state); + + expect(state.cronEditingJobId).toBeNull(); + expect(state.cronForm).toEqual({ ...DEFAULT_CRON_FORM }); + expect(state.cronFieldErrors).toEqual(validateCronForm(DEFAULT_CRON_FORM)); + }); + + it("cloning a job switches to create mode and applies copy naming", () => { + const state = createState({ + cronJobs: [ + { + id: "job-1", + name: "Daily ping", + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron", expr: "0 9 * * *" }, + sessionTarget: "main", + wakeMode: "next-heartbeat", + payload: { kind: "systemEvent", text: "ping" }, + state: {}, + }, + ], + cronEditingJobId: "job-1", + }); + + const sourceJob = state.cronJobs[0]; + expect(sourceJob).toBeDefined(); + if (!sourceJob) { + return; + } + startCronClone(state, sourceJob); + + expect(state.cronEditingJobId).toBeNull(); + expect(state.cronRunsJobId).toBe("job-1"); + expect(state.cronForm.name).toBe("Daily ping copy"); + expect(state.cronForm.payloadText).toBe("ping"); + }); + + it("submits cron.add after cloning", async () => { + const request = vi.fn(async (method: string, _payload?: unknown) => { + if (method === "cron.add") { + return { id: "job-new" }; + } + if (method === "cron.list") { + return { jobs: [] }; + } + if (method === "cron.status") { + return { enabled: true, jobs: 0, nextWakeAtMs: null }; + } + return {}; + }); + const sourceJob = { + id: "job-1", + name: "Daily ping", + enabled: true, + createdAtMs: 0, + updatedAtMs: 0, + schedule: { kind: "cron" as const, expr: "0 9 * * *" }, + sessionTarget: "main" as const, + wakeMode: "next-heartbeat" as const, + payload: { kind: "systemEvent" as const, text: "ping" }, + state: {}, + }; + const state = createState({ + client: { request } as unknown as CronState["client"], + cronJobs: [sourceJob], + cronEditingJobId: "job-1", + }); + + startCronClone(state, sourceJob); + await addCronJob(state); + + const addCall = request.mock.calls.find(([method]) => method === "cron.add"); + const updateCall = request.mock.calls.find(([method]) => method === "cron.update"); + expect(addCall).toBeDefined(); + expect(updateCall).toBeUndefined(); + expect((addCall?.[1] as { name?: string } | undefined)?.name).toBe("Daily ping copy"); + }); + + it("loads paged jobs with query/filter/sort params", async () => { + const request = vi.fn(async (method: string, payload?: unknown) => { + if (method === "cron.list") { + expect(payload).toMatchObject({ + limit: 50, + offset: 0, + query: "daily", + enabled: "enabled", + sortBy: "updatedAtMs", + sortDir: "desc", + }); + return { + jobs: [{ id: "job-1", name: "Daily", enabled: true }], + total: 1, + hasMore: false, + nextOffset: null, + }; + } + return {}; + }); + const state = createState({ + client: { request } as unknown as CronState["client"], + cronJobsQuery: "daily", + cronJobsEnabledFilter: "enabled", + cronJobsSortBy: "updatedAtMs", + cronJobsSortDir: "desc", + }); + + await loadCronJobsPage(state); + + expect(state.cronJobs).toHaveLength(1); + expect(state.cronJobsTotal).toBe(1); + expect(state.cronJobsHasMore).toBe(false); + }); + + it("loads and appends paged run history", async () => { + const request = vi.fn(async (method: string, payload?: unknown) => { + if (method !== "cron.runs") { + return {}; + } + const offset = (payload as { offset?: number } | undefined)?.offset ?? 0; + if (offset === 0) { + return { + entries: [{ ts: 2, jobId: "job-1", status: "ok", summary: "newest" }], + total: 2, + hasMore: true, + nextOffset: 1, + }; + } + return { + entries: [{ ts: 1, jobId: "job-1", status: "ok", summary: "older" }], + total: 2, + hasMore: false, + nextOffset: null, + }; + }); + const state = createState({ + client: { request } as unknown as CronState["client"], + }); + + await loadCronRuns(state, "job-1"); + expect(state.cronRuns).toHaveLength(1); + expect(state.cronRunsHasMore).toBe(true); + + await loadMoreCronRuns(state); + expect(state.cronRuns).toHaveLength(2); + expect(state.cronRuns[0]?.summary).toBe("newest"); + expect(state.cronRuns[1]?.summary).toBe("older"); + }); }); diff --git a/ui/src/ui/controllers/cron.ts b/ui/src/ui/controllers/cron.ts index 1a69b9c3c12..99917cce741 100644 --- a/ui/src/ui/controllers/cron.ts +++ b/ui/src/ui/controllers/cron.ts @@ -1,21 +1,78 @@ +import { DEFAULT_CRON_FORM } from "../app-defaults.ts"; import { toNumber } from "../format.ts"; import type { GatewayBrowserClient } from "../gateway.ts"; -import type { CronJob, CronRunLogEntry, CronStatus } from "../types.ts"; +import type { + CronJob, + CronDeliveryStatus, + CronJobsEnabledFilter, + CronJobsListResult, + CronJobsSortBy, + CronRunScope, + CronRunLogEntry, + CronRunsResult, + CronRunsStatusFilter, + CronRunsStatusValue, + CronSortDir, + CronStatus, +} from "../types.ts"; +import { CRON_CHANNEL_LAST } from "../ui-types.ts"; import type { CronFormState } from "../ui-types.ts"; +export type CronFieldKey = + | "name" + | "scheduleAt" + | "everyAmount" + | "cronExpr" + | "staggerAmount" + | "payloadText" + | "payloadModel" + | "payloadThinking" + | "timeoutSeconds" + | "deliveryTo"; + +export type CronFieldErrors = Partial>; + export type CronState = { client: GatewayBrowserClient | null; connected: boolean; cronLoading: boolean; + cronJobsLoadingMore: boolean; cronJobs: CronJob[]; + cronJobsTotal: number; + cronJobsHasMore: boolean; + cronJobsNextOffset: number | null; + cronJobsLimit: number; + cronJobsQuery: string; + cronJobsEnabledFilter: CronJobsEnabledFilter; + cronJobsSortBy: CronJobsSortBy; + cronJobsSortDir: CronSortDir; cronStatus: CronStatus | null; cronError: string | null; cronForm: CronFormState; + cronFieldErrors: CronFieldErrors; + cronEditingJobId: string | null; cronRunsJobId: string | null; + cronRunsLoadingMore: boolean; cronRuns: CronRunLogEntry[]; + cronRunsTotal: number; + cronRunsHasMore: boolean; + cronRunsNextOffset: number | null; + cronRunsLimit: number; + cronRunsScope: CronRunScope; + cronRunsStatuses: CronRunsStatusValue[]; + cronRunsDeliveryStatuses: CronDeliveryStatus[]; + cronRunsStatusFilter: CronRunsStatusFilter; + cronRunsQuery: string; + cronRunsSortDir: CronSortDir; cronBusy: boolean; }; +export type CronModelSuggestionsState = { + client: GatewayBrowserClient | null; + connected: boolean; + cronModelSuggestions: string[]; +}; + export function supportsAnnounceDelivery( form: Pick, ) { @@ -35,6 +92,65 @@ export function normalizeCronFormState(form: CronFormState): CronFormState { }; } +export function validateCronForm(form: CronFormState): CronFieldErrors { + const errors: CronFieldErrors = {}; + if (!form.name.trim()) { + errors.name = "Name is required."; + } + if (form.scheduleKind === "at") { + const ms = Date.parse(form.scheduleAt); + if (!Number.isFinite(ms)) { + errors.scheduleAt = "Enter a valid date/time."; + } + } else if (form.scheduleKind === "every") { + const amount = toNumber(form.everyAmount, 0); + if (amount <= 0) { + errors.everyAmount = "Interval must be greater than 0."; + } + } else { + if (!form.cronExpr.trim()) { + errors.cronExpr = "Cron expression is required."; + } + if (!form.scheduleExact) { + const staggerAmount = form.staggerAmount.trim(); + if (staggerAmount) { + const stagger = toNumber(staggerAmount, 0); + if (stagger <= 0) { + errors.staggerAmount = "Stagger must be greater than 0."; + } + } + } + } + if (!form.payloadText.trim()) { + errors.payloadText = + form.payloadKind === "systemEvent" + ? "System text is required." + : "Agent message is required."; + } + if (form.payloadKind === "agentTurn") { + const timeoutRaw = form.timeoutSeconds.trim(); + if (timeoutRaw) { + const timeout = toNumber(timeoutRaw, 0); + if (timeout <= 0) { + errors.timeoutSeconds = "If set, timeout must be greater than 0 seconds."; + } + } + } + if (form.deliveryMode === "webhook") { + const target = form.deliveryTo.trim(); + if (!target) { + errors.deliveryTo = "Webhook URL is required."; + } else if (!/^https?:\/\//i.test(target)) { + errors.deliveryTo = "Webhook URL must start with http:// or https://."; + } + } + return errors; +} + +export function hasCronFormErrors(errors: CronFieldErrors): boolean { + return Object.keys(errors).length > 0; +} + export async function loadCronStatus(state: CronState) { if (!state.client || !state.connected) { return; @@ -47,27 +163,267 @@ export async function loadCronStatus(state: CronState) { } } -export async function loadCronJobs(state: CronState) { +export async function loadCronModelSuggestions(state: CronModelSuggestionsState) { if (!state.client || !state.connected) { return; } - if (state.cronLoading) { + try { + const res = await state.client.request("models.list", {}); + const models = (res as { models?: unknown[] } | null)?.models; + if (!Array.isArray(models)) { + state.cronModelSuggestions = []; + return; + } + const ids = models + .map((entry) => { + if (!entry || typeof entry !== "object") { + return ""; + } + const id = (entry as { id?: unknown }).id; + return typeof id === "string" ? id.trim() : ""; + }) + .filter(Boolean); + state.cronModelSuggestions = Array.from(new Set(ids)).toSorted((a, b) => a.localeCompare(b)); + } catch { + state.cronModelSuggestions = []; + } +} + +export async function loadCronJobs(state: CronState) { + return await loadCronJobsPage(state, { append: false }); +} + +function normalizeCronPageMeta(params: { + totalRaw: unknown; + limitRaw: unknown; + offsetRaw: unknown; + nextOffsetRaw: unknown; + hasMoreRaw: unknown; + pageCount: number; +}) { + const total = + typeof params.totalRaw === "number" && Number.isFinite(params.totalRaw) + ? Math.max(0, Math.floor(params.totalRaw)) + : params.pageCount; + const limit = + typeof params.limitRaw === "number" && Number.isFinite(params.limitRaw) + ? Math.max(1, Math.floor(params.limitRaw)) + : Math.max(1, params.pageCount); + const offset = + typeof params.offsetRaw === "number" && Number.isFinite(params.offsetRaw) + ? Math.max(0, Math.floor(params.offsetRaw)) + : 0; + const hasMore = + typeof params.hasMoreRaw === "boolean" + ? params.hasMoreRaw + : offset + params.pageCount < Math.max(total, offset + params.pageCount); + const nextOffset = + typeof params.nextOffsetRaw === "number" && Number.isFinite(params.nextOffsetRaw) + ? Math.max(0, Math.floor(params.nextOffsetRaw)) + : hasMore + ? offset + params.pageCount + : null; + return { total, limit, offset, hasMore, nextOffset }; +} + +export async function loadCronJobsPage(state: CronState, opts?: { append?: boolean }) { + if (!state.client || !state.connected) { return; } - state.cronLoading = true; + if (state.cronLoading || state.cronJobsLoadingMore) { + return; + } + const append = opts?.append === true; + if (append) { + if (!state.cronJobsHasMore) { + return; + } + state.cronJobsLoadingMore = true; + } else { + state.cronLoading = true; + } state.cronError = null; try { - const res = await state.client.request<{ jobs?: Array }>("cron.list", { - includeDisabled: true, + const offset = append ? Math.max(0, state.cronJobsNextOffset ?? state.cronJobs.length) : 0; + const res = await state.client.request("cron.list", { + includeDisabled: state.cronJobsEnabledFilter === "all", + limit: state.cronJobsLimit, + offset, + query: state.cronJobsQuery.trim() || undefined, + enabled: state.cronJobsEnabledFilter, + sortBy: state.cronJobsSortBy, + sortDir: state.cronJobsSortDir, }); - state.cronJobs = Array.isArray(res.jobs) ? res.jobs : []; + const jobs = Array.isArray(res.jobs) ? res.jobs : []; + state.cronJobs = append ? [...state.cronJobs, ...jobs] : jobs; + const meta = normalizeCronPageMeta({ + totalRaw: res.total, + limitRaw: res.limit, + offsetRaw: res.offset, + nextOffsetRaw: res.nextOffset, + hasMoreRaw: res.hasMore, + pageCount: jobs.length, + }); + state.cronJobsTotal = Math.max(meta.total, state.cronJobs.length); + state.cronJobsHasMore = meta.hasMore; + state.cronJobsNextOffset = meta.nextOffset; + if ( + state.cronEditingJobId && + !state.cronJobs.some((job) => job.id === state.cronEditingJobId) + ) { + clearCronEditState(state); + } } catch (err) { state.cronError = String(err); } finally { - state.cronLoading = false; + if (append) { + state.cronJobsLoadingMore = false; + } else { + state.cronLoading = false; + } } } +export async function loadMoreCronJobs(state: CronState) { + await loadCronJobsPage(state, { append: true }); +} + +export async function reloadCronJobs(state: CronState) { + await loadCronJobsPage(state, { append: false }); +} + +export function updateCronJobsFilter( + state: CronState, + patch: Partial< + Pick< + CronState, + "cronJobsQuery" | "cronJobsEnabledFilter" | "cronJobsSortBy" | "cronJobsSortDir" + > + >, +) { + if (typeof patch.cronJobsQuery === "string") { + state.cronJobsQuery = patch.cronJobsQuery; + } + if (patch.cronJobsEnabledFilter) { + state.cronJobsEnabledFilter = patch.cronJobsEnabledFilter; + } + if (patch.cronJobsSortBy) { + state.cronJobsSortBy = patch.cronJobsSortBy; + } + if (patch.cronJobsSortDir) { + state.cronJobsSortDir = patch.cronJobsSortDir; + } +} + +function clearCronEditState(state: CronState) { + state.cronEditingJobId = null; +} + +function resetCronFormToDefaults(state: CronState) { + state.cronForm = { ...DEFAULT_CRON_FORM }; + state.cronFieldErrors = validateCronForm(state.cronForm); +} + +function formatDateTimeLocal(input: string): string { + const ms = Date.parse(input); + if (!Number.isFinite(ms)) { + return ""; + } + const date = new Date(ms); + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + const hour = String(date.getHours()).padStart(2, "0"); + const minute = String(date.getMinutes()).padStart(2, "0"); + return `${year}-${month}-${day}T${hour}:${minute}`; +} + +function parseEverySchedule(everyMs: number): Pick { + if (everyMs % 86_400_000 === 0) { + return { everyAmount: String(Math.max(1, everyMs / 86_400_000)), everyUnit: "days" }; + } + if (everyMs % 3_600_000 === 0) { + return { everyAmount: String(Math.max(1, everyMs / 3_600_000)), everyUnit: "hours" }; + } + const minutes = Math.max(1, Math.ceil(everyMs / 60_000)); + return { everyAmount: String(minutes), everyUnit: "minutes" }; +} + +function parseStaggerSchedule( + staggerMs?: number, +): Pick { + if (staggerMs === 0) { + return { scheduleExact: true, staggerAmount: "", staggerUnit: "seconds" }; + } + if (typeof staggerMs !== "number" || !Number.isFinite(staggerMs) || staggerMs < 0) { + return { scheduleExact: false, staggerAmount: "", staggerUnit: "seconds" }; + } + if (staggerMs % 60_000 === 0) { + return { + scheduleExact: false, + staggerAmount: String(Math.max(1, staggerMs / 60_000)), + staggerUnit: "minutes", + }; + } + return { + scheduleExact: false, + staggerAmount: String(Math.max(1, Math.ceil(staggerMs / 1_000))), + staggerUnit: "seconds", + }; +} + +function jobToForm(job: CronJob, prev: CronFormState): CronFormState { + const next: CronFormState = { + ...prev, + name: job.name, + description: job.description ?? "", + agentId: job.agentId ?? "", + clearAgent: false, + enabled: job.enabled, + deleteAfterRun: job.deleteAfterRun ?? false, + scheduleKind: job.schedule.kind, + scheduleAt: "", + everyAmount: prev.everyAmount, + everyUnit: prev.everyUnit, + cronExpr: prev.cronExpr, + cronTz: "", + scheduleExact: false, + staggerAmount: "", + staggerUnit: "seconds", + sessionTarget: job.sessionTarget, + wakeMode: job.wakeMode, + payloadKind: job.payload.kind, + payloadText: job.payload.kind === "systemEvent" ? job.payload.text : job.payload.message, + payloadModel: job.payload.kind === "agentTurn" ? (job.payload.model ?? "") : "", + payloadThinking: job.payload.kind === "agentTurn" ? (job.payload.thinking ?? "") : "", + deliveryMode: job.delivery?.mode ?? "none", + deliveryChannel: job.delivery?.channel ?? CRON_CHANNEL_LAST, + deliveryTo: job.delivery?.to ?? "", + deliveryBestEffort: job.delivery?.bestEffort ?? false, + timeoutSeconds: + job.payload.kind === "agentTurn" && typeof job.payload.timeoutSeconds === "number" + ? String(job.payload.timeoutSeconds) + : "", + }; + + if (job.schedule.kind === "at") { + next.scheduleAt = formatDateTimeLocal(job.schedule.at); + } else if (job.schedule.kind === "every") { + const parsed = parseEverySchedule(job.schedule.everyMs); + next.everyAmount = parsed.everyAmount; + next.everyUnit = parsed.everyUnit; + } else { + next.cronExpr = job.schedule.expr; + next.cronTz = job.schedule.tz ?? ""; + const staggerFields = parseStaggerSchedule(job.schedule.staggerMs); + next.scheduleExact = staggerFields.scheduleExact; + next.staggerAmount = staggerFields.staggerAmount; + next.staggerUnit = staggerFields.staggerUnit; + } + + return normalizeCronFormState(next); +} + export function buildCronSchedule(form: CronFormState) { if (form.scheduleKind === "at") { const ms = Date.parse(form.scheduleAt); @@ -89,7 +445,19 @@ export function buildCronSchedule(form: CronFormState) { if (!expr) { throw new Error("Cron expression required."); } - return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined }; + if (form.scheduleExact) { + return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs: 0 }; + } + const staggerAmount = form.staggerAmount.trim(); + if (!staggerAmount) { + return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined }; + } + const staggerValue = toNumber(staggerAmount, 0); + if (staggerValue <= 0) { + throw new Error("Invalid stagger amount."); + } + const staggerMs = form.staggerUnit === "minutes" ? staggerValue * 60_000 : staggerValue * 1_000; + return { kind: "cron" as const, expr, tz: form.cronTz.trim() || undefined, staggerMs }; } export function buildCronPayload(form: CronFormState) { @@ -107,8 +475,18 @@ export function buildCronPayload(form: CronFormState) { const payload: { kind: "agentTurn"; message: string; + model?: string; + thinking?: string; timeoutSeconds?: number; } = { kind: "agentTurn", message }; + const model = form.payloadModel.trim(); + if (model) { + payload.model = model; + } + const thinking = form.payloadThinking.trim(); + if (thinking) { + payload.thinking = thinking; + } const timeoutSeconds = toNumber(form.timeoutSeconds, 0); if (timeoutSeconds > 0) { payload.timeoutSeconds = timeoutSeconds; @@ -127,6 +505,11 @@ export async function addCronJob(state: CronState) { if (form !== state.cronForm) { state.cronForm = form; } + const fieldErrors = validateCronForm(form); + state.cronFieldErrors = fieldErrors; + if (hasCronFormErrors(fieldErrors)) { + return; + } const schedule = buildCronSchedule(form); const payload = buildCronPayload(form); @@ -140,14 +523,16 @@ export async function addCronJob(state: CronState) { ? form.deliveryChannel.trim() || "last" : undefined, to: form.deliveryTo.trim() || undefined, + bestEffort: form.deliveryBestEffort, } : undefined; - const agentId = form.agentId.trim(); + const agentId = form.clearAgent ? null : form.agentId.trim(); const job = { name: form.name.trim(), - description: form.description.trim() || undefined, - agentId: agentId || undefined, + description: form.description.trim(), + agentId: agentId === null ? null : agentId || undefined, enabled: form.enabled, + deleteAfterRun: form.deleteAfterRun, schedule, sessionTarget: form.sessionTarget, wakeMode: form.wakeMode, @@ -157,13 +542,16 @@ export async function addCronJob(state: CronState) { if (!job.name) { throw new Error("Name required."); } - await state.client.request("cron.add", job); - state.cronForm = { - ...state.cronForm, - name: "", - description: "", - payloadText: "", - }; + if (state.cronEditingJobId) { + await state.client.request("cron.update", { + id: state.cronEditingJobId, + patch: job, + }); + clearCronEditState(state); + } else { + await state.client.request("cron.add", job); + resetCronFormToDefaults(state); + } await loadCronJobs(state); await loadCronStatus(state); } catch (err) { @@ -198,7 +586,11 @@ export async function runCronJob(state: CronState, job: CronJob) { state.cronError = null; try { await state.client.request("cron.run", { id: job.id, mode: "force" }); - await loadCronRuns(state, job.id); + if (state.cronRunsScope === "all") { + await loadCronRuns(state, null); + } else { + await loadCronRuns(state, job.id); + } } catch (err) { state.cronError = String(err); } finally { @@ -214,9 +606,15 @@ export async function removeCronJob(state: CronState, job: CronJob) { state.cronError = null; try { await state.client.request("cron.remove", { id: job.id }); + if (state.cronEditingJobId === job.id) { + clearCronEditState(state); + } if (state.cronRunsJobId === job.id) { state.cronRunsJobId = null; state.cronRuns = []; + state.cronRunsTotal = 0; + state.cronRunsHasMore = false; + state.cronRunsNextOffset = null; } await loadCronJobs(state); await loadCronStatus(state); @@ -227,18 +625,152 @@ export async function removeCronJob(state: CronState, job: CronJob) { } } -export async function loadCronRuns(state: CronState, jobId: string) { +export async function loadCronRuns( + state: CronState, + jobId: string | null, + opts?: { append?: boolean }, +) { if (!state.client || !state.connected) { return; } + const scope = state.cronRunsScope; + const activeJobId = jobId ?? state.cronRunsJobId; + if (scope === "job" && !activeJobId) { + state.cronRuns = []; + state.cronRunsTotal = 0; + state.cronRunsHasMore = false; + state.cronRunsNextOffset = null; + return; + } + const append = opts?.append === true; + if (append && !state.cronRunsHasMore) { + return; + } try { - const res = await state.client.request<{ entries?: Array }>("cron.runs", { - id: jobId, - limit: 50, + if (append) { + state.cronRunsLoadingMore = true; + } + const offset = append ? Math.max(0, state.cronRunsNextOffset ?? state.cronRuns.length) : 0; + const res = await state.client.request("cron.runs", { + scope, + id: scope === "job" ? (activeJobId ?? undefined) : undefined, + limit: state.cronRunsLimit, + offset, + statuses: state.cronRunsStatuses.length > 0 ? state.cronRunsStatuses : undefined, + status: state.cronRunsStatusFilter, + deliveryStatuses: + state.cronRunsDeliveryStatuses.length > 0 ? state.cronRunsDeliveryStatuses : undefined, + query: state.cronRunsQuery.trim() || undefined, + sortDir: state.cronRunsSortDir, }); - state.cronRunsJobId = jobId; - state.cronRuns = Array.isArray(res.entries) ? res.entries : []; + const entries = Array.isArray(res.entries) ? res.entries : []; + state.cronRuns = + append && (scope === "all" || state.cronRunsJobId === activeJobId) + ? [...state.cronRuns, ...entries] + : entries; + if (scope === "job") { + state.cronRunsJobId = activeJobId ?? null; + } + const meta = normalizeCronPageMeta({ + totalRaw: res.total, + limitRaw: res.limit, + offsetRaw: res.offset, + nextOffsetRaw: res.nextOffset, + hasMoreRaw: res.hasMore, + pageCount: entries.length, + }); + state.cronRunsTotal = Math.max(meta.total, state.cronRuns.length); + state.cronRunsHasMore = meta.hasMore; + state.cronRunsNextOffset = meta.nextOffset; } catch (err) { state.cronError = String(err); + } finally { + if (append) { + state.cronRunsLoadingMore = false; + } } } + +export async function loadMoreCronRuns(state: CronState) { + if (state.cronRunsScope === "job" && !state.cronRunsJobId) { + return; + } + await loadCronRuns(state, state.cronRunsJobId, { append: true }); +} + +export function updateCronRunsFilter( + state: CronState, + patch: Partial< + Pick< + CronState, + | "cronRunsScope" + | "cronRunsStatuses" + | "cronRunsDeliveryStatuses" + | "cronRunsStatusFilter" + | "cronRunsQuery" + | "cronRunsSortDir" + > + >, +) { + if (patch.cronRunsScope) { + state.cronRunsScope = patch.cronRunsScope; + } + if (Array.isArray(patch.cronRunsStatuses)) { + state.cronRunsStatuses = patch.cronRunsStatuses; + state.cronRunsStatusFilter = + patch.cronRunsStatuses.length === 1 ? patch.cronRunsStatuses[0] : "all"; + } + if (Array.isArray(patch.cronRunsDeliveryStatuses)) { + state.cronRunsDeliveryStatuses = patch.cronRunsDeliveryStatuses; + } + if (patch.cronRunsStatusFilter) { + state.cronRunsStatusFilter = patch.cronRunsStatusFilter; + state.cronRunsStatuses = + patch.cronRunsStatusFilter === "all" ? [] : [patch.cronRunsStatusFilter]; + } + if (typeof patch.cronRunsQuery === "string") { + state.cronRunsQuery = patch.cronRunsQuery; + } + if (patch.cronRunsSortDir) { + state.cronRunsSortDir = patch.cronRunsSortDir; + } +} + +export function startCronEdit(state: CronState, job: CronJob) { + state.cronEditingJobId = job.id; + state.cronRunsJobId = job.id; + state.cronForm = jobToForm(job, state.cronForm); + state.cronFieldErrors = validateCronForm(state.cronForm); +} + +function buildCloneName(name: string, existingNames: Set) { + const base = name.trim() || "Job"; + const first = `${base} copy`; + if (!existingNames.has(first.toLowerCase())) { + return first; + } + let index = 2; + while (index < 1000) { + const next = `${base} copy ${index}`; + if (!existingNames.has(next.toLowerCase())) { + return next; + } + index += 1; + } + return `${base} copy ${Date.now()}`; +} + +export function startCronClone(state: CronState, job: CronJob) { + clearCronEditState(state); + state.cronRunsJobId = job.id; + const existingNames = new Set(state.cronJobs.map((entry) => entry.name.trim().toLowerCase())); + const cloned = jobToForm(job, state.cronForm); + cloned.name = buildCloneName(job.name, existingNames); + state.cronForm = cloned; + state.cronFieldErrors = validateCronForm(state.cronForm); +} + +export function cancelCronEdit(state: CronState) { + clearCronEditState(state); + resetCronFormToDefaults(state); +} diff --git a/ui/src/ui/presenter.ts b/ui/src/ui/presenter.ts index dbeaa687336..6f0fdc0ad4b 100644 --- a/ui/src/ui/presenter.ts +++ b/ui/src/ui/presenter.ts @@ -18,7 +18,8 @@ export function formatNextRun(ms?: number | null) { if (!ms) { return "n/a"; } - return `${formatMs(ms)} (${formatRelativeTimestamp(ms)})`; + const weekday = new Date(ms).toLocaleDateString(undefined, { weekday: "short" }); + return `${weekday}, ${formatMs(ms)} (${formatRelativeTimestamp(ms)})`; } export function formatSessionTokens(row: GatewaySessionRow) { diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 4413c23a58e..012d1cc236d 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -440,7 +440,7 @@ export type { export type CronSchedule = | { kind: "at"; at: string } | { kind: "every"; everyMs: number; anchorMs?: number } - | { kind: "cron"; expr: string; tz?: string }; + | { kind: "cron"; expr: string; tz?: string; staggerMs?: number }; export type CronSessionTarget = "main" | "isolated"; export type CronWakeMode = "next-heartbeat" | "now"; @@ -450,6 +450,7 @@ export type CronPayload = | { kind: "agentTurn"; message: string; + model?: string; thinking?: string; timeoutSeconds?: number; }; @@ -493,17 +494,58 @@ export type CronStatus = { nextWakeAtMs?: number | null; }; +export type CronJobsEnabledFilter = "all" | "enabled" | "disabled"; +export type CronJobsSortBy = "nextRunAtMs" | "updatedAtMs" | "name"; +export type CronSortDir = "asc" | "desc"; +export type CronRunsStatusFilter = "all" | "ok" | "error" | "skipped"; +export type CronRunsStatusValue = "ok" | "error" | "skipped"; +export type CronDeliveryStatus = "delivered" | "not-delivered" | "unknown" | "not-requested"; +export type CronRunScope = "job" | "all"; + export type CronRunLogEntry = { ts: number; jobId: string; - status: "ok" | "error" | "skipped"; + jobName?: string; + status?: CronRunsStatusValue; durationMs?: number; error?: string; summary?: string; + deliveryStatus?: CronDeliveryStatus; + deliveryError?: string; + delivered?: boolean; + runAtMs?: number; + nextRunAtMs?: number; + model?: string; + provider?: string; + usage?: { + input_tokens?: number; + output_tokens?: number; + total_tokens?: number; + cache_read_tokens?: number; + cache_write_tokens?: number; + }; sessionId?: string; sessionKey?: string; }; +export type CronJobsListResult = { + jobs?: CronJob[]; + total?: number; + offset?: number; + limit?: number; + hasMore?: boolean; + nextOffset?: number | null; +}; + +export type CronRunsResult = { + entries?: CronRunLogEntry[]; + total?: number; + offset?: number; + limit?: number; + hasMore?: boolean; + nextOffset?: number | null; +}; + export type SkillsStatusConfigCheck = { path: string; satisfied: boolean; diff --git a/ui/src/ui/ui-types.ts b/ui/src/ui/ui-types.ts index 724f2a92009..f1087546c79 100644 --- a/ui/src/ui/ui-types.ts +++ b/ui/src/ui/ui-types.ts @@ -18,19 +18,27 @@ export type CronFormState = { name: string; description: string; agentId: string; + clearAgent: boolean; enabled: boolean; + deleteAfterRun: boolean; scheduleKind: "at" | "every" | "cron"; scheduleAt: string; everyAmount: string; everyUnit: "minutes" | "hours" | "days"; cronExpr: string; cronTz: string; + scheduleExact: boolean; + staggerAmount: string; + staggerUnit: "seconds" | "minutes"; sessionTarget: "main" | "isolated"; wakeMode: "next-heartbeat" | "now"; payloadKind: "systemEvent" | "agentTurn"; payloadText: string; + payloadModel: string; + payloadThinking: string; deliveryMode: "none" | "announce" | "webhook"; deliveryChannel: string; deliveryTo: string; + deliveryBestEffort: boolean; timeoutSeconds: string; }; diff --git a/ui/src/ui/views/cron.test.ts b/ui/src/ui/views/cron.test.ts index 839566151cd..b09100494f7 100644 --- a/ui/src/ui/views/cron.test.ts +++ b/ui/src/ui/views/cron.test.ts @@ -22,32 +22,93 @@ function createProps(overrides: Partial = {}): CronProps { return { basePath: "", loading: false, + jobsLoadingMore: false, status: null, jobs: [], + jobsTotal: 0, + jobsHasMore: false, + jobsQuery: "", + jobsEnabledFilter: "all", + jobsSortBy: "nextRunAtMs", + jobsSortDir: "asc", error: null, busy: false, form: { ...DEFAULT_CRON_FORM }, + fieldErrors: {}, + canSubmit: true, + editingJobId: null, channels: [], channelLabels: {}, runsJobId: null, runs: [], + runsTotal: 0, + runsHasMore: false, + runsLoadingMore: false, + runsScope: "all", + runsStatuses: [], + runsDeliveryStatuses: [], + runsStatusFilter: "all", + runsQuery: "", + runsSortDir: "desc", + agentSuggestions: [], + modelSuggestions: [], + thinkingSuggestions: [], + timezoneSuggestions: [], + deliveryToSuggestions: [], onFormChange: () => undefined, onRefresh: () => undefined, onAdd: () => undefined, + onEdit: () => undefined, + onClone: () => undefined, + onCancelEdit: () => undefined, onToggle: () => undefined, onRun: () => undefined, onRemove: () => undefined, onLoadRuns: () => undefined, + onLoadMoreJobs: () => undefined, + onJobsFiltersChange: () => undefined, + onLoadMoreRuns: () => undefined, + onRunsFiltersChange: () => undefined, ...overrides, }; } describe("cron view", () => { - it("prompts to select a job before showing run history", () => { + it("shows all-job history mode by default", () => { const container = document.createElement("div"); render(renderCron(createProps()), container); - expect(container.textContent).toContain("Select a job to inspect run history."); + expect(container.textContent).toContain("Latest runs across all jobs."); + expect(container.textContent).toContain("Status"); + expect(container.textContent).toContain("All statuses"); + expect(container.textContent).toContain("Delivery"); + expect(container.textContent).toContain("All delivery"); + expect(container.textContent).not.toContain("multi-select"); + }); + + it("toggles run status filter via dropdown checkboxes", () => { + const container = document.createElement("div"); + const onRunsFiltersChange = vi.fn(); + render( + renderCron( + createProps({ + onRunsFiltersChange, + }), + ), + container, + ); + + const statusOk = container.querySelector( + '.cron-filter-dropdown[data-filter="status"] input[value="ok"]', + ); + expect(statusOk).not.toBeNull(); + if (!(statusOk instanceof HTMLInputElement)) { + return; + } + statusOk.checked = true; + statusOk.dispatchEvent(new Event("change", { bubbles: true })); + + expect(onRunsFiltersChange).toHaveBeenCalledWith({ cronRunsStatuses: ["ok"] }); }); it("loads run history when clicking a job row", () => { @@ -80,6 +141,7 @@ describe("cron view", () => { createProps({ jobs: [job], runsJobId: "job-1", + runsScope: "job", onLoadRuns, }), ), @@ -135,6 +197,7 @@ describe("cron view", () => { createProps({ jobs: [job], runsJobId: "job-1", + runsScope: "job", runs: [ { ts: 1, jobId: "job-1", status: "ok", summary: "older run" }, { ts: 2, jobId: "job-1", status: "ok", summary: "newer run" }, @@ -159,6 +222,30 @@ describe("cron view", () => { expect(summaries[1]).toBe("older run"); }); + it("labels past nextRunAtMs as due instead of next", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + runsScope: "all", + runs: [ + { + ts: Date.now(), + jobId: "job-1", + status: "ok", + summary: "done", + nextRunAtMs: Date.now() - 13 * 60_000, + }, + ], + }), + ), + container, + ); + + expect(container.textContent).toContain("Due"); + expect(container.textContent).not.toContain("Next 13"); + }); + it("shows webhook delivery option in the form", () => { const container = document.createElement("div"); render( @@ -198,7 +285,7 @@ describe("cron view", () => { expect(options).not.toContain("Announce summary (default)"); expect(options).toContain("Webhook POST"); expect(options).toContain("None (internal)"); - expect(container.querySelector('input[placeholder="https://example.invalid/cron"]')).toBeNull(); + expect(container.querySelector('input[placeholder="https://example.com/cron"]')).toBeNull(); }); it("shows webhook delivery details for jobs", () => { @@ -222,4 +309,346 @@ describe("cron view", () => { expect(container.textContent).toContain("webhook"); expect(container.textContent).toContain("https://example.invalid/cron"); }); + + it("wires the Edit action and shows save/cancel controls when editing", () => { + const container = document.createElement("div"); + const onEdit = vi.fn(); + const onLoadRuns = vi.fn(); + const onCancelEdit = vi.fn(); + const job = createJob("job-3"); + + render( + renderCron( + createProps({ + jobs: [job], + editingJobId: "job-3", + onEdit, + onLoadRuns, + onCancelEdit, + }), + ), + container, + ); + + const editButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Edit", + ); + expect(editButton).not.toBeUndefined(); + editButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onEdit).toHaveBeenCalledWith(job); + expect(onLoadRuns).toHaveBeenCalledWith("job-3"); + + expect(container.textContent).toContain("Edit Job"); + expect(container.textContent).toContain("Save changes"); + + const cancelButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Cancel", + ); + expect(cancelButton).not.toBeUndefined(); + cancelButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onCancelEdit).toHaveBeenCalledTimes(1); + }); + + it("renders advanced controls for cron + agent payload + delivery", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "cron", + payloadKind: "agentTurn", + deliveryMode: "announce", + }, + }), + ), + container, + ); + + expect(container.textContent).toContain("Advanced"); + expect(container.textContent).toContain("Exact timing (no stagger)"); + expect(container.textContent).toContain("Stagger window"); + expect(container.textContent).toContain("Model"); + expect(container.textContent).toContain("Thinking"); + expect(container.textContent).toContain("Best effort delivery"); + }); + + it("groups stagger window and unit inside the same stagger row", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "cron", + payloadKind: "agentTurn", + }, + }), + ), + container, + ); + + const staggerGroup = container.querySelector(".cron-stagger-group"); + expect(staggerGroup).not.toBeNull(); + expect(staggerGroup?.textContent).toContain("Stagger window"); + expect(staggerGroup?.textContent).toContain("Stagger unit"); + }); + + it("explains timeout blank behavior and shows cron jitter hint", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "cron", + payloadKind: "agentTurn", + }, + }), + ), + container, + ); + + expect(container.textContent).toContain( + "Optional. Leave blank to use the gateway default timeout behavior for this run.", + ); + expect(container.textContent).toContain("Need jitter? Use Advanced"); + }); + + it("disables Agent ID when clear-agent is enabled", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + clearAgent: true, + }, + }), + ), + container, + ); + + const agentInput = container.querySelector('input[placeholder="main or ops"]'); + expect(agentInput).not.toBeNull(); + expect(agentInput instanceof HTMLInputElement).toBe(true); + expect(agentInput instanceof HTMLInputElement ? agentInput.disabled : false).toBe(true); + }); + + it("renders sectioned cron form layout", () => { + const container = document.createElement("div"); + render(renderCron(createProps()), container); + expect(container.textContent).toContain("Enabled"); + expect(container.textContent).toContain("Jobs"); + expect(container.textContent).toContain("Next wake"); + expect(container.textContent).toContain("Basics"); + expect(container.textContent).toContain("Schedule"); + expect(container.textContent).toContain("Execution"); + expect(container.textContent).toContain("Delivery"); + expect(container.textContent).toContain("Advanced"); + }); + + it("renders checkbox fields with input first for alignment", () => { + const container = document.createElement("div"); + render(renderCron(createProps()), container); + const checkboxLabel = container.querySelector(".cron-checkbox"); + expect(checkboxLabel).not.toBeNull(); + const firstElement = checkboxLabel?.firstElementChild; + expect(firstElement?.tagName.toLowerCase()).toBe("input"); + }); + + it("hides cron-only advanced controls for non-cron schedules", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "every", + payloadKind: "systemEvent", + deliveryMode: "none", + }, + }), + ), + container, + ); + expect(container.textContent).not.toContain("Exact timing (no stagger)"); + expect(container.textContent).not.toContain("Stagger window"); + expect(container.textContent).not.toContain("Model"); + expect(container.textContent).not.toContain("Best effort delivery"); + }); + + it("renders inline validation errors and disables submit when invalid", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + name: "", + scheduleKind: "cron", + cronExpr: "", + payloadText: "", + }, + fieldErrors: { + name: "Name is required.", + cronExpr: "Cron expression is required.", + payloadText: "Agent message is required.", + }, + canSubmit: false, + }), + ), + container, + ); + + expect(container.textContent).toContain("Name is required."); + expect(container.textContent).toContain("Cron expression is required."); + expect(container.textContent).toContain("Agent message is required."); + expect(container.textContent).toContain("Can't add job yet"); + expect(container.textContent).toContain("Fix 3 fields to continue."); + + const saveButton = Array.from(container.querySelectorAll("button")).find((btn) => + ["Add job", "Save changes"].includes(btn.textContent?.trim() ?? ""), + ); + expect(saveButton).not.toBeUndefined(); + expect(saveButton?.disabled).toBe(true); + }); + + it("shows required legend and aria bindings for invalid required fields", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { + ...DEFAULT_CRON_FORM, + scheduleKind: "every", + name: "", + everyAmount: "", + payloadText: "", + }, + fieldErrors: { + name: "Name is required.", + everyAmount: "Interval must be greater than 0.", + payloadText: "Agent message is required.", + }, + canSubmit: false, + }), + ), + container, + ); + + expect(container.textContent).toContain("* Required"); + + const nameInput = container.querySelector("#cron-name"); + expect(nameInput?.getAttribute("aria-invalid")).toBe("true"); + expect(nameInput?.getAttribute("aria-describedby")).toBe("cron-error-name"); + expect(container.querySelector("#cron-error-name")?.textContent).toContain("Name is required."); + + const everyInput = container.querySelector("#cron-every-amount"); + expect(everyInput?.getAttribute("aria-invalid")).toBe("true"); + expect(everyInput?.getAttribute("aria-describedby")).toBe("cron-error-everyAmount"); + expect(container.querySelector("#cron-error-everyAmount")?.textContent).toContain( + "Interval must be greater than 0.", + ); + }); + + it("wires the Clone action from job rows", () => { + const container = document.createElement("div"); + const onClone = vi.fn(); + const onLoadRuns = vi.fn(); + const job = createJob("job-clone"); + render( + renderCron( + createProps({ + jobs: [job], + onClone, + onLoadRuns, + }), + ), + container, + ); + + const cloneButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Clone", + ); + expect(cloneButton).not.toBeUndefined(); + cloneButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + expect(onClone).toHaveBeenCalledWith(job); + expect(onLoadRuns).toHaveBeenCalledWith("job-clone"); + }); + + it("selects row when clicking Enable/Disable, Run, and Remove actions", () => { + const container = document.createElement("div"); + const onToggle = vi.fn(); + const onRun = vi.fn(); + const onRemove = vi.fn(); + const onLoadRuns = vi.fn(); + const job = createJob("job-actions"); + render( + renderCron( + createProps({ + jobs: [job], + onToggle, + onRun, + onRemove, + onLoadRuns, + }), + ), + container, + ); + + const enableButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Disable", + ); + expect(enableButton).not.toBeUndefined(); + enableButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const runButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Run", + ); + expect(runButton).not.toBeUndefined(); + runButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + const removeButton = Array.from(container.querySelectorAll("button")).find( + (btn) => btn.textContent?.trim() === "Remove", + ); + expect(removeButton).not.toBeUndefined(); + removeButton?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + + expect(onToggle).toHaveBeenCalledWith(job, false); + expect(onRun).toHaveBeenCalledWith(job); + expect(onRemove).toHaveBeenCalledWith(job); + expect(onLoadRuns).toHaveBeenCalledTimes(3); + expect(onLoadRuns).toHaveBeenNthCalledWith(1, "job-actions"); + expect(onLoadRuns).toHaveBeenNthCalledWith(2, "job-actions"); + expect(onLoadRuns).toHaveBeenNthCalledWith(3, "job-actions"); + }); + + it("renders suggestion datalists for agent/model/thinking/timezone", () => { + const container = document.createElement("div"); + render( + renderCron( + createProps({ + form: { ...DEFAULT_CRON_FORM, scheduleKind: "cron", payloadKind: "agentTurn" }, + agentSuggestions: ["main"], + modelSuggestions: ["openai/gpt-5.2"], + thinkingSuggestions: ["low"], + timezoneSuggestions: ["UTC"], + deliveryToSuggestions: ["+15551234567"], + }), + ), + container, + ); + + expect(container.querySelector("datalist#cron-agent-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-model-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-thinking-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-tz-suggestions")).not.toBeNull(); + expect(container.querySelector("datalist#cron-delivery-to-suggestions")).not.toBeNull(); + expect(container.querySelector('input[list="cron-agent-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-model-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-thinking-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-tz-suggestions"]')).not.toBeNull(); + expect(container.querySelector('input[list="cron-delivery-to-suggestions"]')).not.toBeNull(); + }); }); diff --git a/ui/src/ui/views/cron.ts b/ui/src/ui/views/cron.ts index e5cc32408ea..e84c6f9f03f 100644 --- a/ui/src/ui/views/cron.ts +++ b/ui/src/ui/views/cron.ts @@ -1,32 +1,119 @@ import { html, nothing } from "lit"; +import { ifDefined } from "lit/directives/if-defined.js"; +import type { CronFieldErrors, CronFieldKey } from "../controllers/cron.ts"; import { formatRelativeTimestamp, formatMs } from "../format.ts"; import { pathForTab } from "../navigation.ts"; import { formatCronSchedule, formatNextRun } from "../presenter.ts"; import type { ChannelUiMetaEntry, CronJob, CronRunLogEntry, CronStatus } from "../types.ts"; +import type { + CronDeliveryStatus, + CronJobsEnabledFilter, + CronRunScope, + CronRunsStatusValue, + CronJobsSortBy, + CronRunsStatusFilter, + CronSortDir, +} from "../types.ts"; import type { CronFormState } from "../ui-types.ts"; export type CronProps = { basePath: string; loading: boolean; + jobsLoadingMore: boolean; status: CronStatus | null; jobs: CronJob[]; + jobsTotal: number; + jobsHasMore: boolean; + jobsQuery: string; + jobsEnabledFilter: CronJobsEnabledFilter; + jobsSortBy: CronJobsSortBy; + jobsSortDir: CronSortDir; error: string | null; busy: boolean; form: CronFormState; + fieldErrors: CronFieldErrors; + canSubmit: boolean; + editingJobId: string | null; channels: string[]; channelLabels?: Record; channelMeta?: ChannelUiMetaEntry[]; runsJobId: string | null; runs: CronRunLogEntry[]; + runsTotal: number; + runsHasMore: boolean; + runsLoadingMore: boolean; + runsScope: CronRunScope; + runsStatuses: CronRunsStatusValue[]; + runsDeliveryStatuses: CronDeliveryStatus[]; + runsStatusFilter: CronRunsStatusFilter; + runsQuery: string; + runsSortDir: CronSortDir; + agentSuggestions: string[]; + modelSuggestions: string[]; + thinkingSuggestions: string[]; + timezoneSuggestions: string[]; + deliveryToSuggestions: string[]; onFormChange: (patch: Partial) => void; onRefresh: () => void; onAdd: () => void; + onEdit: (job: CronJob) => void; + onClone: (job: CronJob) => void; + onCancelEdit: () => void; onToggle: (job: CronJob, enabled: boolean) => void; onRun: (job: CronJob) => void; onRemove: (job: CronJob) => void; onLoadRuns: (jobId: string) => void; + onLoadMoreJobs: () => void; + onJobsFiltersChange: (patch: { + cronJobsQuery?: string; + cronJobsEnabledFilter?: CronJobsEnabledFilter; + cronJobsSortBy?: CronJobsSortBy; + cronJobsSortDir?: CronSortDir; + }) => void | Promise; + onLoadMoreRuns: () => void; + onRunsFiltersChange: (patch: { + cronRunsScope?: CronRunScope; + cronRunsStatuses?: CronRunsStatusValue[]; + cronRunsDeliveryStatuses?: CronDeliveryStatus[]; + cronRunsStatusFilter?: CronRunsStatusFilter; + cronRunsQuery?: string; + cronRunsSortDir?: CronSortDir; + }) => void | Promise; }; +const RUN_STATUS_OPTIONS: Array<{ value: CronRunsStatusValue; label: string }> = [ + { value: "ok", label: "OK" }, + { value: "error", label: "Error" }, + { value: "skipped", label: "Skipped" }, +]; + +const RUN_DELIVERY_OPTIONS: Array<{ value: CronDeliveryStatus; label: string }> = [ + { value: "delivered", label: "Delivered" }, + { value: "not-delivered", label: "Not delivered" }, + { value: "unknown", label: "Unknown" }, + { value: "not-requested", label: "Not requested" }, +]; + +function toggleSelection(selected: T[], value: T, checked: boolean): T[] { + const set = new Set(selected); + if (checked) { + set.add(value); + } else { + set.delete(value); + } + return Array.from(set); +} + +function summarizeSelection(selectedLabels: string[], allLabel: string) { + if (selectedLabels.length === 0) { + return allLabel; + } + if (selectedLabels.length <= 2) { + return selectedLabels.join(", "); + } + return `${selectedLabels[0]} +${selectedLabels.length - 1}`; +} + function buildChannelOptions(props: CronProps): string[] { const options = ["last", ...props.channels.filter(Boolean)]; const current = props.form.deliveryChannel?.trim(); @@ -54,291 +141,975 @@ function resolveChannelLabel(props: CronProps, channel: string): string { return props.channelLabels?.[channel] ?? channel; } +function renderRunFilterDropdown(params: { + id: string; + title: string; + summary: string; + options: Array<{ value: string; label: string }>; + selected: string[]; + onToggle: (value: string, checked: boolean) => void; + onClear: () => void; +}) { + return html` +
+ ${params.title} +
+ + ${params.summary} + +
+
+ ${params.options.map( + (option) => html` + + `, + )} +
+
+ +
+
+
+
+ `; +} + +function renderSuggestionList(id: string, options: string[]) { + const clean = Array.from(new Set(options.map((option) => option.trim()).filter(Boolean))); + if (clean.length === 0) { + return nothing; + } + return html` + ${clean.map((value) => html` `)} + `; +} + +type BlockingField = { + key: CronFieldKey; + label: string; + message: string; + inputId: string; +}; + +function errorIdForField(key: CronFieldKey) { + return `cron-error-${key}`; +} + +function inputIdForField(key: CronFieldKey) { + if (key === "name") { + return "cron-name"; + } + if (key === "scheduleAt") { + return "cron-schedule-at"; + } + if (key === "everyAmount") { + return "cron-every-amount"; + } + if (key === "cronExpr") { + return "cron-cron-expr"; + } + if (key === "staggerAmount") { + return "cron-stagger-amount"; + } + if (key === "payloadText") { + return "cron-payload-text"; + } + if (key === "payloadModel") { + return "cron-payload-model"; + } + if (key === "payloadThinking") { + return "cron-payload-thinking"; + } + if (key === "timeoutSeconds") { + return "cron-timeout-seconds"; + } + return "cron-delivery-to"; +} + +function fieldLabelForKey( + key: CronFieldKey, + form: CronFormState, + deliveryMode: CronFormState["deliveryMode"], +) { + if (key === "payloadText") { + return form.payloadKind === "systemEvent" ? "Main timeline message" : "Assistant task prompt"; + } + if (key === "deliveryTo") { + return deliveryMode === "webhook" ? "Webhook URL" : "To"; + } + const labels: Record = { + name: "Name", + scheduleAt: "Run at", + everyAmount: "Every", + cronExpr: "Expression", + staggerAmount: "Stagger window", + payloadText: "Payload text", + payloadModel: "Model", + payloadThinking: "Thinking", + timeoutSeconds: "Timeout (seconds)", + deliveryTo: "To", + }; + return labels[key]; +} + +function collectBlockingFields( + errors: CronFieldErrors, + form: CronFormState, + deliveryMode: CronFormState["deliveryMode"], +): BlockingField[] { + const orderedKeys: CronFieldKey[] = [ + "name", + "scheduleAt", + "everyAmount", + "cronExpr", + "staggerAmount", + "payloadText", + "payloadModel", + "payloadThinking", + "timeoutSeconds", + "deliveryTo", + ]; + const fields: BlockingField[] = []; + for (const key of orderedKeys) { + const message = errors[key]; + if (!message) { + continue; + } + fields.push({ + key, + label: fieldLabelForKey(key, form, deliveryMode), + message, + inputId: inputIdForField(key), + }); + } + return fields; +} + +function focusFormField(id: string) { + const el = document.getElementById(id); + if (!(el instanceof HTMLElement)) { + return; + } + if (typeof el.scrollIntoView === "function") { + el.scrollIntoView({ block: "center", behavior: "smooth" }); + } + el.focus(); +} + +function renderFieldLabel(text: string, required = false) { + return html` + ${text} + ${ + required + ? html` + + required + ` + : nothing + } + `; +} + export function renderCron(props: CronProps) { + const isEditing = Boolean(props.editingJobId); + const isAgentTurn = props.form.payloadKind === "agentTurn"; + const isCronSchedule = props.form.scheduleKind === "cron"; const channelOptions = buildChannelOptions(props); const selectedJob = props.runsJobId == null ? undefined : props.jobs.find((job) => job.id === props.runsJobId); - const selectedRunTitle = selectedJob?.name ?? props.runsJobId ?? "(select a job)"; - const orderedRuns = props.runs.toSorted((a, b) => b.ts - a.ts); + const selectedRunTitle = + props.runsScope === "all" + ? "all jobs" + : (selectedJob?.name ?? props.runsJobId ?? "(select a job)"); + const runs = props.runs; + const selectedStatusLabels = RUN_STATUS_OPTIONS.filter((option) => + props.runsStatuses.includes(option.value), + ).map((option) => option.label); + const selectedDeliveryLabels = RUN_DELIVERY_OPTIONS.filter((option) => + props.runsDeliveryStatuses.includes(option.value), + ).map((option) => option.label); + const statusSummary = summarizeSelection(selectedStatusLabels, "All statuses"); + const deliverySummary = summarizeSelection(selectedDeliveryLabels, "All delivery"); const supportsAnnounce = props.form.sessionTarget === "isolated" && props.form.payloadKind === "agentTurn"; const selectedDeliveryMode = 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 submitDisabledReason = + blockedByValidation && !props.canSubmit + ? `Fix ${blockingFields.length} ${blockingFields.length === 1 ? "field" : "fields"} to continue.` + : ""; return html` -
-
-
Scheduler
-
Gateway-owned cron scheduler status.
-
-
-
Enabled
-
+
+
+
+
Enabled
+
+ ${props.status ? (props.status.enabled ? "Yes" : "No") : "n/a"} -
-
-
-
Jobs
-
${props.status?.jobs ?? "n/a"}
-
-
-
Next wake
-
${formatNextRun(props.status?.nextWakeAtMs ?? null)}
+
-
- - ${props.error ? html`${props.error}` : nothing} +
+
Jobs
+
${props.status?.jobs ?? "n/a"}
+
+
+
Next wake
+
${formatNextRun(props.status?.nextWakeAtMs ?? null)}
+
+ + ${props.error ? html`${props.error}` : nothing} +
+
-
-
New Job
-
Create a scheduled wakeup or agent run.
-
- - - - - -
- ${renderScheduleFields(props)} -
- - - -
- -
- + + + +
+
+ +
+
Schedule
+
Control when this job runs.
+
+ +
+ ${renderScheduleFields(props)} +
+ +
+
Execution
+
Choose when to wake, and what this job should do.
+
+ + + ${ - supportsAnnounce + isAgentTurn ? html` - + ` : nothing } - - - - - ${ - props.form.payloadKind === "agentTurn" - ? html` - - ` - : nothing - } - ${ - selectedDeliveryMode !== "none" - ? html` - +
+ +
+ +
+
Delivery
+
Choose where run summaries are sent.
+
+ + ` : nothing } + + + +
Announce posts a summary to chat. None keeps execution internal.
+ + ${ + selectedDeliveryMode !== "none" + ? html` + + ${ + selectedDeliveryMode === "announce" + ? html` + + ` + : nothing + } + ${ + selectedDeliveryMode === "webhook" + ? renderFieldError( + props.fieldErrors.deliveryTo, + errorIdForField("deliveryTo"), + ) + : nothing + } + ` + : nothing + } +
+
+ +
+ Advanced +
+ Optional overrides for delivery guarantees, schedule jitter, and model controls. +
+
+ + + ${ + isCronSchedule + ? html` + +
+ + +
+ ` + : nothing + } + ${ + isAgentTurn + ? html` + + + ` + : nothing + } + ${ + selectedDeliveryMode !== "none" + ? html` + + ` + : nothing + } +
+
+
+ ${ + blockedByValidation + ? html` +
+
Can't add job yet
+
Fill the required fields below to enable submit.
+
    + ${blockingFields.map( + (field) => html` +
  • + +
  • + `, + )} +
+
+ ` + : nothing + } +
+ + ${ + submitDisabledReason + ? html`
${submitDisabledReason}
` + : nothing + } + ${ + isEditing + ? html` + ` : nothing }
-
- -
- + -
-
Jobs
-
All scheduled jobs stored in the gateway.
- ${ - props.jobs.length === 0 - ? html` -
No jobs yet.
- ` - : html` -
- ${props.jobs.map((job) => renderJob(job, props))} -
- ` - } -
- -
-
Run history
-
Latest runs for ${selectedRunTitle}.
- ${ - props.runsJobId == null - ? html` -
Select a job to inspect run history.
- ` - : orderedRuns.length === 0 - ? html` -
No runs yet.
- ` - : html` -
- ${orderedRuns.map((entry) => renderRun(entry, props.basePath))} -
- ` - } -
+ ${renderSuggestionList("cron-agent-suggestions", props.agentSuggestions)} + ${renderSuggestionList("cron-model-suggestions", props.modelSuggestions)} + ${renderSuggestionList("cron-thinking-suggestions", props.thinkingSuggestions)} + ${renderSuggestionList("cron-tz-suggestions", props.timezoneSuggestions)} + ${renderSuggestionList("cron-delivery-to-suggestions", props.deliveryToSuggestions)} `; } @@ -346,31 +1117,44 @@ function renderScheduleFields(props: CronProps) { const form = props.form; if (form.scheduleKind === "at") { return html` -