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>
This commit is contained in:
hurkicorgi 2026-04-13 05:57:39 -06:00
parent 359a12ef9d
commit cf467b26c7
5 changed files with 677 additions and 0 deletions

View file

@ -0,0 +1,206 @@
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 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"
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, "'\\''");
}
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 obj 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 parseTasks(): Task[] {
const lines = readCrontab().split("\n");
const tasks: Task[] = [];
for (const line of lines) {
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);
}
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,
params: {
message: typeof params.message === "string" ? params.message : undefined,
keep: Number.isInteger(Number(params.keep)) ? Number(params.keep) : undefined,
},
};
try {
const cmd = buildCommand(task);
const line = `${cron} ${cmd} ${TASK_MARKER}${encodeTask(task)}`;
const crontab = readCrontab();
const updated = (crontab ? crontab.replace(/\n+$/, "") + "\n" : "") + line + "\n";
writeCrontab(updated);
return NextResponse.json(task);
} 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((line) => {
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;
}