Bundle analyzer, task run-now/toggle/weekly, error reporter, OG image, a11y
- 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>
This commit is contained in:
parent
cf467b26c7
commit
7ada9ec7d9
14 changed files with 578 additions and 56 deletions
67
components/ErrorReporter.tsx
Normal file
67
components/ErrorReporter.tsx
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
|
||||
const SEEN = new Set<string>();
|
||||
const MAX_SEEN = 50;
|
||||
|
||||
function fingerprint(type: string, message: string, stack?: string): string {
|
||||
const head = (stack || "").split("\n").slice(0, 3).join("\n");
|
||||
return `${type}|${message.slice(0, 80)}|${head.slice(0, 120)}`;
|
||||
}
|
||||
|
||||
async function report(payload: {
|
||||
type: string;
|
||||
message: string;
|
||||
stack?: string;
|
||||
url?: string;
|
||||
}) {
|
||||
const fp = fingerprint(payload.type, payload.message, payload.stack);
|
||||
if (SEEN.has(fp)) return;
|
||||
SEEN.add(fp);
|
||||
if (SEEN.size > MAX_SEEN) {
|
||||
const first = SEEN.values().next().value;
|
||||
if (first) SEEN.delete(first);
|
||||
}
|
||||
try {
|
||||
await fetch("/api/errors", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ...payload, url: payload.url || location.href }),
|
||||
keepalive: true,
|
||||
});
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function ErrorReporter() {
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (process.env.NODE_ENV !== "production") return;
|
||||
|
||||
const onError = (e: ErrorEvent) => {
|
||||
report({
|
||||
type: "window.error",
|
||||
message: e.message || String(e.error),
|
||||
stack: e.error?.stack,
|
||||
url: e.filename,
|
||||
});
|
||||
};
|
||||
|
||||
const onRejection = (e: PromiseRejectionEvent) => {
|
||||
const reason = e.reason;
|
||||
const message =
|
||||
reason instanceof Error ? reason.message : String(reason);
|
||||
const stack = reason instanceof Error ? reason.stack : undefined;
|
||||
report({ type: "unhandledrejection", message, stack });
|
||||
};
|
||||
|
||||
window.addEventListener("error", onError);
|
||||
window.addEventListener("unhandledrejection", onRejection);
|
||||
return () => {
|
||||
window.removeEventListener("error", onError);
|
||||
window.removeEventListener("unhandledrejection", onRejection);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
|
@ -115,6 +115,7 @@ export function LogViewer() {
|
|||
setLines(Number(e.target.value));
|
||||
setTimeout(() => refetch(), 50);
|
||||
}}
|
||||
aria-label="Number of log lines to fetch"
|
||||
className="h-9 rounded-md border border-input bg-muted px-2 text-sm text-foreground focus:outline-none"
|
||||
>
|
||||
<option value={50}>50 lines</option>
|
||||
|
|
|
|||
|
|
@ -627,9 +627,11 @@ export function ModManager() {
|
|||
variant="secondary"
|
||||
className="gap-1 cursor-pointer hover:bg-destructive/20"
|
||||
onClick={() => toggleSelect(s)}
|
||||
role="button"
|
||||
aria-label={`Unselect ${s.title}`}
|
||||
>
|
||||
{s.title}
|
||||
<span className="text-muted-foreground">x</span>
|
||||
<span aria-hidden className="text-muted-foreground">×</span>
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -14,24 +14,34 @@ type Task = {
|
|||
type: TaskType;
|
||||
label: string;
|
||||
cron: string;
|
||||
enabled: boolean;
|
||||
params: { message?: string; keep?: number };
|
||||
};
|
||||
|
||||
type Preset =
|
||||
| { kind: "daily"; hour: number; minute: number }
|
||||
| { kind: "every-hours"; hours: number };
|
||||
type Preset = "daily" | "every-hours" | "weekly";
|
||||
|
||||
function cronFromPreset(p: Preset): string {
|
||||
if (p.kind === "daily") return `${p.minute} ${p.hour} * * *`;
|
||||
return `0 */${p.hours} * * *`;
|
||||
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 (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")}`;
|
||||
|
|
@ -42,9 +52,18 @@ function describeCron(cron: string): string {
|
|||
function nextRun(cron: string): Date | null {
|
||||
const parts = cron.trim().split(/\s+/);
|
||||
if (parts.length !== 5) return null;
|
||||
const [m, h] = parts;
|
||||
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);
|
||||
|
|
@ -85,10 +104,11 @@ export function ScheduledTasks() {
|
|||
type: "say" as TaskType,
|
||||
message: "",
|
||||
keep: 5,
|
||||
preset: "daily" as "daily" | "every-hours",
|
||||
preset: "daily" as Preset,
|
||||
hour: 4,
|
||||
minute: 0,
|
||||
hours: 6,
|
||||
dow: 0,
|
||||
});
|
||||
|
||||
const { data: tasks = [], isLoading } = useQuery<Task[]>({
|
||||
|
|
@ -97,19 +117,18 @@ export function ScheduledTasks() {
|
|||
staleTime: 30_000,
|
||||
});
|
||||
|
||||
const invalidate = () =>
|
||||
queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] });
|
||||
|
||||
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: {},
|
||||
};
|
||||
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", {
|
||||
|
|
@ -125,11 +144,44 @@ export function ScheduledTasks() {
|
|||
toast.success("Task scheduled");
|
||||
setAdding(false);
|
||||
setForm((f) => ({ ...f, message: "" }));
|
||||
queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] });
|
||||
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", {
|
||||
|
|
@ -143,7 +195,7 @@ export function ScheduledTasks() {
|
|||
},
|
||||
onSuccess: () => {
|
||||
toast.success("Task removed");
|
||||
queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] });
|
||||
invalidate();
|
||||
},
|
||||
onError: (err) => toast.error("Remove failed", { description: err.message }),
|
||||
});
|
||||
|
|
@ -167,7 +219,7 @@ export function ScheduledTasks() {
|
|||
<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)
|
||||
Recurring chat, backups, and snapshot pruning via crontab
|
||||
</p>
|
||||
</div>
|
||||
{!adding && (
|
||||
|
|
@ -224,7 +276,7 @@ export function ScheduledTasks() {
|
|||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex gap-1">
|
||||
{(["daily", "every-hours"] as const).map((p) => (
|
||||
{(["daily", "every-hours", "weekly"] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
onClick={() => setForm((f) => ({ ...f, preset: p }))}
|
||||
|
|
@ -235,12 +287,27 @@ export function ScheduledTasks() {
|
|||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
{p === "daily" ? "Daily" : "Every N hours"}
|
||||
{p === "daily" ? "Daily" : p === "weekly" ? "Weekly" : "Every Nh"}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{form.preset === "daily" ? (
|
||||
{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}
|
||||
|
|
@ -248,6 +315,7 @@ export function ScheduledTasks() {
|
|||
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}>
|
||||
|
|
@ -262,6 +330,7 @@ export function ScheduledTasks() {
|
|||
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}>
|
||||
|
|
@ -270,13 +339,16 @@ export function ScheduledTasks() {
|
|||
))}
|
||||
</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}>
|
||||
|
|
@ -316,28 +388,60 @@ export function ScheduledTasks() {
|
|||
{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"
|
||||
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">
|
||||
<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)} · next {relative(nextRun(t.cron))}
|
||||
{describeCron(t.cron)}
|
||||
{t.enabled && <> · 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>
|
||||
<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>
|
||||
|
|
|
|||
|
|
@ -243,6 +243,7 @@ function ScheduledRestart() {
|
|||
<select
|
||||
value={h}
|
||||
onChange={(e) => setHour(parseInt(e.target.value))}
|
||||
aria-label="Restart hour"
|
||||
className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground"
|
||||
>
|
||||
{Array.from({ length: 24 }, (_, i) => (
|
||||
|
|
@ -255,6 +256,7 @@ function ScheduledRestart() {
|
|||
<select
|
||||
value={m}
|
||||
onChange={(e) => setMinute(parseInt(e.target.value))}
|
||||
aria-label="Restart minute"
|
||||
className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground"
|
||||
>
|
||||
{[0, 15, 30, 45].map((v) => (
|
||||
|
|
|
|||
|
|
@ -82,7 +82,8 @@ export function StatusCard() {
|
|||
</p>
|
||||
<button
|
||||
onClick={copyIP}
|
||||
className="w-full rounded-md border border-dashed border-border px-2 py-2 hover:border-primary/50 transition cursor-pointer min-h-[44px]"
|
||||
aria-label="Copy server address"
|
||||
className="w-full rounded-md border border-dashed border-border px-2 py-2 hover:border-primary/50 transition cursor-pointer min-h-[44px] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<p className="font-mono text-xs font-semibold text-foreground">
|
||||
minecraft.hurkicorgi.com
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue