- Bundle analyzer: @next/bundle-analyzer wired through next.config.ts,
new `bun run analyze` script (ANALYZE=true next build).
- Scheduled tasks gain:
- enabled flag round-tripped via a #DISABLED prefix on the crontab line
(preserves the mc-task marker + payload for re-enable).
- PATCH /api/schedule/tasks to toggle enabled.
- POST /api/schedule/tasks/run to execute a task immediately via
the same buildCommand used for the cron line (60s timeout, kills
child on client abort).
- Weekly preset in the UI (day-of-week selector), broader aria-labels
on all form selects. Human-readable cron renders "Tue at 04:30"
and next-run calc accounts for weekly.
- Hover-reveal Run now / Enable / Disable / Remove actions; disabled
tasks render at 60% opacity with a "disabled" badge and no next-run.
- /api/errors: minimal append-only JSONL reporter (200ms throttle, UA
and IP captured, fields length-bounded). Falls back to a stable
path under /home/minecraft/logs/ and never fails the client on
logging errors.
- ErrorReporter client (production-only) listens to window.error and
unhandledrejection, fingerprint-dedups via a bounded in-memory set,
sends with keepalive so unloads still flush.
- app/opengraph-image.tsx: 1200x630 dynamic PNG with live status dot
(green/red), player count, address. 60s memo on the status probe.
- A11y: aria-labels on log-line count select, scheduled-restart hour
and minute selects, copy-server-address button (plus focus-visible
ring). ModManager selected-mod pill upgraded to role=button with a
real accessible name and a proper × glyph.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
451 lines
15 KiB
TypeScript
451 lines
15 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;
|
|
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<Task[]>({
|
|
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<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: "" }));
|
|
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 (
|
|
<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, 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", "weekly"] 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" : p === "weekly" ? "Weekly" : "Every Nh"}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{form.preset === "weekly" && (
|
|
<select
|
|
value={form.dow}
|
|
onChange={(e) => setForm((f) => ({ ...f, dow: parseInt(e.target.value) }))}
|
|
className="h-9 rounded-md border border-input bg-muted px-2 text-sm"
|
|
aria-label="Day of week"
|
|
>
|
|
{DOW_LABELS.map((d, i) => (
|
|
<option key={d} value={i}>
|
|
{d}
|
|
</option>
|
|
))}
|
|
</select>
|
|
)}
|
|
|
|
{(form.preset === "daily" || form.preset === "weekly") && (
|
|
<>
|
|
<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"
|
|
aria-label="Hour"
|
|
>
|
|
{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"
|
|
aria-label="Minute"
|
|
>
|
|
{[0, 15, 30, 45].map((v) => (
|
|
<option key={v} value={v}>
|
|
{pad(v)}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</>
|
|
)}
|
|
|
|
{form.preset === "every-hours" && (
|
|
<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"
|
|
aria-label="Hour interval"
|
|
>
|
|
{[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 ${
|
|
t.enabled ? "" : "opacity-60"
|
|
}`}
|
|
>
|
|
<div className="min-w-0 flex-1">
|
|
<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>
|
|
{!t.enabled && (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-xs px-1.5 py-0 border-muted-foreground/40 text-muted-foreground"
|
|
>
|
|
disabled
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<p className="text-xs text-muted-foreground">
|
|
{describeCron(t.cron)}
|
|
{t.enabled && <> · next {relative(nextRun(t.cron))}</>}
|
|
</p>
|
|
</div>
|
|
<div className="flex gap-1 shrink-0 flex-wrap justify-end">
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => runNow.mutate(t.id)}
|
|
disabled={runNow.isPending}
|
|
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
|
|
title="Run immediately"
|
|
>
|
|
Run now
|
|
</Button>
|
|
<Button
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => toggle.mutate({ id: t.id, enabled: !t.enabled })}
|
|
disabled={toggle.isPending}
|
|
className="text-xs h-9 sm:opacity-0 sm:group-hover:opacity-100 transition"
|
|
>
|
|
{t.enabled ? "Disable" : "Enable"}
|
|
</Button>
|
|
<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"
|
|
>
|
|
Remove
|
|
</Button>
|
|
</div>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|