mc-dashboard/components/ScheduledTasks.tsx
hurkicorgi cf467b26c7 Expanded scheduled tasks + keyboard shortcuts
- scripts/run-task.sh: dispatcher for say / backup / snapshot-prune with
  safe arg handling (message stripped of CR/LF, integer-clamped keep
  count). Logs to ~/logs/mc-dashboard/tasks.log (falls back to /tmp).
- New /api/schedule/tasks GET/POST/DELETE route: stores tasks as
  crontab lines with `# mc-task:<base64(json)>` marker so the UI can
  round-trip them. Strict server-side validation:
    - Cron expression regex (5 fields, * / N / N-N / N,N / */N)
    - say message: 1–120 chars, no newlines/backticks/shell quotes
    - snapshot-prune keep: integer 1–50
    - task id: 16-hex only
  Single-quote-escaped message in the generated shell command.
- ScheduledTasks UI under ServerControls (alongside the existing single
  ScheduledRestart): pick type (Announce / Backup / Prune snapshots),
  preset schedule (daily at HH:MM or every N hours), adds with one
  click. Tasks list shows human-readable schedule + "next in Xh" hint
  computed client-side. Hover-reveal Remove action.
- Admin keyboard shortcuts: when not typing,
    r — refetch the active tab's query keys (toast feedback)
    / — focus the first input/contenteditable in the active panel
    ? — toast the shortcuts cheat sheet
  Chord-free, mirrors existing ⌘K palette and Esc handlers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:57:39 -06:00

347 lines
11 KiB
TypeScript

"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;
params: { message?: string; keep?: number };
};
type Preset =
| { kind: "daily"; hour: number; minute: number }
| { kind: "every-hours"; hours: number };
function cronFromPreset(p: Preset): string {
if (p.kind === "daily") return `${p.minute} ${p.hour} * * *`;
return `0 */${p.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 (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] = parts;
const now = new Date();
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 "daily" | "every-hours",
hour: 4,
minute: 0,
hours: 6,
});
const { data: tasks = [], isLoading } = useQuery<Task[]>({
queryKey: ["schedule-tasks"],
queryFn: () => fetch("/api/schedule/tasks").then((r) => r.json()),
staleTime: 30_000,
});
const create = useMutation({
mutationFn: async () => {
const cron = cronFromPreset(
form.preset === "daily"
? { kind: "daily", hour: form.hour, minute: form.minute }
: { kind: "every-hours", hours: form.hours }
);
const body: { type: TaskType; cron: string; label: string; params: Record<string, unknown> } = {
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: "" }));
queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] });
},
onError: (err) => toast.error("Failed to add task", { 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");
queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] });
},
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 (
<div className="space-y-3">
<div className="flex items-center justify-between gap-2 flex-wrap">
<div>
<p className="text-sm font-semibold">Scheduled Tasks</p>
<p className="text-xs text-muted-foreground">
Recurring chat messages, backups, and snapshot pruning (via crontab)
</p>
</div>
{!adding && (
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
Add task
</Button>
)}
</div>
{adding && (
<div className="rounded-lg border border-border bg-muted/40 p-3 space-y-3">
<div className="flex flex-wrap gap-2">
{(["say", "backup", "snapshot-prune"] as const).map((t) => (
<button
key={t}
onClick={() => setForm((f) => ({ ...f, type: t }))}
aria-pressed={form.type === t}
className={`text-xs font-semibold uppercase rounded px-2.5 py-1 border transition ${
form.type === t
? "border-primary/40 bg-primary/10 text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{t === "snapshot-prune" ? "prune" : t}
</button>
))}
</div>
{form.type === "say" && (
<Input
placeholder="Message to broadcast (e.g. §a[Server] Daily reset soon)"
value={form.message}
onChange={(e) => setForm((f) => ({ ...f, message: e.target.value }))}
maxLength={120}
className="h-9 text-sm"
/>
)}
{form.type === "snapshot-prune" && (
<div className="flex items-center gap-2">
<label className="text-xs text-muted-foreground">Keep latest</label>
<Input
type="number"
min={1}
max={50}
value={form.keep}
onChange={(e) =>
setForm((f) => ({ ...f, keep: parseInt(e.target.value) || 5 }))
}
className="h-9 text-sm w-20"
/>
</div>
)}
<div className="flex flex-wrap items-center gap-2">
<div className="flex gap-1">
{(["daily", "every-hours"] as const).map((p) => (
<button
key={p}
onClick={() => setForm((f) => ({ ...f, preset: p }))}
aria-pressed={form.preset === p}
className={`text-[10px] font-semibold uppercase rounded px-2 py-1 border transition ${
form.preset === p
? "border-primary/40 bg-primary/10 text-primary"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
{p === "daily" ? "Daily" : "Every N hours"}
</button>
))}
</div>
{form.preset === "daily" ? (
<>
<select
value={form.hour}
onChange={(e) =>
setForm((f) => ({ ...f, hour: parseInt(e.target.value) }))
}
className="h-9 rounded-md border border-input bg-muted px-2 text-sm"
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={i}>
{pad(i)}
</option>
))}
</select>
<span className="text-muted-foreground">:</span>
<select
value={form.minute}
onChange={(e) =>
setForm((f) => ({ ...f, minute: parseInt(e.target.value) }))
}
className="h-9 rounded-md border border-input bg-muted px-2 text-sm"
>
{[0, 15, 30, 45].map((v) => (
<option key={v} value={v}>
{pad(v)}
</option>
))}
</select>
</>
) : (
<select
value={form.hours}
onChange={(e) =>
setForm((f) => ({ ...f, hours: parseInt(e.target.value) }))
}
className="h-9 rounded-md border border-input bg-muted px-2 text-sm"
>
{[1, 2, 3, 4, 6, 8, 12].map((v) => (
<option key={v} value={v}>
every {v}h
</option>
))}
</select>
)}
</div>
<div className="flex justify-end gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setAdding(false)}
disabled={create.isPending}
>
Cancel
</Button>
<Button
size="sm"
onClick={() => create.mutate()}
disabled={!canSubmit || create.isPending}
>
{create.isPending ? "Adding..." : "Add task"}
</Button>
</div>
</div>
)}
{isLoading ? (
<p className="text-xs text-muted-foreground">Loading...</p>
) : tasks.length === 0 ? (
<p className="text-xs text-muted-foreground">No scheduled tasks yet.</p>
) : (
<ul className="space-y-1">
{tasks.map((t) => (
<li
key={t.id}
className="flex items-center justify-between gap-2 px-3 py-2 rounded-md bg-muted/50 group"
>
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-medium truncate">{t.label}</span>
<Badge variant="secondary" className="text-xs px-1.5 py-0">
{t.type}
</Badge>
</div>
<p className="text-xs text-muted-foreground">
{describeCron(t.cron)} · next {relative(nextRun(t.cron))}
</p>
</div>
<Button
size="sm"
variant="ghost"
onClick={() => remove.mutate(t.id)}
disabled={remove.isPending}
className="text-xs h-9 text-muted-foreground sm:opacity-0 sm:group-hover:opacity-100 transition shrink-0"
>
Remove
</Button>
</li>
))}
</ul>
)}
</div>
);
}