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;
|
||||||
|
}
|
||||||
|
|
@ -2,6 +2,8 @@
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import dynamic from "next/dynamic";
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { ServerControls } from "@/components/ServerControls";
|
import { ServerControls } from "@/components/ServerControls";
|
||||||
import { Analytics } from "@/components/Analytics";
|
import { Analytics } from "@/components/Analytics";
|
||||||
|
|
@ -44,10 +46,60 @@ const TABS = [
|
||||||
{ value: "logs", label: "Logs" },
|
{ value: "logs", label: "Logs" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
const TAB_QUERIES: Record<string, string[]> = {
|
||||||
|
server: "status analytics schedule schedule-tasks".split(" "),
|
||||||
|
players: ["players"],
|
||||||
|
chat: ["chat"],
|
||||||
|
mods: "mods mod-updates snapshots".split(" "),
|
||||||
|
backups: ["backups"],
|
||||||
|
logs: ["logs"],
|
||||||
|
};
|
||||||
|
|
||||||
export function AdminTabs() {
|
export function AdminTabs() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
const [value, setValue] = useState<string>("server");
|
const [value, setValue] = useState<string>("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(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
const hash = window.location.hash.replace("#", "");
|
const hash = window.location.hash.replace("#", "");
|
||||||
|
|
|
||||||
347
components/ScheduledTasks.tsx
Normal file
347
components/ScheduledTasks.tsx
Normal file
|
|
@ -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<Task[]>({
|
||||||
|
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<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", {
|
||||||
|
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 (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||||
|
<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)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!adding && (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => setAdding(true)}>
|
||||||
|
Add task
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{adding && (
|
||||||
|
<div className="rounded-lg border border-border bg-muted/40 p-3 space-y-3">
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{(["say", "backup", "snapshot-prune"] as const).map((t) => (
|
||||||
|
<button
|
||||||
|
key={t}
|
||||||
|
onClick={() => setForm((f) => ({ ...f, type: t }))}
|
||||||
|
aria-pressed={form.type === t}
|
||||||
|
className={`text-xs font-semibold uppercase rounded px-2.5 py-1 border transition ${
|
||||||
|
form.type === t
|
||||||
|
? "border-primary/40 bg-primary/10 text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t === "snapshot-prune" ? "prune" : t}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.type === "say" && (
|
||||||
|
<Input
|
||||||
|
placeholder="Message to broadcast (e.g. §a[Server] Daily reset soon)"
|
||||||
|
value={form.message}
|
||||||
|
onChange={(e) => setForm((f) => ({ ...f, message: e.target.value }))}
|
||||||
|
maxLength={120}
|
||||||
|
className="h-9 text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{form.type === "snapshot-prune" && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label className="text-xs text-muted-foreground">Keep latest</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
max={50}
|
||||||
|
value={form.keep}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, keep: parseInt(e.target.value) || 5 }))
|
||||||
|
}
|
||||||
|
className="h-9 text-sm w-20"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(["daily", "every-hours"] as const).map((p) => (
|
||||||
|
<button
|
||||||
|
key={p}
|
||||||
|
onClick={() => setForm((f) => ({ ...f, preset: p }))}
|
||||||
|
aria-pressed={form.preset === p}
|
||||||
|
className={`text-[10px] font-semibold uppercase rounded px-2 py-1 border transition ${
|
||||||
|
form.preset === p
|
||||||
|
? "border-primary/40 bg-primary/10 text-primary"
|
||||||
|
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p === "daily" ? "Daily" : "Every N hours"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{form.preset === "daily" ? (
|
||||||
|
<>
|
||||||
|
<select
|
||||||
|
value={form.hour}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, hour: parseInt(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="h-9 rounded-md border border-input bg-muted px-2 text-sm"
|
||||||
|
>
|
||||||
|
{Array.from({ length: 24 }, (_, i) => (
|
||||||
|
<option key={i} value={i}>
|
||||||
|
{pad(i)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="text-muted-foreground">:</span>
|
||||||
|
<select
|
||||||
|
value={form.minute}
|
||||||
|
onChange={(e) =>
|
||||||
|
setForm((f) => ({ ...f, minute: parseInt(e.target.value) }))
|
||||||
|
}
|
||||||
|
className="h-9 rounded-md border border-input bg-muted px-2 text-sm"
|
||||||
|
>
|
||||||
|
{[0, 15, 30, 45].map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
{pad(v)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<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"
|
||||||
|
>
|
||||||
|
{[1, 2, 3, 4, 6, 8, 12].map((v) => (
|
||||||
|
<option key={v} value={v}>
|
||||||
|
every {v}h
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={() => setAdding(false)}
|
||||||
|
disabled={create.isPending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => create.mutate()}
|
||||||
|
disabled={!canSubmit || create.isPending}
|
||||||
|
>
|
||||||
|
{create.isPending ? "Adding..." : "Add task"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Loading...</p>
|
||||||
|
) : tasks.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No scheduled tasks yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-1">
|
||||||
|
{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"
|
||||||
|
>
|
||||||
|
<div className="min-w-0">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{describeCron(t.cron)} · 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>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -7,6 +7,7 @@ import { Button } from "@/components/ui/button";
|
||||||
import { Alert, AlertDescription } from "@/components/ui/alert";
|
import { Alert, AlertDescription } from "@/components/ui/alert";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Skeleton } from "@/components/ui/skeleton";
|
import { Skeleton } from "@/components/ui/skeleton";
|
||||||
|
import { ScheduledTasks } from "@/components/ScheduledTasks";
|
||||||
import { StatusBadge, statusFromServer } from "@/components/StatusBadge";
|
import { StatusBadge, statusFromServer } from "@/components/StatusBadge";
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
|
|
@ -191,6 +192,11 @@ export function ServerControls() {
|
||||||
|
|
||||||
{/* Scheduled restart */}
|
{/* Scheduled restart */}
|
||||||
<ScheduledRestart />
|
<ScheduledRestart />
|
||||||
|
|
||||||
|
<Separator />
|
||||||
|
|
||||||
|
{/* Additional scheduled tasks */}
|
||||||
|
<ScheduledTasks />
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
66
scripts/run-task.sh
Executable file
66
scripts/run-task.sh
Executable file
|
|
@ -0,0 +1,66 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# Dispatcher for dashboard-scheduled tasks.
|
||||||
|
# Usage: run-task.sh <type> [...params]
|
||||||
|
# say <message>
|
||||||
|
# backup
|
||||||
|
# snapshot-prune <keepCount>
|
||||||
|
|
||||||
|
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
|
||||||
Loading…
Add table
Add a link
Reference in a new issue