Minecraft 26.2 Fabric server stack with CraftDeck management panel

- docker-compose: itzg/minecraft-server (Fabric, offline mode, whitelist
  enforced, Modrinth-managed mods), itzg/mc-backup sidecar, custom dashboard
- Gated auth for offline mode: approve username -> whitelisted with a short
  registration window -> EasyAuth /register + /login; auto-revoke on expiry
- CraftDeck panel (Next.js 16, zero native deps): live console (SSE), players
  & stats, lag diagnostics (server TPS vs player ping via spark), performance
  graphs, Modrinth mod search/manager, backups & restore, sandboxed file
  browser/editor, .env config editor with compose apply, cron schedules,
  Discord alerts, scrypt-hashed panel login
- scripts/setup.sh generates secrets; docs/ covers auth, mods, updating,
  backups, networking (Tailscale), troubleshooting

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Aryan SinghandClaude Fable 5 committed 2026-07-10 11:07:07 -05:00
commit 279d137ce5
98 files changed
+9925

No files matched your search

+289
View File
@@ -0,0 +1,289 @@
"use client";
import { useEffect, useState } from "react";
import { ShieldCheck, UserPlus, KeyRound, RefreshCw, Import, Copy } from "lucide-react";
import { api, useApi, useSSE, toast } from "@/lib/api";
import { Card, Button, Badge, Input, Modal, EmptyState } from "@/components/ui";
import { timeAgo, fmtCountdown, fmtDateTime } from "@/lib/format";
type ApprovedPlayer = {
id: number;
username: string;
status: "pending" | "registered" | "expired" | "revoked";
approved_at: number | null;
window_expires_at: number | null;
registered_at: number | null;
last_seen_at: number | null;
note: string | null;
};
type AccessData = {
approved: ApprovedPlayer[];
whitelist: string[] | null;
registered: string[] | null;
};
const STATUS_BADGE: Record<ApprovedPlayer["status"], { color: "good" | "warn" | "crit" | "muted"; label: string }> = {
pending: { color: "warn", label: "waiting to register" },
registered: { color: "good", label: "registered" },
expired: { color: "muted", label: "window expired" },
revoked: { color: "crit", label: "revoked" },
};
export default function AccessPage() {
const { data, error, refresh } = useApi<AccessData>("/api/approved", 10000);
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [tempPassword, setTempPassword] = useState<{ user: string; password: string } | null>(null);
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
const [, tick] = useState(0);
// live countdown for pending windows
useEffect(() => {
const t = setInterval(() => tick((x) => x + 1), 1000);
return () => clearInterval(t);
}, []);
useSSE("/api/stream/events", {
"mc-event": () => void refresh(),
});
const run = async (label: string, body: Record<string, unknown>, done?: (d: unknown) => void) => {
setBusy(label);
try {
const d = await api("/api/approved", { body });
done?.(d);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusy(null);
}
};
const approve = () => {
const name = newName.trim();
if (!name) return;
void run("approve", { op: "approve", username: name }, () => {
toast(`${name} approved — the registration window is open`, "success");
setNewName("");
});
};
const players = data?.approved ?? [];
const outsideWhitelist = (data?.whitelist ?? []).filter(
(w) => !players.some((p) => p.username.toLowerCase() === w.toLowerCase())
);
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold">Access &amp; Auth</h1>
<p className="mt-0.5 text-xs text-ink2">
Only approved usernames can join. New players must <code>/register</code> within the window, then{" "}
<code>/login</code> on every visit.
</p>
</div>
{/* approve box */}
<Card>
<div className="flex flex-wrap items-end gap-3">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-ink2">Minecraft username to approve</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && approve()}
placeholder="e.g. Steve_123"
className="w-full font-mono"
maxLength={16}
/>
</div>
<Button variant="primary" onClick={approve} busy={busy === "approve"}>
<UserPlus size={14} /> Approve &amp; open window
</Button>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-muted">
Approving whitelists the name and starts the registration countdown. Tell your friend to join{" "}
<em>right away</em> and run <code>/register &lt;password&gt; &lt;password&gt;</code>. If the timer
expires first, the name is automatically removed from the whitelist.
</p>
</Card>
{error && (
<div className="rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm text-warn">{error}</div>
)}
{/* player table */}
<Card
title={`Approved players (${players.length})`}
actions={
<Button
size="sm"
onClick={() =>
void run("import", { op: "import-whitelist" }, (d) => {
const n = (d as { imported: number }).imported;
toast(n > 0 ? `Imported ${n} whitelist entr${n === 1 ? "y" : "ies"}` : "Nothing new to import", "info");
})
}
busy={busy === "import"}
title="Pull in players whitelisted outside the panel"
>
<Import size={13} /> Import whitelist
</Button>
}
pad={false}
>
{players.length === 0 ? (
<EmptyState
icon={<ShieldCheck size={28} />}
title="Nobody approved yet"
hint="Approve your friends' usernames above. Nobody else can join the server."
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Player</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Last seen</th>
<th className="px-4 py-2 font-medium">Registered</th>
<th className="px-4 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{players.map((p) => {
const badge = STATUS_BADGE[p.status];
return (
<tr key={p.id} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-mono font-medium">{p.username}</td>
<td className="px-4 py-2.5">
<Badge color={badge.color}>{badge.label}</Badge>
{p.status === "pending" && p.window_expires_at && (
<span className="ml-2 font-mono text-xs text-warn">
{fmtCountdown(p.window_expires_at)}
</span>
)}
{p.note && <span className="ml-2 text-[11px] text-muted">({p.note})</span>}
</td>
<td className="px-4 py-2.5 text-ink2">{timeAgo(p.last_seen_at)}</td>
<td className="px-4 py-2.5 text-ink2">{fmtDateTime(p.registered_at)}</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1.5">
{(p.status === "expired" || p.status === "revoked") && (
<Button
size="sm"
onClick={() =>
void run(`reopen-${p.username}`, { op: "reopen", username: p.username }, () =>
toast(`Window re-opened for ${p.username}`, "success")
)
}
busy={busy === `reopen-${p.username}`}
>
<RefreshCw size={12} /> Re-open window
</Button>
)}
{p.status === "registered" && (
<>
<Button
size="sm"
onClick={() =>
void run(
`reset-${p.username}`,
{ op: "reset-password", username: p.username },
(d) =>
setTempPassword({
user: p.username,
password: (d as { tempPassword: string }).tempPassword,
})
)
}
busy={busy === `reset-${p.username}`}
title="Set a temporary password and share it with them"
>
<KeyRound size={12} /> Reset password
</Button>
</>
)}
{p.status !== "revoked" && (
<Button size="sm" variant="danger" onClick={() => setConfirmRevoke(p.username)}>
Revoke
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</Card>
{/* whitelist drift warning */}
{outsideWhitelist.length > 0 && (
<Card title="On the whitelist but not managed here">
<div className="flex flex-wrap items-center gap-2">
{outsideWhitelist.map((w) => (
<Badge key={w} color="warn">
{w}
</Badge>
))}
<span className="text-xs text-muted">— use “Import whitelist” to manage them from the panel.</span>
</div>
</Card>
)}
{/* temp password modal */}
<Modal
open={tempPassword !== null}
onClose={() => setTempPassword(null)}
title={`Temporary password for ${tempPassword?.user}`}
>
<p className="text-sm text-ink2">
Share this privately. They log in with it and can change it in game via{" "}
<code>/account</code>.
</p>
<div className="mt-3 flex items-center gap-2 rounded-lg border border-border bg-surface2 px-3 py-2">
<code className="flex-1 font-mono text-lg tracking-wide text-accent">{tempPassword?.password}</code>
<Button
size="sm"
onClick={() => {
void navigator.clipboard.writeText(tempPassword?.password ?? "");
toast("Copied to clipboard", "success");
}}
>
<Copy size={12} /> Copy
</Button>
</div>
<p className="mt-3 text-[11px] text-muted">
This password is shown once and never stored by the panel.
</p>
</Modal>
{/* revoke confirm modal */}
<Modal open={confirmRevoke !== null} onClose={() => setConfirmRevoke(null)} title={`Revoke ${confirmRevoke}?`}>
<p className="text-sm text-ink2">
This removes them from the whitelist, deletes their password account, and kicks them if online. You
can re-approve them later.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setConfirmRevoke(null)}>Cancel</Button>
<Button
variant="danger"
busy={busy === `revoke-${confirmRevoke}`}
onClick={() => {
const name = confirmRevoke!;
setConfirmRevoke(null);
void run(`revoke-${name}`, { op: "revoke", username: name }, () =>
toast(`${name} revoked`, "warn")
);
}}
>
Revoke access
</Button>
</div>
</Modal>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { Archive, Download, Trash2, History, PlayCircle } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Input, EmptyState, Modal } from "@/components/ui";
import { fmtBytes, fmtDateTime, timeAgo } from "@/lib/format";
type BackupsData = {
backups: Array<{ name: string; size: number; mtime: number }>;
totalSize: number;
interval: string;
retentionDays: string;
};
export default function BackupsPage() {
const { data, refresh } = useApi<BackupsData>("/api/backups", 30000);
const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [restoreTarget, setRestoreTarget] = useState<string | null>(null);
const [restoreConfirm, setRestoreConfirm] = useState("");
const [restoring, setRestoring] = useState(false);
const backupNow = async () => {
setCreating(true);
toast("Backup started — this can take a minute on a big world", "info");
try {
const res = await api<{ created: string }>("/api/backups", { method: "POST" });
toast(`Backup created: ${res.created}`, "success");
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setCreating(false);
}
};
const remove = async (name: string) => {
setDeleting(name);
try {
await api(`/api/backups/file?name=${encodeURIComponent(name)}`, { method: "DELETE" });
toast(`${name} deleted`, "warn");
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setDeleting(null);
}
};
const restore = async () => {
if (!restoreTarget) return;
setRestoring(true);
try {
await api("/api/backups/restore", { body: { name: restoreTarget, confirm: restoreConfirm } });
toast("Restore complete — the server is starting with the restored world", "success");
setRestoreTarget(null);
setRestoreConfirm("");
} catch (err) {
toast((err as Error).message, "error");
} finally {
setRestoring(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Backups</h1>
<p className="mt-0.5 text-xs text-ink2">
Automatic every {data?.interval ?? "24h"}, kept {data?.retentionDays ?? "7"} days ·{" "}
{fmtBytes(data?.totalSize)} total — change in{" "}
<a href="/config" className="text-accent underline">
Configuration
</a>
</p>
</div>
<Button variant="primary" onClick={backupNow} busy={creating}>
<Archive size={14} /> Back up now
</Button>
</div>
<Card pad={false}>
{!data || data.backups.length === 0 ? (
<EmptyState
icon={<Archive size={28} />}
title="No backups yet"
hint='The backup sidecar creates the first one automatically shortly after the server starts, or press "Back up now".'
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Backup</th>
<th className="px-4 py-2 font-medium">Created</th>
<th className="px-4 py-2 font-medium">Size</th>
<th className="px-4 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{data.backups.map((b) => (
<tr key={b.name} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-mono text-xs font-medium">{b.name}</td>
<td className="px-4 py-2.5 text-ink2" title={fmtDateTime(b.mtime)}>
{timeAgo(b.mtime)}
</td>
<td className="px-4 py-2.5 text-ink2">{fmtBytes(b.size)}</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1.5">
<a href={`/api/backups/file?name=${encodeURIComponent(b.name)}`}>
<Button size="sm" title="Download">
<Download size={12} />
</Button>
</a>
<Button size="sm" variant="warn" onClick={() => setRestoreTarget(b.name)} title="Restore">
<History size={12} /> Restore
</Button>
<Button
size="sm"
variant="danger"
busy={deleting === b.name}
onClick={() => void remove(b.name)}
title="Delete"
>
<Trash2 size={12} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
<Modal
open={restoreTarget !== null}
onClose={() => {
setRestoreTarget(null);
setRestoreConfirm("");
}}
title="Restore backup"
>
<div className="space-y-3 text-sm text-ink2">
<p>
Restoring <code className="text-ink">{restoreTarget}</code> will:
</p>
<ol className="list-decimal space-y-1 pl-5 text-xs">
<li>Stop the server (players are disconnected)</li>
<li>
Overwrite the current world and settings with the backup —{" "}
<span className="text-warn">everything since the backup is lost</span>
</li>
<li>Start the server again</li>
</ol>
<p className="text-xs">
Type <code className="text-warn">restore</code> to confirm:
</p>
<Input
value={restoreConfirm}
onChange={(e) => setRestoreConfirm(e.target.value)}
placeholder="restore"
className="w-full font-mono"
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setRestoreTarget(null)}>Cancel</Button>
<Button variant="danger" disabled={restoreConfirm !== "restore"} busy={restoring} onClick={restore}>
<PlayCircle size={13} /> Restore this backup
</Button>
</div>
</Modal>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState } from "react";
import { Rocket, AlertTriangle } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Input, Modal, Toggle } from "@/components/ui";
type ConfigValues = Record<string, string>;
const FIELDS: Array<{
key: string;
label: string;
hint?: string;
section: "server" | "gameplay" | "resources" | "backups";
placeholder?: string;
}> = [
{ key: "MC_VERSION", label: "Minecraft version", hint: "e.g. 26.2 — change, then Apply to upgrade", section: "server" },
{ key: "SERVER_TYPE", label: "Server type / loader", hint: "FABRIC (default), NEOFORGE, FORGE, VANILLA…", section: "server" },
{ key: "MC_MOTD", label: "MOTD (server list text)", section: "server" },
{ key: "MC_PORT", label: "Game port", section: "server" },
{ key: "MODRINTH_MODPACK", label: "Modrinth modpack slug", hint: "leave empty for plain Fabric — see docs before using", section: "server" },
{ key: "MC_DIFFICULTY", label: "Difficulty", hint: "peaceful / easy / normal / hard", section: "gameplay" },
{ key: "MC_MAX_PLAYERS", label: "Max players", section: "gameplay" },
{ key: "MC_VIEW_DISTANCE", label: "View distance", hint: "chunks — lower = less RAM/CPU", section: "gameplay" },
{ key: "MC_SIMULATION_DISTANCE", label: "Simulation distance", section: "gameplay" },
{ key: "MC_SEED", label: "World seed", hint: "only applies to newly generated worlds", section: "gameplay" },
{ key: "MC_MEMORY", label: "Java heap", hint: "e.g. 6G — leave ~1 GB headroom below the container limit", section: "resources" },
{ key: "MC_CONTAINER_MEM_LIMIT", label: "Container memory limit", hint: "e.g. 7g", section: "resources" },
{ key: "TZ", label: "Timezone", hint: "e.g. Asia/Kolkata — used by schedules and backups", section: "resources" },
{ key: "BACKUP_INTERVAL", label: "Backup interval", hint: "e.g. 6h, 24h", section: "backups" },
{ key: "BACKUP_RETENTION_DAYS", label: "Keep backups (days)", section: "backups" },
];
const SECTIONS = [
{ id: "server", title: "Server" },
{ id: "gameplay", title: "Gameplay" },
{ id: "resources", title: "Resources & time" },
{ id: "backups", title: "Backups" },
] as const;
export default function ConfigPage() {
const { data, refresh } = useApi<ConfigValues>("/api/config", 0);
const [values, setValues] = useState<ConfigValues>({});
const [saving, setSaving] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
const [applying, setApplying] = useState(false);
const [backupFirst, setBackupFirst] = useState(true);
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (data) setValues(data);
}, [data]);
const set = (key: string, value: string) => {
setValues((v) => ({ ...v, [key]: value }));
setDirty(true);
};
const save = async () => {
setSaving(true);
try {
const updates: ConfigValues = {};
for (const f of FIELDS) updates[f.key] = values[f.key] ?? "";
await api("/api/config", { body: { updates } });
toast("Saved to .env — apply to make it live", "success");
setDirty(false);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setSaving(false);
}
};
const apply = async () => {
setApplying(true);
try {
await api("/api/config/apply", { body: { service: "mc", backupFirst } });
await api("/api/config/apply", { body: { service: "backup" } });
toast("Applied — containers recreated with the new configuration", "success");
setApplyOpen(false);
} catch (err) {
toast((err as Error).message, "error");
} finally {
setApplying(false);
}
};
const versionChanged = data && values["MC_VERSION"] !== data["MC_VERSION"];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Configuration</h1>
<p className="mt-0.5 text-xs text-ink2">
Values live in <code>.env</code>. Save writes the file; Apply recreates the server so they take
effect. Raw files (server.properties, EasyAuth) are editable in{" "}
<a href="/files" className="text-accent underline">
Files
</a>
.
</p>
</div>
<div className="flex gap-2">
<Button onClick={save} busy={saving} disabled={!dirty}>
Save
</Button>
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={dirty}>
<Rocket size={14} /> Apply (restarts server)
</Button>
</div>
</div>
{versionChanged && (
<div className="flex items-center gap-2 rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm text-warn">
<AlertTriangle size={15} />
Version change detected ({data?.["MC_VERSION"]} → {values["MC_VERSION"]}). Save, then Apply with the
safety backup enabled. Mods are automatically re-resolved for the new version.
</div>
)}
<div className="grid gap-4 lg:grid-cols-2">
{SECTIONS.map((section) => (
<Card key={section.id} title={section.title}>
<div className="space-y-3">
{FIELDS.filter((f) => f.section === section.id).map((f) => (
<div key={f.key}>
<label className="mb-1 block text-xs font-medium text-ink2">{f.label}</label>
<Input
value={values[f.key] ?? ""}
onChange={(e) => set(f.key, e.target.value)}
className="w-full font-mono"
placeholder={f.placeholder}
/>
{f.hint && <p className="mt-1 text-[10px] text-muted">{f.hint}</p>}
</div>
))}
</div>
</Card>
))}
</div>
<Modal open={applyOpen} onClose={() => setApplyOpen(false)} title="Apply configuration?">
<div className="space-y-3 text-sm text-ink2">
<p>
The server (and backup sidecar) containers are recreated with the saved <code>.env</code>. Players
online are disconnected while the server restarts.
</p>
<Toggle checked={backupFirst} onChange={setBackupFirst} label="Take a safety backup first (recommended)" />
</div>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setApplyOpen(false)}>Cancel</Button>
<Button variant="primary" busy={applying} onClick={apply}>
Apply now
</Button>
</div>
</Modal>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { SendHorizonal, ArrowDownToLine, Eraser } from "lucide-react";
import { api, useSSE, toast } from "@/lib/api";
import { Button, Input } from "@/components/ui";
const QUICK_COMMANDS = [
"list",
"whitelist list",
"auth list",
"spark tps",
"save-all",
"time set day",
"weather clear",
];
function colorFor(line: string): string {
if (/ERROR|FATAL|Exception|Crash/i.test(line)) return "text-crit";
if (/WARN/i.test(line)) return "text-warn";
if (/joined the game|registered|Done \(/.test(line)) return "text-good";
if (line.startsWith("> ")) return "text-accent";
return "text-ink2";
}
export default function ConsolePage() {
const [lines, setLines] = useState<string[]>([]);
const [command, setCommand] = useState("");
const [history, setHistory] = useState<string[]>([]);
const [histIdx, setHistIdx] = useState(-1);
const [filter, setFilter] = useState("");
const [follow, setFollow] = useState(true);
const [busy, setBusy] = useState(false);
const paneRef = useRef<HTMLDivElement>(null);
useSSE("/api/stream/logs", {
message: (line) => {
setLines((xs) => {
const next = [...xs, line as string];
return next.length > 1500 ? next.slice(-1200) : next;
});
},
});
useEffect(() => {
if (follow && paneRef.current) {
paneRef.current.scrollTop = paneRef.current.scrollHeight;
}
}, [lines, follow]);
const onScroll = () => {
const el = paneRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
setFollow(atBottom);
};
const send = async (cmd?: string) => {
const c = (cmd ?? command).trim();
if (!c) return;
setBusy(true);
try {
await api("/api/console", { body: { command: c } });
setHistory((h) => [c, ...h.filter((x) => x !== c)].slice(0, 50));
setHistIdx(-1);
setCommand("");
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusy(false);
}
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
void send();
} else if (e.key === "ArrowUp") {
e.preventDefault();
const next = Math.min(histIdx + 1, history.length - 1);
if (history[next]) {
setHistIdx(next);
setCommand(history[next]);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
const next = histIdx - 1;
setHistIdx(next);
setCommand(next >= 0 ? history[next] : "");
}
};
const visible = filter ? lines.filter((l) => l.toLowerCase().includes(filter.toLowerCase())) : lines;
return (
<div className="flex h-[calc(100vh-7.5rem)] flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Console</h1>
<p className="mt-0.5 text-xs text-ink2">Live server log with RCON command input</p>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="Filter lines…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-48"
/>
<Button size="sm" onClick={() => setLines([])}>
<Eraser size={13} /> Clear
</Button>
{!follow && (
<Button
size="sm"
variant="primary"
onClick={() => {
setFollow(true);
paneRef.current?.scrollTo({ top: paneRef.current.scrollHeight });
}}
>
<ArrowDownToLine size={13} /> Follow
</Button>
)}
</div>
</div>
<div
ref={paneRef}
onScroll={onScroll}
className="scroll-slim flex-1 overflow-y-auto rounded-xl border border-border bg-[#070a0e] p-3 font-mono text-[12px] leading-[1.55]"
>
{visible.length === 0 && (
<div className="py-10 text-center text-muted">
Waiting for log output… (is the server running?)
</div>
)}
{visible.map((line, i) => (
<div key={i} className={`whitespace-pre-wrap break-all ${colorFor(line)}`}>
{line}
</div>
))}
</div>
<div className="space-y-2">
<div className="flex gap-2">
<div className="relative flex-1">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-accent">
&gt;
</span>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Type a server command… (↑ for history)"
className="w-full pl-7 font-mono"
spellCheck={false}
/>
</div>
<Button variant="primary" onClick={() => void send()} busy={busy}>
<SendHorizonal size={14} /> Send
</Button>
</div>
<div className="flex flex-wrap gap-1.5">
{QUICK_COMMANDS.map((c) => (
<button
key={c}
onClick={() => void send(c)}
className="rounded-md border border-border bg-surface2 px-2 py-0.5 font-mono text-[11px] text-ink2 hover:text-ink"
>
{c}
</button>
))}
</div>
</div>
</div>
);
}
+304
View File
@@ -0,0 +1,304 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Folder,
File as FileIcon,
ChevronRight,
Home,
Upload,
FolderPlus,
Trash2,
Download,
Save,
X,
} from "lucide-react";
import CodeMirror from "@uiw/react-codemirror";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { oneDark } from "@codemirror/theme-one-dark";
import { api, toast } from "@/lib/api";
import { Card, Button, EmptyState, Input, Modal } from "@/components/ui";
import { fmtBytes, fmtDateTime } from "@/lib/format";
type Entry = { name: string; type: "file" | "dir"; size: number; mtime: number };
const TEXT_EXT = /\.(txt|json|json5|yml|yaml|toml|conf|cfg|properties|log|mcmeta|nbt\.txt|md|sh|env|ini|snbt)$/i;
function langFor(name: string) {
if (/\.json5?(\.|$)/i.test(name) || name.endsWith(".mcmeta")) return [json()];
if (/\.ya?ml$/i.test(name)) return [yaml()];
return [];
}
export default function FilesPage() {
const [path, setPath] = useState("");
const [entries, setEntries] = useState<Entry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<{ path: string; content: string; dirty: boolean; truncated: boolean } | null>(null);
const [saving, setSaving] = useState(false);
const [mkdirOpen, setMkdirOpen] = useState(false);
const [mkdirName, setMkdirName] = useState("");
const [confirmDelete, setConfirmDelete] = useState<Entry | null>(null);
const uploadRef = useRef<HTMLInputElement>(null);
const load = useCallback(async (p: string) => {
setError(null);
try {
setEntries(await api<Entry[]>(`/api/files?dir=${encodeURIComponent(p)}`));
} catch (err) {
setEntries([]);
setError((err as Error).message);
}
}, []);
useEffect(() => {
void load(path);
}, [path, load]);
const open = async (entry: Entry) => {
const full = path ? `${path}/${entry.name}` : entry.name;
if (entry.type === "dir") {
setPath(full);
return;
}
if (!TEXT_EXT.test(entry.name) && entry.size > 512 * 1024) {
window.location.href = `/api/files/download?path=${encodeURIComponent(full)}`;
return;
}
try {
const d = await api<{ content: string; truncated: boolean }>(`/api/files?file=${encodeURIComponent(full)}`);
setEditing({ path: full, content: d.content, dirty: false, truncated: d.truncated });
} catch (err) {
toast((err as Error).message, "error");
}
};
const save = async () => {
if (!editing) return;
setSaving(true);
try {
await api("/api/files", { body: { op: "write", path: editing.path, content: editing.content } });
toast(`Saved ${editing.path}`, "success");
setEditing({ ...editing, dirty: false });
void load(path);
} catch (err) {
toast((err as Error).message, "error");
} finally {
setSaving(false);
}
};
const upload = async (file: File) => {
const form = new FormData();
form.append("dir", path);
form.append("file", file);
toast(`Uploading ${file.name}…`, "info");
const res = await fetch("/api/files/upload", { method: "POST", body: form });
const payload = await res.json();
if (!res.ok || payload.ok === false) {
toast(payload.error ?? "Upload failed", "error");
} else {
toast(`${file.name} uploaded`, "success");
void load(path);
}
};
const crumbs = path ? path.split("/") : [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Files</h1>
<p className="mt-0.5 text-xs text-ink2">Server data directory — world, configs, mods, logs</p>
</div>
<div className="flex gap-2">
<input
ref={uploadRef}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void upload(f);
e.target.value = "";
}}
/>
<Button size="sm" onClick={() => uploadRef.current?.click()}>
<Upload size={13} /> Upload here
</Button>
<Button size="sm" onClick={() => setMkdirOpen(true)}>
<FolderPlus size={13} /> New folder
</Button>
</div>
</div>
{/* breadcrumbs */}
<div className="flex flex-wrap items-center gap-1 text-sm">
<button onClick={() => setPath("")} className="flex items-center gap-1 text-ink2 hover:text-ink">
<Home size={13} /> data
</button>
{crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight size={12} className="text-muted" />
<button
onClick={() => setPath(crumbs.slice(0, i + 1).join("/"))}
className={i === crumbs.length - 1 ? "font-medium text-ink" : "text-ink2 hover:text-ink"}
>
{c}
</button>
</span>
))}
</div>
{error && <div className="rounded-xl border border-crit/40 bg-crit/10 px-4 py-3 text-sm text-crit">{error}</div>}
<Card pad={false}>
{entries === null ? (
<EmptyState title="Loading…" />
) : entries.length === 0 && !error ? (
<EmptyState icon={<Folder size={28} />} title="Empty folder" />
) : (
<table className="w-full text-sm">
<tbody>
{entries.map((e) => (
<tr key={e.name} className="border-b border-border/50 last:border-0 hover:bg-surface2">
<td className="cursor-pointer px-4 py-2" onClick={() => void open(e)}>
<span className="flex items-center gap-2.5">
{e.type === "dir" ? (
<Folder size={15} className="text-series1" />
) : (
<FileIcon size={15} className="text-muted" />
)}
<span className={e.type === "dir" ? "font-medium" : ""}>{e.name}</span>
</span>
</td>
<td className="w-28 px-4 py-2 text-xs text-muted">{e.type === "file" ? fmtBytes(e.size) : "—"}</td>
<td className="w-40 px-4 py-2 text-xs text-muted">{fmtDateTime(e.mtime)}</td>
<td className="w-24 px-4 py-2">
<div className="flex justify-end gap-1">
{e.type === "file" && (
<a
href={`/api/files/download?path=${encodeURIComponent(path ? `${path}/${e.name}` : e.name)}`}
className="rounded p-1 text-muted hover:text-ink"
title="Download"
>
<Download size={13} />
</a>
)}
<button
onClick={() => setConfirmDelete(e)}
className="rounded p-1 text-muted hover:text-crit"
title={e.type === "dir" ? "Delete (empty folders only)" : "Delete"}
>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
{/* editor modal */}
{editing && (
<div className="fixed inset-0 z-50 flex flex-col bg-black/70 p-4 md:p-10">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-surface">
<header className="flex items-center justify-between border-b border-border px-4 py-2.5">
<div className="flex items-center gap-2 font-mono text-sm">
{editing.path}
{editing.dirty && <span className="text-warn">●</span>}
{editing.truncated && <span className="text-[10px] text-warn">(truncated — file over 2 MB)</span>}
</div>
<div className="flex gap-2">
<Button size="sm" variant="primary" onClick={save} busy={saving} disabled={!editing.dirty || editing.truncated}>
<Save size={13} /> Save
</Button>
<Button size="sm" onClick={() => setEditing(null)}>
<X size={13} /> Close
</Button>
</div>
</header>
<div className="scroll-slim min-h-0 flex-1 overflow-auto">
<CodeMirror
value={editing.content}
onChange={(v) => setEditing((e) => (e ? { ...e, content: v, dirty: true } : e))}
extensions={langFor(editing.path)}
theme={oneDark}
height="100%"
style={{ fontSize: 12, height: "100%" }}
/>
</div>
</div>
</div>
)}
{/* new folder modal */}
<Modal open={mkdirOpen} onClose={() => setMkdirOpen(false)} title="New folder">
<Input
value={mkdirName}
onChange={(e) => setMkdirName(e.target.value)}
placeholder="folder name"
className="w-full"
onKeyDown={async (e) => {
if (e.key === "Enter" && mkdirName.trim()) {
await api("/api/files", {
body: { op: "mkdir", path: path ? `${path}/${mkdirName.trim()}` : mkdirName.trim() },
});
setMkdirOpen(false);
setMkdirName("");
void load(path);
}
}}
/>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setMkdirOpen(false)}>Cancel</Button>
<Button
variant="primary"
onClick={async () => {
if (!mkdirName.trim()) return;
await api("/api/files", {
body: { op: "mkdir", path: path ? `${path}/${mkdirName.trim()}` : mkdirName.trim() },
});
setMkdirOpen(false);
setMkdirName("");
void load(path);
}}
>
Create
</Button>
</div>
</Modal>
{/* delete confirm */}
<Modal open={confirmDelete !== null} onClose={() => setConfirmDelete(null)} title={`Delete ${confirmDelete?.name}?`}>
<p className="text-sm text-ink2">
{confirmDelete?.type === "dir"
? "Only empty folders can be deleted from the panel."
: "This permanently deletes the file from the server data directory."}
</p>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setConfirmDelete(null)}>Cancel</Button>
<Button
variant="danger"
onClick={async () => {
const e = confirmDelete!;
setConfirmDelete(null);
try {
await api("/api/files", { body: { op: "delete", path: path ? `${path}/${e.name}` : e.name } });
toast(`${e.name} deleted`, "warn");
void load(path);
} catch (err) {
toast((err as Error).message, "error");
}
}}
>
Delete
</Button>
</div>
</Modal>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Shell } from "@/components/shell";
export default function PanelLayout({ children }: { children: React.ReactNode }) {
return <Shell>{children}</Shell>;
}
+267
View File
@@ -0,0 +1,267 @@
"use client";
import { useState } from "react";
import { Package, Search, Trash2, Power, Download, RefreshCw, Lock } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Badge, Input, EmptyState, Modal } from "@/components/ui";
import { fmtBytes } from "@/lib/format";
type ModsData = {
installed: Array<{
file: string;
name: string;
id: string | null;
version: string | null;
disabled: boolean;
size: number;
}>;
managed: string[];
mcVersion: string;
};
type SearchHit = {
slug: string;
title: string;
description: string;
downloads: number;
icon_url: string | null;
server_side: string;
client_side: string;
};
const PROTECTED = new Set(["fabric-api", "easyauth"]);
export default function ModsPage() {
const { data, refresh } = useApi<ModsData>("/api/mods", 0);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<SearchHit[] | null>(null);
const [searching, setSearching] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [applying, setApplying] = useState(false);
const [needsApply, setNeedsApply] = useState(false);
const [confirmApply, setConfirmApply] = useState(false);
const search = async () => {
if (!query.trim()) return;
setSearching(true);
try {
setHits(await api<SearchHit[]>(`/api/mods/search?q=${encodeURIComponent(query.trim())}`));
} catch (err) {
toast((err as Error).message, "error");
} finally {
setSearching(false);
}
};
const manage = async (label: string, body: Record<string, unknown>, message: string) => {
setBusy(label);
try {
const res = await api<{ needsApply?: boolean; needsRestart?: boolean }>("/api/mods/manage", { body });
toast(message, "success");
if (res.needsApply || res.needsRestart) setNeedsApply(true);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusy(null);
}
};
const apply = async () => {
setConfirmApply(false);
setApplying(true);
try {
await api("/api/mods/apply", { method: "POST" });
toast("Server recreated — mods re-resolved for the current version", "success");
setNeedsApply(false);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setApplying(false);
}
};
const managedSet = new Set((data?.managed ?? []).map((s) => s.toLowerCase()));
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Mods</h1>
<p className="mt-0.5 text-xs text-ink2">
Managed mods auto-update to the newest build for Minecraft {data?.mcVersion ?? "…"} on every apply
</p>
</div>
<Button variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
<RefreshCw size={14} /> {needsApply ? "Apply changes (restarts server)" : "Update all & restart"}
</Button>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* Modrinth search */}
<Card title="Add from Modrinth">
<div className="flex gap-2">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void search()}
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.2"}…`}
className="w-full"
/>
<Button onClick={() => void search()} busy={searching}>
<Search size={14} />
</Button>
</div>
<div className="scroll-slim mt-3 max-h-96 space-y-2 overflow-y-auto">
{hits === null && (
<p className="py-6 text-center text-xs text-muted">
Search Modrinth — results are pre-filtered to Fabric + your server version.
</p>
)}
{hits?.length === 0 && <p className="py-6 text-center text-xs text-muted">No compatible mods found.</p>}
{hits?.map((h) => {
const already = managedSet.has(h.slug.toLowerCase());
return (
<div key={h.slug} className="flex items-center gap-3 rounded-lg border border-border bg-surface2 p-2.5">
{h.icon_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={h.icon_url} alt="" className="h-9 w-9 rounded-md bg-surface3" />
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-surface3">
<Package size={16} className="text-muted" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{h.title}</span>
{h.client_side === "required" && (
<Badge color="warn">needs client install</Badge>
)}
</div>
<div className="truncate text-[11px] text-muted">{h.description}</div>
</div>
<Button
size="sm"
variant={already ? "ghost" : "primary"}
disabled={already}
busy={busy === `add-${h.slug}`}
onClick={() => void manage(`add-${h.slug}`, { op: "add", slug: h.slug }, `${h.title} added`)}
>
<Download size={12} /> {already ? "Added" : "Add"}
</Button>
</div>
);
})}
</div>
</Card>
{/* managed list */}
<Card title={`Managed mods (${data?.managed.length ?? 0})`}>
<p className="mb-3 text-[11px] leading-relaxed text-muted">
These Modrinth projects are pinned in <code>.env</code> (MODRINTH_PROJECTS). The server resolves the
newest compatible build on every start, so updating Minecraft also updates these automatically.
</p>
<ul className="space-y-1.5">
{(data?.managed ?? []).map((slug) => (
<li key={slug} className="flex items-center gap-2 rounded-lg border border-border bg-surface2 px-3 py-2">
<span className="font-mono text-sm">{slug}</span>
{PROTECTED.has(slug.toLowerCase()) && (
<Badge color="info">
<Lock size={9} /> required
</Badge>
)}
<span className="ml-auto">
{!PROTECTED.has(slug.toLowerCase()) && (
<Button
size="sm"
variant="danger"
busy={busy === `remove-${slug}`}
onClick={() => void manage(`remove-${slug}`, { op: "remove", slug }, `${slug} removed`)}
>
<Trash2 size={12} />
</Button>
)}
</span>
</li>
))}
</ul>
</Card>
</div>
{/* installed jars */}
<Card title="Installed jar files" pad={false}>
{!data || data.installed.length === 0 ? (
<EmptyState
icon={<Package size={28} />}
title="No mod jars found"
hint="Jars appear here once the server has started and downloaded its mods. You can also upload jars via the Files page (mods/ folder)."
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Mod</th>
<th className="px-4 py-2 font-medium">Version</th>
<th className="px-4 py-2 font-medium">File</th>
<th className="px-4 py-2 font-medium">Size</th>
<th className="px-4 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{data.installed.map((m) => (
<tr key={m.file} className={`border-b border-border/50 last:border-0 ${m.disabled ? "opacity-50" : ""}`}>
<td className="px-4 py-2.5 font-medium">
{m.name} {m.disabled && <Badge color="muted">disabled</Badge>}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-ink2">{m.version ?? "—"}</td>
<td className="px-4 py-2.5 font-mono text-[11px] text-muted">{m.file}</td>
<td className="px-4 py-2.5 text-ink2">{fmtBytes(m.size)}</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1.5">
<Button
size="sm"
busy={busy === `toggle-${m.file}`}
onClick={() =>
void manage(
`toggle-${m.file}`,
{ op: "toggle", file: m.file },
m.disabled ? `${m.name} enabled` : `${m.name} disabled`
)
}
title={m.disabled ? "Enable" : "Disable (renames to .disabled)"}
>
<Power size={12} />
</Button>
<Button
size="sm"
variant="danger"
busy={busy === `delete-${m.file}`}
onClick={() => void manage(`delete-${m.file}`, { op: "delete", file: m.file }, `${m.file} deleted`)}
>
<Trash2 size={12} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
<Modal open={confirmApply} onClose={() => setConfirmApply(false)} title="Apply mod changes?">
<p className="text-sm leading-relaxed text-ink2">
This recreates the server container: online players are disconnected for a minute or two while the
server restarts and re-resolves every managed mod against Minecraft {data?.mcVersion}.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setConfirmApply(false)}>Cancel</Button>
<Button variant="primary" onClick={() => void apply()}>
Apply &amp; restart
</Button>
</div>
</Modal>
</div>
);
}
+264
View File
@@ -0,0 +1,264 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Play, Square, RotateCw, HardDrive, Globe2, Cuboid } from "lucide-react";
import { api, useApi, useSSE, toast } from "@/lib/api";
import { Card, StatTile, Button, Badge, EmptyState } from "@/components/ui";
import { Sparkline, UsageBar, type Point } from "@/components/charts";
import { fmtBytes, fmtDuration, timeAgo, fmtCountdown } from "@/lib/format";
type ServerData = {
dockerAvailable: boolean;
container: { exists: boolean; running: boolean; status: string; health?: string };
players: { online: number; max: number; names: string[] };
version: string | null;
serverType: string | null;
metrics: Record<string, number | null> | null;
pendingApprovals: number;
world: { seed: string | null; sizeBytes: number | null; dimensions: string[]; levelName: string | null };
uptimeMs: number | null;
};
type MetricRow = {
bucket: number;
cpu: number | null;
mem_used: number | null;
mem_limit: number | null;
tps: number | null;
players: number | null;
disk_used: number | null;
disk_free: number | null;
};
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
type ApprovedRow = { username: string; status: string; window_expires_at: number | null };
const ACTIVITY_LABELS: Record<string, { label: string; color: "good" | "warn" | "crit" | "info" | "muted" }> = {
join: { label: "joined", color: "good" },
leave: { label: "left", color: "muted" },
connect: { label: "connecting", color: "info" },
register: { label: "registered", color: "good" },
approve: { label: "approved", color: "info" },
expire: { label: "window expired", color: "warn" },
revoke: { label: "revoked", color: "crit" },
server_online: { label: "server online", color: "good" },
server_stopping: { label: "server stopping", color: "warn" },
backup: { label: "backup", color: "info" },
restore: { label: "restore", color: "warn" },
power: { label: "power", color: "muted" },
console: { label: "console", color: "muted" },
world_reset: { label: "world reset", color: "crit" },
};
export default function OverviewPage() {
const { data: server, refresh } = useApi<ServerData>("/api/server", 10000);
const { data: metricRows, refresh: refreshMetrics } = useApi<MetricRow[]>("/api/metrics?range=1h", 30000);
const { data: activity, refresh: refreshActivity } = useApi<ActivityRow[]>("/api/activity?limit=14", 20000);
const { data: approvedData, refresh: refreshApproved } = useApi<{ approved: ApprovedRow[] }>(
"/api/approved",
15000
);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [, forceTick] = useState(0);
useSSE("/api/stream/events", {
"mc-event": () => {
void refreshActivity();
void refreshApproved();
},
metrics: () => forceTick((x) => x + 1),
});
const power = async (action: "start" | "stop" | "restart") => {
setBusyAction(action);
try {
await api("/api/server/power", { body: { action } });
toast(`Server ${action} requested`, "success");
setTimeout(() => void refresh(), 1500);
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusyAction(null);
}
};
const running = server?.container.running ?? false;
const m = server?.metrics;
const points = (key: keyof MetricRow): Point[] =>
(metricRows ?? []).map((r) => ({ ts: r.bucket, v: r[key] as number | null }));
const pending = (approvedData?.approved ?? []).filter((a) => a.status === "pending");
return (
<div className="space-y-4">
{/* header row */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Overview</h1>
<p className="mt-0.5 text-xs text-ink2">
{server?.serverType ?? "Fabric"} · Minecraft {server?.version ?? "…"}
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
</p>
</div>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
onClick={() => power("start")}
busy={busyAction === "start"}
disabled={running || !server?.dockerAvailable}
>
<Play size={13} /> Start
</Button>
<Button
size="sm"
onClick={() => power("restart")}
busy={busyAction === "restart"}
disabled={!running}
>
<RotateCw size={13} /> Restart
</Button>
<Button
variant="danger"
size="sm"
onClick={() => power("stop")}
busy={busyAction === "stop"}
disabled={!running}
>
<Square size={13} /> Stop
</Button>
</div>
</div>
{!server?.dockerAvailable && server && (
<div className="rounded-xl border border-crit/40 bg-crit/10 px-4 py-3 text-sm text-crit">
Docker is unreachable from the panel. If you're running the dashboard outside the compose stack,
server control and metrics are unavailable.
</div>
)}
{/* stat tiles */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile
label="TPS"
value={m?.tps != null ? Number(m.tps).toFixed(1) : "—"}
sub="target 20.0"
accent={m?.tps != null ? (Number(m.tps) >= 18 ? "good" : Number(m.tps) >= 15 ? "warn" : "crit") : undefined}
>
<Sparkline points={points("tps")} yMax={20} color="var(--color-series2)" />
</StatTile>
<StatTile label="CPU" value={m?.cpu != null ? `${Number(m.cpu).toFixed(0)}%` : "—"} sub="of one core × cores">
<Sparkline points={points("cpu")} />
</StatTile>
<StatTile
label="Memory"
value={m?.mem_used != null ? fmtBytes(Number(m.mem_used)) : "—"}
sub={m?.mem_limit ? `limit ${fmtBytes(Number(m.mem_limit))}` : undefined}
>
<Sparkline points={points("mem_used")} yMax={m?.mem_limit ? Number(m.mem_limit) : undefined} />
</StatTile>
<StatTile
label="Players"
value={`${server?.players.online ?? 0}${server?.players.max ? ` / ${server.players.max}` : ""}`}
sub={server?.players.names.join(", ") || "nobody online"}
>
<Sparkline points={points("players")} color="var(--color-series3)" />
</StatTile>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* activity feed */}
<Card title="Recent activity">
{!activity || activity.length === 0 ? (
<EmptyState title="No activity yet" hint="Joins, registrations and server events show up here." />
) : (
<ul className="space-y-2">
{activity.map((a) => {
const meta = ACTIVITY_LABELS[a.type] ?? { label: a.type, color: "muted" as const };
return (
<li key={a.id} className="flex items-center gap-2.5 text-sm">
<Badge color={meta.color}>{meta.label}</Badge>
<span className="truncate text-ink">
{a.player && <span className="font-medium">{a.player} </span>}
<span className="text-ink2">{a.detail}</span>
</span>
<span className="ml-auto shrink-0 text-[11px] text-muted">{timeAgo(a.ts)}</span>
</li>
);
})}
</ul>
)}
</Card>
<div className="space-y-4">
{/* pending registrations */}
{pending.length > 0 && (
<Card title="Waiting to register">
<ul className="space-y-2">
{pending.map((p) => (
<li key={p.username} className="flex items-center gap-2 text-sm">
<Badge color="warn">pending</Badge>
<span className="font-medium">{p.username}</span>
<span className="ml-auto font-mono text-xs text-warn">
{p.window_expires_at ? fmtCountdown(p.window_expires_at) : ""}
</span>
</li>
))}
</ul>
<p className="mt-3 text-[11px] text-muted">
They must join and <code>/register &lt;password&gt; &lt;password&gt;</code> before the timer runs
out. Manage in{" "}
<Link href="/access" className="text-accent underline">
Access &amp; Auth
</Link>
.
</p>
</Card>
)}
{/* world + disk */}
<Card title="World & storage">
<div className="space-y-3">
<div className="flex items-center gap-3 text-sm">
<Cuboid size={15} className="text-muted" />
<span className="text-ink2">World</span>
<span className="ml-auto font-medium">
{server?.world.levelName ?? "world"} · {fmtBytes(server?.world.sizeBytes)}
</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Globe2 size={15} className="text-muted" />
<span className="text-ink2">Dimensions</span>
<span className="ml-auto font-medium">
{server?.world.dimensions.length ? server.world.dimensions.join(" · ") : "not generated yet"}
</span>
</div>
{server?.world.seed && (
<div className="flex items-center gap-3 text-sm">
<span className="text-ink2">Seed</span>
<span className="ml-auto font-mono text-xs">{server.world.seed}</span>
</div>
)}
<div className="pt-1">
{m?.disk_used != null && m?.disk_free != null ? (
<UsageBar
used={Number(m.disk_used)}
total={Number(m.disk_used) + Number(m.disk_free)}
label="Host disk"
format={fmtBytes}
/>
) : (
<div className="flex items-center gap-2 text-xs text-muted">
<HardDrive size={13} /> disk stats unavailable
</div>
)}
</div>
</div>
</Card>
</div>
</div>
</div>
);
}
@@ -0,0 +1,261 @@
"use client";
import { useMemo, useState } from "react";
import { Stethoscope, Wifi } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Badge, Tabs, EmptyState, Modal } from "@/components/ui";
import { TimeSeriesChart, Sparkline, type Point } from "@/components/charts";
import { fmtBytes } from "@/lib/format";
type MetricRow = {
bucket: number;
cpu: number | null;
mem_used: number | null;
mem_limit: number | null;
net_rx: number | null;
net_tx: number | null;
tps: number | null;
mspt: number | null;
players: number | null;
disk_used: number | null;
disk_free: number | null;
};
type LagData = {
tps: number | null;
mspt: number | null;
cpu: number | null;
serverHealthy: boolean;
verdict: "no-data" | "server-lagging" | "player-connections" | "all-good";
players: Array<{
name: string;
ping: number | null;
verdict: string;
history: Array<{ ts: number; ping: number }>;
}>;
};
const RANGES = [
{ id: "1h", label: "1 hour" },
{ id: "6h", label: "6 hours" },
{ id: "24h", label: "24 hours" },
{ id: "7d", label: "7 days" },
];
const VERDICTS: Record<LagData["verdict"], { badge: "good" | "warn" | "crit" | "muted"; title: string; hint: string }> = {
"all-good": {
badge: "good",
title: "Everything looks healthy",
hint: "The server is ticking at full speed and every player's connection is fine.",
},
"server-lagging": {
badge: "crit",
title: "The server itself is lagging",
hint: "Tick rate is below target — everyone experiences this equally. Check CPU/memory, heavy redstone/farms, or too many loaded chunks.",
},
"player-connections": {
badge: "warn",
title: "Server is fine — some connections are not",
hint: "The server is ticking normally. The players flagged below have high ping, so lag is on their network path, not your machine.",
},
"no-data": {
badge: "muted",
title: "No data yet",
hint: "Start the server and give the sampler a minute to collect TPS and ping data.",
},
};
export default function PerformancePage() {
const [range, setRange] = useState("1h");
const { data: rows } = useApi<MetricRow[]>(`/api/metrics?range=${range}`, 30000);
const { data: lag, refresh: refreshLag } = useApi<LagData>("/api/lag", 30000);
const [report, setReport] = useState<string | null>(null);
const [busyReport, setBusyReport] = useState(false);
const pts = useMemo(() => {
const make = (key: keyof MetricRow): Point[] => (rows ?? []).map((r) => ({ ts: r.bucket, v: r[key] as number | null }));
// network counters are cumulative; convert to per-second rates
const rates = (key: "net_rx" | "net_tx"): Point[] => {
const out: Point[] = [];
const data = rows ?? [];
for (let i = 1; i < data.length; i++) {
const dv = (data[i][key] ?? 0) - (data[i - 1][key] ?? 0);
const dt = (data[i].bucket - data[i - 1].bucket) / 1000;
out.push({ ts: data[i].bucket, v: dv >= 0 && dt > 0 ? dv / dt : null });
}
return out;
};
return { make, rates };
}, [rows]);
const runHealthReport = async () => {
setBusyReport(true);
try {
const d = await api<{ report: string }>("/api/lag/healthreport", { method: "POST" });
setReport(d.report);
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusyReport(false);
}
};
const verdict = VERDICTS[lag?.verdict ?? "no-data"];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Performance &amp; Lag</h1>
<p className="mt-0.5 text-xs text-ink2">Server tick health, resource usage, and who's actually lagging</p>
</div>
<Tabs tabs={RANGES} active={range} onChange={setRange} />
</div>
{/* lag verdict */}
<Card
title="Lag diagnosis"
actions={
<Button size="sm" onClick={runHealthReport} busy={busyReport}>
<Stethoscope size={13} /> Full health report
</Button>
}
>
<div className="flex flex-wrap items-start gap-4">
<Badge color={verdict.badge}>{verdict.title}</Badge>
<p className="min-w-64 flex-1 text-xs leading-relaxed text-ink2">{verdict.hint}</p>
<div className="flex gap-4 text-center">
{[
["TPS", lag?.tps != null ? lag.tps.toFixed(1) : "—"],
["MSPT", lag?.mspt != null ? `${lag.mspt.toFixed(1)} ms` : "—"],
["CPU", lag?.cpu != null ? `${lag.cpu.toFixed(0)}%` : "—"],
].map(([label, value]) => (
<div key={label as string}>
<div className="text-[10px] uppercase tracking-wide text-muted">{label}</div>
<div className="text-lg font-semibold">{value}</div>
</div>
))}
</div>
</div>
{/* per-player connections */}
<div className="mt-4 border-t border-border pt-3">
<div className="mb-2 text-xs font-medium text-ink2">Player connections (last hour)</div>
{!lag || lag.players.length === 0 ? (
<div className="text-xs text-muted">Nobody online right now.</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{lag.players.map((p) => (
<div key={p.name} className="rounded-lg border border-border bg-surface2 p-3">
<div className="flex items-center gap-2">
<Wifi
size={13}
className={
p.verdict === "bad-connection"
? "text-crit"
: p.verdict === "weak-connection"
? "text-warn"
: "text-good"
}
/>
<span className="font-mono text-sm font-medium">{p.name}</span>
<span className="ml-auto text-sm font-semibold">
{p.ping !== null ? `${p.ping} ms` : "—"}
</span>
</div>
<div className="mt-2">
<Sparkline
points={p.history.map((h) => ({ ts: h.ts, v: h.ping }))}
color={
p.verdict === "bad-connection"
? "var(--color-crit)"
: p.verdict === "weak-connection"
? "var(--color-warn)"
: "var(--color-series2)"
}
height={28}
/>
</div>
<div className="mt-1 text-[10px] text-muted">
{p.verdict === "bad-connection"
? "Their connection is struggling — it's not the server."
: p.verdict === "weak-connection"
? "Somewhat high ping — their network, not the server."
: p.verdict === "good"
? "Healthy connection."
: "No ping data yet."}
</div>
</div>
))}
</div>
)}
</div>
</Card>
{/* charts — one measure per chart (never dual-axis) */}
{!rows || rows.length < 2 ? (
<Card>
<EmptyState
title="Collecting metrics…"
hint="The sampler stores a data point every 10 seconds while the panel runs."
/>
</Card>
) : (
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Tick rate (TPS)">
<TimeSeriesChart
series={[{ name: "TPS", color: "var(--color-series2)", points: pts.make("tps") }]}
yMax={20}
height={190}
/>
</Card>
<Card title="Tick duration (MSPT)">
<TimeSeriesChart
series={[{ name: "MSPT", color: "var(--color-series3)", points: pts.make("mspt") }]}
unit=" ms"
height={190}
/>
</Card>
<Card title="CPU usage">
<TimeSeriesChart
series={[{ name: "CPU", color: "var(--color-series1)", points: pts.make("cpu") }]}
unit="%"
height={190}
/>
</Card>
<Card title="Memory">
<TimeSeriesChart
series={[{ name: "Used", color: "var(--color-series1)", points: pts.make("mem_used") }]}
height={190}
formatValue={(v) => fmtBytes(v)}
/>
</Card>
<Card title="Network throughput">
<TimeSeriesChart
series={[
{ name: "Down", color: "var(--color-series1)", points: pts.rates("net_rx") },
{ name: "Up", color: "var(--color-series2)", points: pts.rates("net_tx") },
]}
height={190}
formatValue={(v) => `${fmtBytes(v)}/s`}
/>
</Card>
<Card title="Players online">
<TimeSeriesChart
series={[{ name: "Players", color: "var(--color-series3)", points: pts.make("players") }]}
height={190}
formatValue={(v) => String(Math.round(v))}
/>
</Card>
</div>
)}
{/* spark health report modal */}
<Modal open={report !== null} onClose={() => setReport(null)} title="spark health report" wide>
<pre className="scroll-slim max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-lg bg-[#070a0e] p-3 font-mono text-[11px] leading-relaxed text-ink2">
{report}
</pre>
</Modal>
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
"use client";
import { useState } from "react";
import { Users, UserX, Wifi } from "lucide-react";
import { api, useApi, useSSE, toast } from "@/lib/api";
import { Card, Badge, Button, EmptyState, Modal } from "@/components/ui";
import { timeAgo, fmtDateTime } from "@/lib/format";
type PlayerRow = {
name: string;
uuid: string;
playTimeHours: number;
deaths: number;
mobKills: number;
playerKills: number;
distanceKm: number;
jumps: number;
damageDealt: number;
blocksMined: number;
isOnline: boolean;
ping: number | null;
authStatus: string | null;
lastSeenAt: number | null;
};
type PlayersData = { online: string[]; max: number; players: PlayerRow[] };
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
export default function PlayersPage() {
const { data, refresh } = useApi<PlayersData>("/api/players", 15000);
const { data: joins, refresh: refreshJoins } = useApi<ActivityRow[]>("/api/activity?limit=30", 20000);
const [detail, setDetail] = useState<PlayerRow | null>(null);
const [kicking, setKicking] = useState<string | null>(null);
useSSE("/api/stream/events", {
"mc-event": () => {
void refresh();
void refreshJoins();
},
});
const kick = async (name: string) => {
setKicking(name);
try {
await api("/api/console", { body: { command: `kick ${name} Kicked from the panel` } });
toast(`${name} kicked`, "warn");
setTimeout(() => void refresh(), 1000);
} catch (err) {
toast((err as Error).message, "error");
} finally {
setKicking(null);
}
};
const players = data?.players ?? [];
const online = players.filter((p) => p.isOnline);
const joinEvents = (joins ?? []).filter((j) => ["join", "leave", "connect"].includes(j.type));
const pingBadge = (ping: number | null) => {
if (ping === null) return null;
const color = ping > 250 ? "crit" : ping > 120 ? "warn" : "good";
return (
<Badge color={color}>
<Wifi size={10} /> {ping} ms
</Badge>
);
};
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold">Players</h1>
<p className="mt-0.5 text-xs text-ink2">
{online.length} online{data?.max ? ` of ${data.max} max` : ""} · {players.length} have played
</p>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<Card title="All players" pad={false}>
{players.length === 0 ? (
<EmptyState
icon={<Users size={28} />}
title="No player data yet"
hint="Stats appear after someone plays on the server."
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Player</th>
<th className="px-4 py-2 font-medium">Playtime</th>
<th className="px-4 py-2 font-medium">Deaths</th>
<th className="px-4 py-2 font-medium">Last seen</th>
<th className="px-4 py-2 text-right font-medium" />
</tr>
</thead>
<tbody>
{players.map((p) => (
<tr
key={p.uuid}
className="cursor-pointer border-b border-border/50 last:border-0 hover:bg-surface2"
onClick={() => setDetail(p)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${p.isOnline ? "bg-good" : "bg-surface3"}`}
title={p.isOnline ? "online" : "offline"}
/>
<span className="font-mono font-medium">{p.name}</span>
{p.isOnline && pingBadge(p.ping)}
</div>
</td>
<td className="px-4 py-2.5 text-ink2">{p.playTimeHours} h</td>
<td className="px-4 py-2.5 text-ink2">{p.deaths}</td>
<td className="px-4 py-2.5 text-ink2">
{p.isOnline ? <span className="text-good">online now</span> : timeAgo(p.lastSeenAt)}
</td>
<td className="px-4 py-2.5 text-right">
{p.isOnline && (
<Button
size="sm"
variant="danger"
busy={kicking === p.name}
onClick={(e) => {
e.stopPropagation();
void kick(p.name);
}}
>
<UserX size={12} /> Kick
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</div>
{/* join/leave feed */}
<Card title="Joins & leaves">
{joinEvents.length === 0 ? (
<EmptyState title="No connections yet" />
) : (
<ul className="space-y-2">
{joinEvents.map((e) => (
<li key={e.id} className="flex items-center gap-2 text-sm">
<Badge color={e.type === "join" ? "good" : e.type === "leave" ? "muted" : "info"}>
{e.type === "connect" ? "connecting" : e.type}
</Badge>
<span className="font-mono text-xs font-medium">{e.player}</span>
{e.type === "connect" && e.detail && (
<span className="font-mono text-[10px] text-muted">{e.detail}</span>
)}
<span className="ml-auto text-[10px] text-muted">{timeAgo(e.ts)}</span>
</li>
))}
</ul>
)}
</Card>
</div>
{/* stats drawer */}
<Modal open={detail !== null} onClose={() => setDetail(null)} title={detail?.name ?? ""} wide>
{detail && (
<div>
<div className="mb-3 flex items-center gap-2">
{detail.isOnline ? <Badge color="good">online</Badge> : <Badge color="muted">offline</Badge>}
{detail.authStatus && <Badge color="info">{detail.authStatus}</Badge>}
{detail.isOnline && detail.ping !== null && pingBadge(detail.ping)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
["Playtime", `${detail.playTimeHours} h`],
["Deaths", detail.deaths],
["Mob kills", detail.mobKills],
["Player kills", detail.playerKills],
["Traveled", `${detail.distanceKm} km`],
["Blocks mined", detail.blocksMined.toLocaleString()],
["Jumps", detail.jumps.toLocaleString()],
["Damage dealt", detail.damageDealt.toLocaleString()],
].map(([label, value]) => (
<div key={label as string} className="rounded-lg border border-border bg-surface2 p-3">
<div className="text-[10px] uppercase tracking-wide text-muted">{label}</div>
<div className="mt-0.5 text-lg font-semibold">{value}</div>
</div>
))}
</div>
<div className="mt-3 space-y-1 text-[11px] text-muted">
<div>UUID: <code>{detail.uuid}</code></div>
<div>Last seen: {detail.isOnline ? "online now" : fmtDateTime(detail.lastSeenAt)}</div>
</div>
</div>
)}
</Modal>
</div>
);
}
@@ -0,0 +1,240 @@
"use client";
import { useState } from "react";
import { CalendarClock, Plus, Play, Trash2, Pencil } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Badge, Input, Select, Modal, EmptyState, Toggle } from "@/components/ui";
import { fmtDateTime } from "@/lib/format";
type Schedule = {
id: number;
name: string;
cron: string;
action: "restart" | "backup" | "command" | "announce";
payload: string | null;
enabled: number;
last_run_at: number | null;
last_result: string | null;
};
const ACTION_OPTIONS = [
{ value: "restart", label: "Restart server (30 s warning)" },
{ value: "backup", label: "Create backup" },
{ value: "announce", label: "In-game announcement" },
{ value: "command", label: "Console command" },
];
const PRESETS = [
{ label: "Every day at 5:00", cron: "0 5 * * *" },
{ label: "Every 6 hours", cron: "0 */6 * * *" },
{ label: "Every Sunday at 4:00", cron: "0 4 * * 0" },
{ label: "Every 30 minutes", cron: "*/30 * * * *" },
];
type Draft = { id?: number; name: string; cron: string; action: Schedule["action"]; payload: string };
const EMPTY_DRAFT: Draft = { name: "", cron: "0 5 * * *", action: "restart", payload: "" };
export default function SchedulesPage() {
const { data: schedules, refresh } = useApi<Schedule[]>("/api/schedules", 15000);
const [draft, setDraft] = useState<Draft | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const run = async (label: string, body: Record<string, unknown>, message?: string) => {
setBusy(label);
try {
await api("/api/schedules", { body });
if (message) toast(message, "success");
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusy(null);
}
};
const saveDraft = async () => {
if (!draft) return;
const body =
draft.id === undefined
? { op: "create", name: draft.name, cron: draft.cron, action: draft.action, payload: draft.payload || undefined }
: { op: "update", id: draft.id, name: draft.name, cron: draft.cron, action: draft.action, payload: draft.payload };
setBusy("save");
try {
await api("/api/schedules", { body });
toast(draft.id === undefined ? "Schedule created" : "Schedule updated", "success");
setDraft(null);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusy(null);
}
};
const needsPayload = draft?.action === "command" || draft?.action === "announce";
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Schedules</h1>
<p className="mt-0.5 text-xs text-ink2">Automatic restarts, backups, announcements and commands (cron)</p>
</div>
<Button variant="primary" onClick={() => setDraft({ ...EMPTY_DRAFT })}>
<Plus size={14} /> New schedule
</Button>
</div>
<Card pad={false}>
{!schedules || schedules.length === 0 ? (
<EmptyState
icon={<CalendarClock size={28} />}
title="No schedules yet"
hint="A nightly restart (e.g. daily at 5:00) keeps modded servers fresh, and extra backups never hurt."
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">When (cron)</th>
<th className="px-4 py-2 font-medium">Action</th>
<th className="px-4 py-2 font-medium">Last run</th>
<th className="px-4 py-2 font-medium">Enabled</th>
<th className="px-4 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{schedules.map((s) => (
<tr key={s.id} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-medium">{s.name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-ink2">{s.cron}</td>
<td className="px-4 py-2.5">
<Badge color="info">{s.action}</Badge>
{s.payload && (
<span className="ml-2 font-mono text-[11px] text-muted">
{s.payload.length > 40 ? s.payload.slice(0, 40) + "…" : s.payload}
</span>
)}
</td>
<td className="px-4 py-2.5 text-xs text-ink2">
{fmtDateTime(s.last_run_at)}
{s.last_result?.startsWith("ERROR") && (
<span className="ml-1 text-crit" title={s.last_result}>
failed
</span>
)}
</td>
<td className="px-4 py-2.5">
<Toggle
checked={s.enabled === 1}
onChange={() => void run(`toggle-${s.id}`, { op: "toggle", id: s.id })}
/>
</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1.5">
<Button
size="sm"
busy={busy === `run-${s.id}`}
onClick={() => void run(`run-${s.id}`, { op: "run", id: s.id }, `"${s.name}" executed`)}
title="Run now"
>
<Play size={12} />
</Button>
<Button
size="sm"
onClick={() =>
setDraft({ id: s.id, name: s.name, cron: s.cron, action: s.action, payload: s.payload ?? "" })
}
>
<Pencil size={12} />
</Button>
<Button
size="sm"
variant="danger"
busy={busy === `delete-${s.id}`}
onClick={() => void run(`delete-${s.id}`, { op: "delete", id: s.id }, `"${s.name}" deleted`)}
>
<Trash2 size={12} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
<Modal open={draft !== null} onClose={() => setDraft(null)} title={draft?.id === undefined ? "New schedule" : "Edit schedule"}>
{draft && (
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Name</label>
<Input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="e.g. Nightly restart"
className="w-full"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Action</label>
<Select
value={draft.action}
onChange={(v) => setDraft({ ...draft, action: v as Schedule["action"] })}
options={ACTION_OPTIONS}
className="w-full"
/>
</div>
{needsPayload && (
<div>
<label className="mb-1 block text-xs font-medium text-ink2">
{draft.action === "announce" ? "Message" : "Command (without /)"}
</label>
<Input
value={draft.payload}
onChange={(e) => setDraft({ ...draft, payload: e.target.value })}
placeholder={draft.action === "announce" ? "Backup starting soon!" : "save-all"}
className="w-full font-mono"
/>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Cron expression</label>
<Input
value={draft.cron}
onChange={(e) => setDraft({ ...draft, cron: e.target.value })}
className="w-full font-mono"
/>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button
key={p.cron}
onClick={() => setDraft({ ...draft, cron: p.cron })}
className="rounded-md border border-border bg-surface2 px-2 py-0.5 text-[10px] text-ink2 hover:text-ink"
>
{p.label}
</button>
))}
</div>
<p className="mt-1 text-[10px] text-muted">minute hour day-of-month month day-of-week (server timezone)</p>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={() => setDraft(null)}>Cancel</Button>
<Button
variant="primary"
busy={busy === "save"}
disabled={!draft.name.trim() || (needsPayload && !draft.payload.trim())}
onClick={saveDraft}
>
Save schedule
</Button>
</div>
</div>
)}
</Modal>
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useEffect, useState } from "react";
import { KeyRound, Webhook, Globe, AlertTriangle } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Input, Toggle, Modal } from "@/components/ui";
import { fmtBytes } from "@/lib/format";
type Settings = {
registrationWindowMinutes: number;
notifyJoins: boolean;
discordEnabled: boolean;
discordJoins: boolean;
discordWebhookUrl: string;
};
type WorldInfo = { seed: string | null; sizeBytes: number | null; dimensions: string[]; levelName: string | null };
export default function SettingsPage() {
const { data, refresh } = useApi<Settings>("/api/settings", 0);
const { data: me, refresh: refreshMe } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me");
const { data: world } = useApi<WorldInfo>("/api/world", 0);
const [values, setValues] = useState<Settings | null>(null);
const [saving, setSaving] = useState(false);
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [newPw2, setNewPw2] = useState("");
const [changing, setChanging] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const [resetConfirm, setResetConfirm] = useState("");
const [resetting, setResetting] = useState(false);
useEffect(() => {
if (data) setValues(data);
}, [data]);
const save = async () => {
if (!values) return;
setSaving(true);
try {
await api("/api/settings", { body: values });
toast("Settings saved", "success");
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setSaving(false);
}
};
const changePassword = async () => {
if (newPw !== newPw2) {
toast("New passwords don't match", "error");
return;
}
setChanging(true);
try {
await api("/api/auth/password", { body: { current: currentPw, next: newPw } });
toast("Panel password changed", "success");
setCurrentPw("");
setNewPw("");
setNewPw2("");
void refreshMe();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setChanging(false);
}
};
const resetWorld = async () => {
setResetting(true);
try {
const res = await api<{ archived: string }>("/api/world", {
body: { op: "reset", confirm: resetConfirm },
});
toast(`World reset — old world saved as ${res.archived}`, "warn");
setResetOpen(false);
setResetConfirm("");
} catch (err) {
toast((err as Error).message, "error");
} finally {
setResetting(false);
}
};
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold">Settings</h1>
<p className="mt-0.5 text-xs text-ink2">Panel account, authentication window, notifications, danger zone</p>
</div>
{me?.mustChange && (
<div className="flex items-center gap-2 rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm text-warn">
<AlertTriangle size={15} /> You're still on the initial admin password — change it below.
</div>
)}
<div className="grid gap-4 lg:grid-cols-2">
{/* password */}
<Card title="Panel password">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Current password</label>
<Input type="password" value={currentPw} onChange={(e) => setCurrentPw(e.target.value)} className="w-full" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-ink2">New password</label>
<Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} className="w-full" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Repeat new password</label>
<Input type="password" value={newPw2} onChange={(e) => setNewPw2(e.target.value)} className="w-full" />
</div>
</div>
<Button
variant="primary"
onClick={changePassword}
busy={changing}
disabled={!currentPw || newPw.length < 8 || newPw !== newPw2}
>
<KeyRound size={14} /> Change password
</Button>
<p className="text-[10px] text-muted">At least 8 characters.</p>
</div>
</Card>
{/* auth window */}
<Card title="Player registration window">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Window length (minutes)</label>
<Input
type="number"
min={0.5}
max={120}
step={0.5}
value={values?.registrationWindowMinutes ?? ""}
onChange={(e) =>
setValues((v) => (v ? { ...v, registrationWindowMinutes: Number(e.target.value) } : v))
}
className="w-32 font-mono"
/>
<p className="mt-1 text-[10px] leading-relaxed text-muted">
After you approve a username, the player has this long to join and <code>/register</code>. When
it lapses the name is automatically removed from the whitelist again.
</p>
</div>
<Toggle
checked={values?.notifyJoins ?? true}
onChange={(v) => setValues((s) => (s ? { ...s, notifyJoins: v } : s))}
label="Notify on player joins/leaves"
/>
</div>
</Card>
{/* discord */}
<Card title="Discord alerts">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Webhook URL</label>
<Input
value={values?.discordWebhookUrl ?? ""}
onChange={(e) => setValues((v) => (v ? { ...v, discordWebhookUrl: e.target.value } : v))}
placeholder="https://discord.com/api/webhooks/…"
className="w-full font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted">
Discord → Server settings → Integrations → Webhooks → New webhook → Copy URL
</p>
</div>
<Toggle
checked={values?.discordEnabled ?? true}
onChange={(v) => setValues((s) => (s ? { ...s, discordEnabled: v } : s))}
label="Send alerts to Discord"
/>
<Toggle
checked={values?.discordJoins ?? true}
onChange={(v) => setValues((s) => (s ? { ...s, discordJoins: v } : s))}
label="Include joins/leaves"
/>
<Button
size="sm"
onClick={async () => {
await save();
try {
await api("/api/settings/test-discord", { method: "POST" });
toast("Test sent — check your Discord channel", "success");
} catch (err) {
toast((err as Error).message, "error");
}
}}
>
<Webhook size={13} /> Save &amp; send test
</Button>
</div>
</Card>
{/* danger zone */}
<Card title="Danger zone">
<div className="space-y-3 text-sm">
<div className="flex items-center gap-3">
<Globe size={15} className="text-muted" />
<div className="flex-1">
<div className="font-medium">Reset world</div>
<div className="text-xs text-muted">
Current world: {world?.levelName ?? "world"} ({fmtBytes(world?.sizeBytes)}) — archived, then a
fresh world generates. A safety backup is taken first.
</div>
</div>
<Button variant="danger" size="sm" onClick={() => setResetOpen(true)}>
Reset…
</Button>
</div>
</div>
</Card>
</div>
<div className="flex justify-end">
<Button variant="primary" onClick={save} busy={saving}>
Save settings
</Button>
</div>
<Modal open={resetOpen} onClose={() => setResetOpen(false)} title="Reset the world?">
<div className="space-y-3 text-sm text-ink2">
<p>
The server stops, the current world folder is archived inside the data directory, and a brand-new
world generates on next start. A backup is taken first, but treat this as destructive.
</p>
<p className="text-xs">
Type <code className="text-crit">reset the world</code> to confirm:
</p>
<Input
value={resetConfirm}
onChange={(e) => setResetConfirm(e.target.value)}
placeholder="reset the world"
className="w-full font-mono"
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setResetOpen(false)}>Cancel</Button>
<Button variant="danger" disabled={resetConfirm !== "reset the world"} busy={resetting} onClick={resetWorld}>
Reset world
</Button>
</div>
</Modal>
</div>
);
}