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:
commit
279d137ce5
98 files changed
+9925
No files matched your search
@@ -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 & 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 & 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 <password> <password></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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Shell } from "@/components/shell";
|
||||
|
||||
export default function PanelLayout({ children }: { children: React.ReactNode }) {
|
||||
return <Shell>{children}</Shell>;
|
||||
}
|
||||
@@ -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 & restart
|
||||
</Button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <password> <password></code> before the timer runs
|
||||
out. Manage in{" "}
|
||||
<Link href="/access" className="text-accent underline">
|
||||
Access & 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 & 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user