mc-dashboard/app/api/schedule/tasks/route.ts
hurkicorgi 7ada9ec7d9 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>
2026-04-13 06:08:48 -06:00

272 lines
8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { NextRequest, NextResponse } from "next/server";
import { execSync } from "child_process";
import { writeFileSync, unlinkSync } from "fs";
import { randomBytes } from "crypto";
import { auth } from "@/lib/auth";
export const dynamic = "force-dynamic";
const TASK_MARKER = "# mc-task:";
const DISABLED_PREFIX = "#DISABLED ";
const RUN_SCRIPT = "/home/minecraft/dashboard/scripts/run-task.sh";
const TASK_TYPES = ["say", "backup", "snapshot-prune"] as const;
type TaskType = (typeof TASK_TYPES)[number];
type Task = {
id: string;
type: TaskType;
label: string;
cron: string; // "M H D M W"
enabled: boolean;
params: { message?: string; keep?: number };
};
const MSG_RE = /^[^\r\n$`\\'"]{1,120}$/;
function isValidCron(cron: string): boolean {
const parts = cron.trim().split(/\s+/);
if (parts.length !== 5) return false;
const field = /^(\*|\*\/\d+|\d+(?:[,/\-]\d+)*(?:,\d+(?:[,/\-]\d+)*)*)$/;
return parts.every((p) => field.test(p));
}
function escapeSingle(s: string): string {
return s.replace(/'/g, "'\\''");
}
export function buildCommand(task: Task): string {
const base = `bash ${RUN_SCRIPT}`;
if (task.type === "say") {
const msg = (task.params.message || "").trim();
return `${base} say '${escapeSingle(msg)}'`;
}
if (task.type === "snapshot-prune") {
const keep = Math.min(
Math.max(1, Math.floor(Number(task.params.keep) || 5)),
50
);
return `${base} snapshot-prune ${keep}`;
}
return `${base} backup`;
}
function encodeTask(task: Task): string {
return Buffer.from(JSON.stringify(task), "utf8").toString("base64");
}
function decodeTask(b64: string): Task | null {
try {
const obj = JSON.parse(Buffer.from(b64, "base64").toString("utf8"));
if (
!obj ||
typeof obj.id !== "string" ||
!TASK_TYPES.includes(obj.type) ||
typeof obj.cron !== "string" ||
typeof obj.label !== "string"
) {
return null;
}
return {
id: obj.id,
type: obj.type,
label: obj.label,
cron: obj.cron,
enabled: obj.enabled !== false,
params: obj.params || {},
} as Task;
} catch {
return null;
}
}
function readCrontab(): string {
try {
return execSync("crontab -l 2>/dev/null", { encoding: "utf8" });
} catch {
return "";
}
}
function writeCrontab(contents: string): void {
const tmp = `/tmp/crontab-${Date.now()}-${randomBytes(3).toString("hex")}.tmp`;
writeFileSync(tmp, contents.endsWith("\n") ? contents : contents + "\n", {
mode: 0o600,
});
try {
execSync(`crontab ${tmp}`, { encoding: "utf8" });
} finally {
try { unlinkSync(tmp); } catch {}
}
}
function renderLine(task: Task): string {
const cmd = buildCommand(task);
const body = `${task.cron} ${cmd} ${TASK_MARKER}${encodeTask(task)}`;
return task.enabled ? body : `${DISABLED_PREFIX}${body}`;
}
export function parseTasks(): Task[] {
const lines = readCrontab().split("\n");
const tasks: Task[] = [];
for (const rawLine of lines) {
let line = rawLine;
let enabled = true;
if (line.startsWith(DISABLED_PREFIX)) {
enabled = false;
line = line.slice(DISABLED_PREFIX.length);
}
const idx = line.indexOf(TASK_MARKER);
if (idx < 0) continue;
const b64 = line.slice(idx + TASK_MARKER.length).trim();
const task = decodeTask(b64);
if (task) tasks.push({ ...task, enabled });
}
return tasks;
}
export async function GET() {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
return NextResponse.json(parseTasks());
}
export async function POST(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const body = await req.json();
const type = body.type as TaskType;
const label = typeof body.label === "string" ? body.label.trim().slice(0, 80) : "";
const cron = typeof body.cron === "string" ? body.cron.trim() : "";
const params = body.params || {};
if (!TASK_TYPES.includes(type)) {
return NextResponse.json({ error: "Invalid task type" }, { status: 400 });
}
if (!isValidCron(cron)) {
return NextResponse.json({ error: "Invalid cron expression" }, { status: 400 });
}
if (type === "say") {
if (typeof params.message !== "string" || !MSG_RE.test(params.message)) {
return NextResponse.json(
{ error: "Message must be 1120 chars, no quotes or newlines" },
{ status: 400 }
);
}
}
if (type === "snapshot-prune") {
const keep = Number(params.keep);
if (!Number.isInteger(keep) || keep < 1 || keep > 50) {
return NextResponse.json(
{ error: "keep must be an integer 150" },
{ status: 400 }
);
}
}
const task: Task = {
id: randomBytes(8).toString("hex"),
type,
label: label || defaultLabel(type, params),
cron,
enabled: true,
params: {
message: typeof params.message === "string" ? params.message : undefined,
keep: Number.isInteger(Number(params.keep)) ? Number(params.keep) : undefined,
},
};
try {
const crontab = readCrontab();
const updated =
(crontab ? crontab.replace(/\n+$/, "") + "\n" : "") +
renderLine(task) +
"\n";
writeCrontab(updated);
return NextResponse.json(task);
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
export async function PATCH(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { id, enabled } = await req.json();
if (typeof id !== "string" || !/^[a-f0-9]{16}$/.test(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
if (typeof enabled !== "boolean") {
return NextResponse.json({ error: "enabled must be boolean" }, { status: 400 });
}
try {
const lines = readCrontab().split("\n");
let found: Task | null = null;
const updated = lines.map((rawLine) => {
let line = rawLine;
let wasDisabled = false;
if (line.startsWith(DISABLED_PREFIX)) {
wasDisabled = true;
line = line.slice(DISABLED_PREFIX.length);
}
const idx = line.indexOf(TASK_MARKER);
if (idx < 0) return rawLine;
const task = decodeTask(line.slice(idx + TASK_MARKER.length).trim());
if (!task || task.id !== id) return rawLine;
task.enabled = enabled;
found = task;
return renderLine(task);
void wasDisabled;
});
if (!found) {
return NextResponse.json({ error: "Task not found" }, { status: 404 });
}
writeCrontab(updated.filter(Boolean).join("\n"));
return NextResponse.json(found);
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
export async function DELETE(req: NextRequest) {
const session = await auth();
if (!session) {
return NextResponse.json({ error: "Unauthorized" }, { status: 403 });
}
const { id } = await req.json();
if (typeof id !== "string" || !/^[a-f0-9]{16}$/.test(id)) {
return NextResponse.json({ error: "Invalid id" }, { status: 400 });
}
try {
const lines = readCrontab().split("\n");
const kept = lines.filter((rawLine) => {
const line = rawLine.startsWith(DISABLED_PREFIX)
? rawLine.slice(DISABLED_PREFIX.length)
: rawLine;
const idx = line.indexOf(TASK_MARKER);
if (idx < 0) return true;
const task = decodeTask(line.slice(idx + TASK_MARKER.length).trim());
return !task || task.id !== id;
});
writeCrontab(kept.filter(Boolean).join("\n"));
return NextResponse.json({ ok: true });
} catch (e) {
return NextResponse.json({ error: (e as Error).message }, { status: 500 });
}
}
function defaultLabel(type: TaskType, params: { message?: string; keep?: number }): string {
if (type === "say") return `Announce: ${(params.message || "").slice(0, 40)}`;
if (type === "backup") return "World backup";
if (type === "snapshot-prune") return `Prune snapshots (keep ${params.keep || 5})`;
return type;
}