"use client"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useMemo, useState } from "react"; import { toast } from "sonner"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; type TaskType = "say" | "backup" | "snapshot-prune"; type Task = { id: string; type: TaskType; label: string; cron: string; enabled: boolean; params: { message?: string; keep?: number }; }; type Preset = "daily" | "every-hours" | "weekly"; const DOW_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; function cronFromForm(form: { preset: Preset; hour: number; minute: number; hours: number; dow: number; }): string { const { preset, hour, minute, hours, dow } = form; if (preset === "daily") return `${minute} ${hour} * * *`; if (preset === "weekly") return `${minute} ${hour} * * ${dow}`; return `0 */${hours} * * *`; } function describeCron(cron: string): string { const parts = cron.trim().split(/\s+/); if (parts.length !== 5) return cron; const [m, h, , , dow] = parts; if (dow === "*" && h.startsWith("*/")) return `every ${h.slice(2)}h`; if (/^\d+$/.test(dow) && /^\d+$/.test(h) && /^\d+$/.test(m)) { return `${DOW_LABELS[parseInt(dow) % 7]} at ${h.padStart(2, "0")}:${m.padStart(2, "0")}`; } if (dow === "*" && /^\d+$/.test(h) && /^\d+$/.test(m)) { return `daily at ${h.padStart(2, "0")}:${m.padStart(2, "0")}`; } return cron; } function nextRun(cron: string): Date | null { const parts = cron.trim().split(/\s+/); if (parts.length !== 5) return null; const [m, h, , , dow] = parts; const now = new Date(); if (/^\d+$/.test(dow) && /^\d+$/.test(h) && /^\d+$/.test(m)) { const d = new Date(now); d.setHours(parseInt(h), parseInt(m), 0, 0); const target = parseInt(dow) % 7; let diff = (target - d.getDay() + 7) % 7; if (diff === 0 && d.getTime() <= now.getTime()) diff = 7; d.setDate(d.getDate() + diff); return d; } if (/^\d+$/.test(h) && /^\d+$/.test(m)) { const d = new Date(now); d.setHours(parseInt(h), parseInt(m), 0, 0); if (d.getTime() <= now.getTime()) d.setDate(d.getDate() + 1); return d; } const hMatch = h.match(/^\*\/(\d+)$/); const mm = /^\d+$/.test(m) ? parseInt(m) : 0; if (hMatch) { const step = parseInt(hMatch[1]); const d = new Date(now); d.setMinutes(mm, 0, 0); while (d.getTime() <= now.getTime() || d.getHours() % step !== 0) { d.setHours(d.getHours() + 1); } return d; } return null; } function relative(d: Date | null): string { if (!d) return "—"; const diff = d.getTime() - Date.now(); if (diff <= 0) return "soon"; const min = Math.round(diff / 60000); if (min < 60) return `in ${min}m`; const hr = Math.floor(min / 60); const rem = min % 60; if (hr < 24) return `in ${hr}h${rem ? ` ${rem}m` : ""}`; const day = Math.floor(hr / 24); return `in ${day}d`; } export function ScheduledTasks() { const queryClient = useQueryClient(); const [adding, setAdding] = useState(false); const [form, setForm] = useState({ type: "say" as TaskType, message: "", keep: 5, preset: "daily" as Preset, hour: 4, minute: 0, hours: 6, dow: 0, }); const { data: tasks = [], isLoading } = useQuery({ queryKey: ["schedule-tasks"], queryFn: () => fetch("/api/schedule/tasks").then((r) => r.json()), staleTime: 30_000, }); const invalidate = () => queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] }); const create = useMutation({ mutationFn: async () => { const cron = cronFromForm(form); const body: { type: TaskType; cron: string; label: string; params: Record; } = { type: form.type, cron, label: "", params: {} }; if (form.type === "say") body.params.message = form.message.trim(); if (form.type === "snapshot-prune") body.params.keep = form.keep; const res = await fetch("/api/schedule/tasks", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); return data as Task; }, onSuccess: () => { toast.success("Task scheduled"); setAdding(false); setForm((f) => ({ ...f, message: "" })); invalidate(); }, onError: (err) => toast.error("Failed to add task", { description: err.message }), }); const toggle = useMutation({ mutationFn: async ({ id, enabled }: { id: string; enabled: boolean }) => { const res = await fetch("/api/schedule/tasks", { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id, enabled }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); return data as Task; }, onSuccess: (t) => { toast.success(t.enabled ? "Task enabled" : "Task disabled"); invalidate(); }, onError: (err) => toast.error("Toggle failed", { description: err.message }), }); const runNow = useMutation({ mutationFn: async (id: string) => { const res = await fetch("/api/schedule/tasks/run", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }), }); const data = await res.json(); if (!res.ok) throw new Error(data.message || data.error); return data; }, onSuccess: () => toast.success("Task ran"), onError: (err) => toast.error("Run failed", { description: err.message }), }); const remove = useMutation({ mutationFn: async (id: string) => { const res = await fetch("/api/schedule/tasks", { method: "DELETE", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ id }), }); const data = await res.json(); if (!res.ok) throw new Error(data.error); return data; }, onSuccess: () => { toast.success("Task removed"); invalidate(); }, onError: (err) => toast.error("Remove failed", { description: err.message }), }); const canSubmit = useMemo(() => { if (form.type === "say") { const re = /^[^\r\n$`\\'"]{1,120}$/; return re.test(form.message.trim()); } if (form.type === "snapshot-prune") { return form.keep >= 1 && form.keep <= 50; } return true; }, [form]); const pad = (n: number) => String(n).padStart(2, "0"); return (

Scheduled Tasks

Recurring chat, backups, and snapshot pruning via crontab

{!adding && ( )}
{adding && (
{(["say", "backup", "snapshot-prune"] as const).map((t) => ( ))}
{form.type === "say" && ( setForm((f) => ({ ...f, message: e.target.value }))} maxLength={120} className="h-9 text-sm" /> )} {form.type === "snapshot-prune" && (
setForm((f) => ({ ...f, keep: parseInt(e.target.value) || 5 })) } className="h-9 text-sm w-20" />
)}
{(["daily", "every-hours", "weekly"] as const).map((p) => ( ))}
{form.preset === "weekly" && ( )} {(form.preset === "daily" || form.preset === "weekly") && ( <> : )} {form.preset === "every-hours" && ( )}
)} {isLoading ? (

Loading...

) : tasks.length === 0 ? (

No scheduled tasks yet.

) : (
    {tasks.map((t) => (
  • {t.label} {t.type} {!t.enabled && ( disabled )}

    {describeCron(t.cron)} {t.enabled && <> · next {relative(nextRun(t.cron))}}

  • ))}
)}
); }