- Bundle analyzer: @next/bundle-analyzer wired through next.config.ts,
new `bun run analyze` script (ANALYZE=true next build).
- Scheduled tasks gain:
- enabled flag round-tripped via a #DISABLED prefix on the crontab line
(preserves the mc-task marker + payload for re-enable).
- PATCH /api/schedule/tasks to toggle enabled.
- POST /api/schedule/tasks/run to execute a task immediately via
the same buildCommand used for the cron line (60s timeout, kills
child on client abort).
- Weekly preset in the UI (day-of-week selector), broader aria-labels
on all form selects. Human-readable cron renders "Tue at 04:30"
and next-run calc accounts for weekly.
- Hover-reveal Run now / Enable / Disable / Remove actions; disabled
tasks render at 60% opacity with a "disabled" badge and no next-run.
- /api/errors: minimal append-only JSONL reporter (200ms throttle, UA
and IP captured, fields length-bounded). Falls back to a stable
path under /home/minecraft/logs/ and never fails the client on
logging errors.
- ErrorReporter client (production-only) listens to window.error and
unhandledrejection, fingerprint-dedups via a bounded in-memory set,
sends with keepalive so unloads still flush.
- app/opengraph-image.tsx: 1200x630 dynamic PNG with live status dot
(green/red), player count, address. 60s memo on the status probe.
- A11y: aria-labels on log-line count select, scheduled-restart hour
and minute selects, copy-server-address button (plus focus-visible
ring). ModManager selected-mod pill upgraded to role=button with a
real accessible name and a proper × glyph.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
289 lines
9.4 KiB
TypeScript
289 lines
9.4 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))}
|
|
aria-label="Restart hour"
|
|
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))}
|
|
aria-label="Restart minute"
|
|
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>
|
|
);
|
|
}
|