mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 03:11:25 +00:00
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>
This commit is contained in:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, TSchema>) {
|
||||
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 },
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user