mc-dashboard/components/ServerControls.tsx
hurkicorgi cf467b26c7 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>
2026-04-13 05:57:39 -06:00

287 lines
9.3 KiB
TypeScript

"use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import { toast } from "sonner";
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,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@/components/ui/card";
type ServerStatus = {
online: boolean;
starting?: boolean;
players: { online: number; max: number };
version?: string;
motd?: string;
};
type Action = "start" | "stop" | "restart";
const ACTION_LABEL: Record<Action, string> = {
start: "Start",
stop: "Stop",
restart: "Restart",
};
export function ServerControls() {
const queryClient = useQueryClient();
const [confirm, setConfirm] = useState<Action | null>(null);
const { data: status, isLoading } = useQuery<ServerStatus>({
queryKey: ["status"],
queryFn: () => fetch("/api/status").then((r) => r.json()),
refetchInterval: 60_000,
staleTime: 5000,
});
const action = useMutation({
mutationFn: async (act: Action) => {
const res = await fetch(`/api/server/${act}`, { method: "POST" });
if (!res.ok) {
const data = await res.json();
throw new Error(data.error || "Request failed");
}
return { ...(await res.json()), act };
},
onSuccess: (_, act) => {
toast.success(`${ACTION_LABEL[act]} command sent`, {
description: "Status will update in a few seconds.",
});
setTimeout(
() => queryClient.invalidateQueries({ queryKey: ["status"] }),
3000
);
},
onError: (err, act) => {
toast.error(`${ACTION_LABEL[act]} failed`, { description: err.message });
},
});
const isOnline = status?.online ?? false;
const trigger = (act: Action) => {
if (act === "start") {
action.mutate(act);
} else {
setConfirm(act);
}
};
const confirmRun = () => {
if (confirm) {
action.mutate(confirm);
setConfirm(null);
}
};
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<CardTitle>Server Controls</CardTitle>
<CardDescription className="hidden sm:block">Manage the Minecraft server process</CardDescription>
</div>
{isLoading || !status ? (
<Skeleton className="h-5 w-20" />
) : (
<StatusBadge status={statusFromServer(status)} />
)}
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Stats row */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 sm:gap-3">
<div className="rounded-lg bg-muted p-3 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Players</p>
{isLoading || !status ? (
<Skeleton className="h-7 w-12 mx-auto" />
) : (
<p className="text-xl font-bold tabular-nums">
{isOnline ? `${status.players.online}/${status.players.max}` : "-"}
</p>
)}
</div>
<div className="rounded-lg bg-muted p-3 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Version</p>
{isLoading || !status ? (
<Skeleton className="h-5 w-16 mx-auto mt-1" />
) : (
<p className="text-sm font-semibold mt-1">{status.version || "-"}</p>
)}
</div>
<div className="rounded-lg bg-muted p-3 text-center">
<p className="text-xs text-muted-foreground mb-0.5">Address</p>
<p className="text-xs font-mono font-semibold mt-1">
minecraft.hurkicorgi.com
</p>
</div>
</div>
{/* Confirmation */}
{confirm && (
<Alert className="border-amber-500/30 bg-amber-500/5">
<AlertDescription className="text-amber-300 flex items-center justify-between gap-3 flex-wrap">
<span>
{confirm === "stop"
? "Stop the server? Players will be disconnected."
: "Restart the server? Players will be disconnected briefly."}
</span>
<span className="flex gap-1">
<Button size="sm" variant="destructive" onClick={confirmRun}>
Confirm {ACTION_LABEL[confirm]}
</Button>
<Button size="sm" variant="ghost" onClick={() => setConfirm(null)}>
Cancel
</Button>
</span>
</AlertDescription>
</Alert>
)}
{/* Action buttons */}
<div className="grid grid-cols-3 gap-2">
<Button
variant="outline"
onClick={() => trigger("start")}
disabled={action.isPending || isOnline || !!confirm}
className="w-full border-emerald-500/30 text-emerald-300 hover:bg-emerald-500/10 hover:text-emerald-200 disabled:opacity-40"
>
Start
</Button>
<Button
variant="outline"
onClick={() => trigger("stop")}
disabled={action.isPending || !isOnline || !!confirm}
className="w-full border-red-500/30 text-red-300 hover:bg-red-500/10 hover:text-red-200 disabled:opacity-40"
>
Stop
</Button>
<Button
variant="outline"
onClick={() => trigger("restart")}
disabled={action.isPending || !isOnline || !!confirm}
className="w-full border-amber-500/30 text-amber-300 hover:bg-amber-500/10 hover:text-amber-200 disabled:opacity-40"
>
Restart
</Button>
</div>
{/* Feedback */}
{action.isPending && (
<Alert>
<AlertDescription className="text-muted-foreground flex items-center gap-2">
<span className="h-2 w-2 rounded-full bg-blue-400 animate-pulse" />
Sending {action.variables ? ACTION_LABEL[action.variables] : ""} command...
</AlertDescription>
</Alert>
)}
{/* success/error surfaced via toast */}
<Separator />
{/* Scheduled restart */}
<ScheduledRestart />
<Separator />
{/* Additional scheduled tasks */}
<ScheduledTasks />
</CardContent>
</Card>
);
}
function ScheduledRestart() {
const { data: schedule } = useQuery<{ enabled: boolean; hour: number; minute: number }>({
queryKey: ["schedule"],
queryFn: () => fetch("/api/schedule").then((r) => r.json()),
staleTime: 30_000,
initialData: { enabled: false, hour: 4, minute: 0 },
});
const [hour, setHour] = useState<number | null>(null);
const [minute, setMinute] = useState<number | null>(null);
const h = hour ?? schedule.hour;
const m = minute ?? schedule.minute;
const update = useMutation({
mutationFn: async (params: { enabled: boolean; hour: number; minute: number }) => {
const res = await fetch("/api/schedule", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(params),
});
return res.json();
},
});
const pad = (n: number) => String(n).padStart(2, "0");
return (
<div className="space-y-3">
<div>
<p className="text-sm font-semibold">Scheduled Restart</p>
<p className="text-xs text-muted-foreground">
{schedule.enabled
? `Daily at ${pad(schedule.hour)}:${pad(schedule.minute)} server time — warns players before restarting`
: "Set a time for daily auto-restart with player warnings (uses server's local time)"}
</p>
</div>
<div className="flex flex-wrap items-center gap-2">
<select
value={h}
onChange={(e) => setHour(parseInt(e.target.value))}
className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground"
>
{Array.from({ length: 24 }, (_, i) => (
<option key={i} value={i}>
{pad(i)}
</option>
))}
</select>
<span className="text-muted-foreground font-bold">:</span>
<select
value={m}
onChange={(e) => setMinute(parseInt(e.target.value))}
className="h-10 w-20 rounded-md border border-input bg-muted px-3 text-sm text-foreground"
>
{[0, 15, 30, 45].map((v) => (
<option key={v} value={v}>
{pad(v)}
</option>
))}
</select>
<span className="text-xs text-muted-foreground">server time</span>
{schedule.enabled ? (
<Button
variant="ghost"
onClick={() => update.mutate({ enabled: false, hour: h, minute: m })}
disabled={update.isPending}
className="text-muted-foreground"
>
Disable
</Button>
) : (
<Button
onClick={() => update.mutate({ enabled: true, hour: h, minute: m })}
disabled={update.isPending}
>
Enable
</Button>
)}
</div>
</div>
);
}