import { useEffect, useState } from "react"; import { get } from "../api"; import { getLang, useT } from "../i18n"; /** * Graphical editor for a 5-field cron expression (minute hour day month weekday, * server time zone): every N minutes, hourly, daily, on weekdays, monthly – or a * custom expression. Shows the next runs as computed by the server. */ type Mode = "minutes" | "hourly" | "daily" | "weekly" | "monthly" | "custom"; interface State { mode: Mode; every: number; minute: number; time: string; // HH:MM days: number[]; // cron weekdays, 0 = Sunday dom: number; custom: string; } const MINUTE_STEPS = [5, 10, 15, 20, 30]; /** Monday first; cron numbers (0 = Sunday). */ const WEEK = [1, 2, 3, 4, 5, 6, 0]; const pad = (n: number) => String(n).padStart(2, "0"); const hhmm = (h: string, m: string) => `${pad(Number(h))}:${pad(Number(m))}`; function expandDays(field: string): number[] | null { const out = new Set(); for (const part of field.split(",")) { const m = part.match(/^(\d)(?:-(\d))?$/); if (!m) return null; const a = Number(m[1]); const b = m[2] ? Number(m[2]) : a; if (a > 7 || b > 7 || b < a) return null; for (let d = a; d <= b; d++) out.add(d % 7); } return [...out]; } export function parseCron(expr: string): State { const base: State = { mode: "custom", every: 15, minute: 0, time: "03:00", days: [1, 2, 3, 4, 5], dom: 1, custom: expr.trim() }; const f = expr.trim().split(/\s+/); if (f.length !== 5) return base; const [mi, h, dom, mon, dow] = f; const num = (s: string, max: number) => /^\d+$/.test(s) && Number(s) <= max; if (mon !== "*") return base; let m; if ((m = mi.match(/^\*\/(\d+)$/)) && h === "*" && dom === "*" && dow === "*" && MINUTE_STEPS.includes(Number(m[1]))) return { ...base, mode: "minutes", every: Number(m[1]) }; if (num(mi, 59) && h === "*" && dom === "*" && dow === "*") return { ...base, mode: "hourly", minute: Number(mi) }; if (!num(mi, 59) || !num(h, 23)) return base; const time = hhmm(h, mi); if (dom === "*" && dow === "*") return { ...base, mode: "daily", time }; if (dom === "*") { const days = expandDays(dow); return days?.length ? { ...base, mode: "weekly", time, days } : base; } if (num(dom, 31) && Number(dom) >= 1 && dow === "*") return { ...base, mode: "monthly", time, dom: Number(dom) }; return base; } export function buildCron(s: State): string { const [h, m] = s.time.split(":").map(Number); switch (s.mode) { case "minutes": return `*/${s.every} * * * *`; case "hourly": return `${s.minute} * * * *`; case "daily": return `${m} ${h} * * *`; case "weekly": { const days = [...s.days].sort((a, b) => a - b); return `${m} ${h} * * ${days.length ? days.join(",") : "*"}`; } case "monthly": return `${m} ${h} ${s.dom} * *`; default: return s.custom.trim(); } } const dayName = (d: number, style: "short" | "long") => { // 2023-01-01 was a Sunday. return new Date(2023, 0, 1 + d).toLocaleDateString(getLang(), { weekday: style }); }; /** "2026-11-11T06:30" (server wall time) for display, without time zone conversion. */ const fmtWall = (s: string) => { const [d, hm] = s.split("T"); const [y, mo, da] = d.split("-").map(Number); const date = new Date(y, mo - 1, da).toLocaleDateString(getLang(), { weekday: "short", day: "numeric", month: "short" }); return `${date}, ${hm}`; }; export function CronBuilder({ value, onChange }: { value: string; onChange: (cron: string) => void }) { const t = useT(); const [s, setS] = useState(() => parseCron(value)); const [preview, setPreview] = useState<{ ok: boolean; next_local?: string[]; error?: string; tz?: string }>(); // A value from outside (another automation, reset) that the builder did not produce. useEffect(() => { if (value.trim() !== buildCron(s)) setS(parseCron(value)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [value]); useEffect(() => { const expr = value.trim(); if (!expr) return setPreview(undefined); let alive = true; const h = setTimeout(() => { get(`/jobs/schedule?expr=${encodeURIComponent(expr)}`) .then((p) => alive && setPreview(p)) .catch(() => alive && setPreview(undefined)); }, 250); return () => { alive = false; clearTimeout(h); }; }, [value]); const set = (patch: Partial) => { const next = { ...s, ...patch }; // Switching to "custom" starts from the current expression. if (patch.mode === "custom" && s.mode !== "custom") next.custom = buildCron(s); setS(next); onChange(buildCron(next)); }; const modes: Mode[] = ["minutes", "hourly", "daily", "weekly", "monthly", "custom"]; return (
{modes.map((m) => ( ))}
{s.mode === "minutes" && ( )} {s.mode === "hourly" && ( )} {s.mode === "weekly" && (
{WEEK.map((d) => ( ))}
)} {s.mode === "monthly" && ( )} {(s.mode === "daily" || s.mode === "weekly" || s.mode === "monthly") && ( )} {s.mode === "custom" && ( )}
{value || "–"} {preview?.ok === false && {preview.error}} {preview?.ok && preview.next_local && preview.next_local.length > 0 && ( {t("cron.nextRuns")} {preview.next_local.slice(0, 3).map(fmtWall).join(" · ")} )} {preview?.tz && s.mode !== "minutes" && {t("cron.serverTz", { tz: preview.tz })}} {s.mode === "monthly" && s.dom > 28 && {t("cron.shortMonths")}}
); }