diff --git a/app/api/schedule/tasks/route.ts b/app/api/schedule/tasks/route.ts new file mode 100644 index 0000000..573c68c --- /dev/null +++ b/app/api/schedule/tasks/route.ts @@ -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; +} diff --git a/components/AdminTabs.tsx b/components/AdminTabs.tsx index 6004bd2..31684b9 100644 --- a/components/AdminTabs.tsx +++ b/components/AdminTabs.tsx @@ -2,6 +2,8 @@ import { useEffect, useState } from "react"; import dynamic from "next/dynamic"; +import { useQueryClient } from "@tanstack/react-query"; +import { toast } from "sonner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { ServerControls } from "@/components/ServerControls"; import { Analytics } from "@/components/Analytics"; @@ -44,10 +46,60 @@ const TABS = [ { value: "logs", label: "Logs" }, ]; +const TAB_QUERIES: Record = { + server: "status analytics schedule schedule-tasks".split(" "), + players: ["players"], + chat: ["chat"], + mods: "mods mod-updates snapshots".split(" "), + backups: ["backups"], + logs: ["logs"], +}; + export function AdminTabs() { + const queryClient = useQueryClient(); const [mounted, setMounted] = useState(false); const [value, setValue] = useState("server"); + useEffect(() => { + const isEditable = (el: Element | null) => { + if (!el) return false; + const tag = (el as HTMLElement).tagName; + if (tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT") return true; + return (el as HTMLElement).isContentEditable; + }; + + const onKey = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey) return; + if (isEditable(document.activeElement)) return; + + if (e.key === "r") { + e.preventDefault(); + const keys = TAB_QUERIES[value] || []; + keys.forEach((k) => { + queryClient.refetchQueries({ queryKey: [k] }); + }); + toast.success(`Refreshing ${value}...`, { duration: 1500 }); + } else if (e.key === "/") { + const panel = document.querySelector( + '[data-slot="tabs-content"] input, [data-slot="tabs-content"] [contenteditable="true"]' + ) as HTMLElement | null; + if (panel) { + e.preventDefault(); + panel.focus(); + } + } else if (e.key === "?") { + e.preventDefault(); + toast.message("Keyboard shortcuts", { + description: + "⌘K palette · r refresh · / focus search · Esc close · hash links jump tabs", + duration: 5000, + }); + } + }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [value, queryClient]); + useEffect(() => { setMounted(true); const hash = window.location.hash.replace("#", ""); diff --git a/components/ScheduledTasks.tsx b/components/ScheduledTasks.tsx new file mode 100644 index 0000000..7c0a070 --- /dev/null +++ b/components/ScheduledTasks.tsx @@ -0,0 +1,347 @@ +"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; + params: { message?: string; keep?: number }; +}; + +type Preset = + | { kind: "daily"; hour: number; minute: number } + | { kind: "every-hours"; hours: number }; + +function cronFromPreset(p: Preset): string { + if (p.kind === "daily") return `${p.minute} ${p.hour} * * *`; + return `0 */${p.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 === "*" && /^\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] = parts; + const now = new Date(); + + 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 "daily" | "every-hours", + hour: 4, + minute: 0, + hours: 6, + }); + + const { data: tasks = [], isLoading } = useQuery({ + queryKey: ["schedule-tasks"], + queryFn: () => fetch("/api/schedule/tasks").then((r) => r.json()), + staleTime: 30_000, + }); + + 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 } = { + 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: "" })); + queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] }); + }, + onError: (err) => toast.error("Failed to add task", { 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"); + queryClient.invalidateQueries({ queryKey: ["schedule-tasks"] }); + }, + 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 ( +
+
+
+

Scheduled Tasks

+

+ Recurring chat messages, backups, and snapshot pruning (via crontab) +

+
+ {!adding && ( + + )} +
+ + {adding && ( +
+
+ {(["say", "backup", "snapshot-prune"] as const).map((t) => ( + + ))} +
+ + {form.type === "say" && ( + setForm((f) => ({ ...f, message: e.target.value }))} + maxLength={120} + className="h-9 text-sm" + /> + )} + + {form.type === "snapshot-prune" && ( +
+ + + setForm((f) => ({ ...f, keep: parseInt(e.target.value) || 5 })) + } + className="h-9 text-sm w-20" + /> +
+ )} + +
+
+ {(["daily", "every-hours"] as const).map((p) => ( + + ))} +
+ + {form.preset === "daily" ? ( + <> + + : + + + ) : ( + + )} +
+ +
+ + +
+
+ )} + + {isLoading ? ( +

Loading...

+ ) : tasks.length === 0 ? ( +

No scheduled tasks yet.

+ ) : ( +
    + {tasks.map((t) => ( +
  • +
    +
    + {t.label} + + {t.type} + +
    +

    + {describeCron(t.cron)} · next {relative(nextRun(t.cron))} +

    +
    + +
  • + ))} +
+ )} +
+ ); +} diff --git a/components/ServerControls.tsx b/components/ServerControls.tsx index 1eaf2d6..b6812f9 100644 --- a/components/ServerControls.tsx +++ b/components/ServerControls.tsx @@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button"; import { Alert, AlertDescription } from "@/components/ui/alert"; import { Separator } from "@/components/ui/separator"; import { Skeleton } from "@/components/ui/skeleton"; +import { ScheduledTasks } from "@/components/ScheduledTasks"; import { StatusBadge, statusFromServer } from "@/components/StatusBadge"; import { Card, @@ -191,6 +192,11 @@ export function ServerControls() { {/* Scheduled restart */} + + + + {/* Additional scheduled tasks */} + ); diff --git a/scripts/run-task.sh b/scripts/run-task.sh new file mode 100755 index 0000000..928599b --- /dev/null +++ b/scripts/run-task.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# Dispatcher for dashboard-scheduled tasks. +# Usage: run-task.sh [...params] +# say +# backup +# snapshot-prune + +set -u +TYPE="${1:-}" +shift || true + +LOG="${MC_DASHBOARD_LOG:-/home/minecraft/logs/mc-dashboard/tasks.log}" +mkdir -p "$(dirname "$LOG")" 2>/dev/null || true +if [ ! -w "$(dirname "$LOG")" ]; then LOG="/tmp/mc-dashboard-tasks.log"; fi + +log() { + echo "[$(date -Iseconds)] $*" >>"$LOG" 2>/dev/null || true +} + +rcon_say() { + local msg="$1" + RCON_MSG="$msg" node -e ' + const { Rcon } = require("/home/minecraft/dashboard/node_modules/rcon-client"); + (async () => { + try { + const rcon = await Rcon.connect({ host: "127.0.0.1", port: 25575, password: "23991818cc169249f181436f2a29a013" }); + await rcon.send("say " + process.env.RCON_MSG); + rcon.end(); + } catch (e) { + process.stderr.write(String(e)); + process.exit(1); + } + })(); + ' +} + +case "$TYPE" in + say) + MSG="${*:-}" + if [ -z "$MSG" ]; then log "say: empty message"; exit 1; fi + MSG="$(printf '%s' "$MSG" | tr -d '\r\n')" + rcon_say "$MSG" + log "say: $MSG" + ;; + backup) + bash /home/minecraft/dashboard/scripts/backup-world.sh >>"$LOG" 2>&1 + log "backup: done ($?)" + ;; + snapshot-prune) + KEEP="${1:-5}" + if ! [[ "$KEEP" =~ ^[0-9]+$ ]] || [ "$KEEP" -lt 1 ] || [ "$KEEP" -gt 50 ]; then + KEEP=5 + fi + DIR="/home/minecraft/server/snapshots" + if [ -d "$DIR" ]; then + ls -1dt "$DIR"/*/ 2>/dev/null | tail -n +$((KEEP + 1)) | while read -r d; do + [ -n "$d" ] && rm -rf -- "$d" + done + fi + log "snapshot-prune: kept $KEEP" + ;; + *) + log "unknown task type: $TYPE" + exit 1 + ;; +esac