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:
parent
359a12ef9d
commit
cf467b26c7
5 changed files with 677 additions and 0 deletions
206
app/api/schedule/tasks/route.ts
Normal file
206
app/api/schedule/tasks/route.ts
Normal 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 1–120 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 1–50" },
|
||||
{ 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue