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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/server/db";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 500);
|
||||
const type = req.nextUrl.searchParams.get("type");
|
||||
const rows = type
|
||||
? db().prepare("SELECT * FROM activity WHERE type = ? ORDER BY ts DESC LIMIT ?").all(type, limit)
|
||||
: db().prepare("SELECT * FROM activity ORDER BY ts DESC LIMIT ?").all(limit);
|
||||
return ok(rows);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import {
|
||||
listApproved,
|
||||
approvePlayer,
|
||||
revokePlayer,
|
||||
reopenWindow,
|
||||
resetPassword,
|
||||
forceReregister,
|
||||
importFromWhitelist,
|
||||
} from "@/server/authflow";
|
||||
import { whitelistList, easyauthList } from "@/server/mc";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return handle(async () => {
|
||||
let whitelist: string[] | null = null;
|
||||
let registered: string[] | null = null;
|
||||
try {
|
||||
whitelist = await whitelistList();
|
||||
} catch {}
|
||||
try {
|
||||
registered = await easyauthList();
|
||||
} catch {}
|
||||
return ok({ approved: listApproved(), whitelist, registered });
|
||||
});
|
||||
}
|
||||
|
||||
type Body = {
|
||||
op: "approve" | "revoke" | "reopen" | "reset-password" | "force-reregister" | "import-whitelist";
|
||||
username?: string;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const body = await readJson<Body>(req);
|
||||
switch (body.op) {
|
||||
case "approve": {
|
||||
if (!body.username) return fail("Username required");
|
||||
const player = await approvePlayer(body.username.trim(), body.note);
|
||||
return ok(player);
|
||||
}
|
||||
case "revoke": {
|
||||
if (!body.username) return fail("Username required");
|
||||
await revokePlayer(body.username);
|
||||
return ok({ revoked: true });
|
||||
}
|
||||
case "reopen": {
|
||||
if (!body.username) return fail("Username required");
|
||||
await reopenWindow(body.username);
|
||||
return ok({ reopened: true });
|
||||
}
|
||||
case "reset-password": {
|
||||
if (!body.username) return fail("Username required");
|
||||
const temp = await resetPassword(body.username);
|
||||
return ok({ tempPassword: temp });
|
||||
}
|
||||
case "force-reregister": {
|
||||
if (!body.username) return fail("Username required");
|
||||
await forceReregister(body.username);
|
||||
return ok({ pending: true });
|
||||
}
|
||||
case "import-whitelist": {
|
||||
const added = await importFromWhitelist();
|
||||
return ok({ imported: added });
|
||||
}
|
||||
default:
|
||||
return fail("Unknown operation");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { db } from "@/server/db";
|
||||
import { verifyPassword } from "@/server/passwords";
|
||||
import { createSessionToken, SESSION_COOKIE, sessionCookieAttributes } from "@/server/session";
|
||||
import { env } from "@/server/env";
|
||||
import { fail, readJson, handle } from "@/server/api";
|
||||
|
||||
/** Simple in-memory login rate limit: 10 attempts / 15 min per IP. */
|
||||
const attempts = new Map<string, { count: number; resetAt: number }>();
|
||||
|
||||
function rateLimited(ip: string): boolean {
|
||||
const now = Date.now();
|
||||
const entry = attempts.get(ip);
|
||||
if (!entry || entry.resetAt < now) {
|
||||
attempts.set(ip, { count: 1, resetAt: now + 15 * 60 * 1000 });
|
||||
return false;
|
||||
}
|
||||
entry.count++;
|
||||
return entry.count > 10;
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() || "local";
|
||||
if (rateLimited(ip)) return fail("Too many attempts — try again in 15 minutes", 429);
|
||||
|
||||
const { username, password } = await readJson<{ username?: string; password?: string }>(req);
|
||||
if (!username || !password) return fail("Username and password required");
|
||||
|
||||
const user = db()
|
||||
.prepare("SELECT * FROM panel_users WHERE username = ?")
|
||||
.get(username.toLowerCase()) as { username: string; pass_hash: string; must_change: number } | undefined;
|
||||
|
||||
if (!user || !verifyPassword(password, user.pass_hash)) {
|
||||
return fail("Wrong username or password", 401);
|
||||
}
|
||||
|
||||
attempts.delete(ip);
|
||||
const token = await createSessionToken(env.sessionSecret, user.username);
|
||||
const res = NextResponse.json({ ok: true, data: { mustChange: user.must_change === 1 } });
|
||||
res.headers.set("Set-Cookie", `${SESSION_COOKIE}=${token}; ${sessionCookieAttributes()}`);
|
||||
return res;
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE } from "@/server/session";
|
||||
|
||||
export function POST() {
|
||||
const res = NextResponse.json({ ok: true });
|
||||
res.headers.set("Set-Cookie", `${SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0`);
|
||||
return res;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/server/db";
|
||||
import { verifySessionToken, SESSION_COOKIE } from "@/server/session";
|
||||
import { env } from "@/server/env";
|
||||
import { ok, fail, handle } from "@/server/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const session = await verifySessionToken(env.sessionSecret, req.cookies.get(SESSION_COOKIE)?.value);
|
||||
if (!session) return fail("Unauthorized", 401);
|
||||
const user = db()
|
||||
.prepare("SELECT username, must_change FROM panel_users WHERE username = ?")
|
||||
.get(session.u) as { username: string; must_change: number } | undefined;
|
||||
if (!user) return fail("Unauthorized", 401);
|
||||
return ok({ username: user.username, mustChange: user.must_change === 1 });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/server/db";
|
||||
import { hashPassword, verifyPassword } from "@/server/passwords";
|
||||
import { verifySessionToken, SESSION_COOKIE } from "@/server/session";
|
||||
import { env } from "@/server/env";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const session = await verifySessionToken(env.sessionSecret, req.cookies.get(SESSION_COOKIE)?.value);
|
||||
if (!session) return fail("Unauthorized", 401);
|
||||
|
||||
const { current, next } = await readJson<{ current?: string; next?: string }>(req);
|
||||
if (!current || !next) return fail("Current and new password required");
|
||||
if (next.length < 8) return fail("New password must be at least 8 characters");
|
||||
|
||||
const user = db()
|
||||
.prepare("SELECT pass_hash FROM panel_users WHERE username = ?")
|
||||
.get(session.u) as { pass_hash: string } | undefined;
|
||||
if (!user || !verifyPassword(current, user.pass_hash)) {
|
||||
return fail("Current password is wrong", 401);
|
||||
}
|
||||
db()
|
||||
.prepare("UPDATE panel_users SET pass_hash = ?, must_change = 0 WHERE username = ?")
|
||||
.run(hashPassword(next), session.u);
|
||||
return ok({ changed: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { Readable } from "node:stream";
|
||||
import { getBackupStream, deleteBackup } from "@/server/backups";
|
||||
import { ok, fail, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const name = req.nextUrl.searchParams.get("name");
|
||||
if (!name) return fail("Missing backup name");
|
||||
try {
|
||||
const { stream, size } = getBackupStream(name);
|
||||
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
||||
headers: {
|
||||
"Content-Type": "application/gzip",
|
||||
"Content-Length": String(size),
|
||||
"Content-Disposition": `attachment; filename="${name}"`,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return fail((err as Error).message, 404);
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const name = req.nextUrl.searchParams.get("name");
|
||||
if (!name) return fail("Missing backup name");
|
||||
deleteBackup(name);
|
||||
return ok({ deleted: name });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { restoreBackup } from "@/server/backups";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const { name, confirm } = await readJson<{ name?: string; confirm?: string }>(req);
|
||||
if (!name) return fail("Missing backup name");
|
||||
if (confirm !== "restore") return fail('Type "restore" to confirm');
|
||||
await restoreBackup(name);
|
||||
return ok({ restored: name });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { listBackups, triggerBackup, backupsDirSize } from "@/server/backups";
|
||||
import { readEnvFile } from "@/server/envfile";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return handle(async () => {
|
||||
const envValues = readEnvFile();
|
||||
return ok({
|
||||
backups: listBackups(),
|
||||
totalSize: backupsDirSize(),
|
||||
interval: envValues["BACKUP_INTERVAL"] ?? "24h",
|
||||
retentionDays: envValues["BACKUP_RETENTION_DAYS"] ?? "7",
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST() {
|
||||
return handle(async () => {
|
||||
const name = await triggerBackup();
|
||||
return ok({ created: name });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { applyMcConfig, applyBackupConfig } from "@/server/compose";
|
||||
import { safetyBackup } from "@/server/backups";
|
||||
import { notify } from "@/server/notify";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const { service, backupFirst } = await readJson<{ service?: string; backupFirst?: boolean }>(req);
|
||||
if (backupFirst) await safetyBackup("config apply");
|
||||
const res = service === "backup" ? await applyBackupConfig() : await applyMcConfig();
|
||||
if (!res.ok) {
|
||||
notify("error", "Config apply failed", res.output.slice(-800));
|
||||
return fail(`compose failed: ${res.output.slice(-400)}`, 500);
|
||||
}
|
||||
notify("success", "Configuration applied", "Container recreated with the new settings.", { discord: false });
|
||||
return ok({ output: res.output.slice(-1000) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { readEnvFile, writeEnvFile, EDITABLE_ENV_KEYS } from "@/server/envfile";
|
||||
import { logActivity } from "@/server/db";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return handle(async () => {
|
||||
const all = readEnvFile();
|
||||
const values: Record<string, string> = {};
|
||||
for (const key of EDITABLE_ENV_KEYS) values[key] = all[key] ?? "";
|
||||
return ok(values);
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const { updates } = await readJson<{ updates?: Record<string, string> }>(req);
|
||||
if (!updates || typeof updates !== "object") return fail("Missing updates");
|
||||
const allowed = new Set<string>(EDITABLE_ENV_KEYS);
|
||||
const filtered: Record<string, string> = {};
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
if (!allowed.has(key)) return fail(`Key not editable: ${key}`);
|
||||
if (/[\r\n]/.test(value)) return fail(`Invalid value for ${key}`);
|
||||
filtered[key] = value;
|
||||
}
|
||||
writeEnvFile(filtered);
|
||||
logActivity("config", null, Object.keys(filtered).join(", "));
|
||||
return ok({ updated: Object.keys(filtered), needsApply: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { rcon, stripColors } from "@/server/rcon";
|
||||
import { logActivity } from "@/server/db";
|
||||
import { pushLogLine } from "@/server/bus";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const { command } = await readJson<{ command?: string }>(req);
|
||||
if (!command?.trim()) return fail("Empty command");
|
||||
const cmd = command.trim().replace(/^\//, "");
|
||||
const output = stripColors(await rcon().exec(cmd));
|
||||
logActivity("console", null, cmd);
|
||||
// Echo the exchange into the live console stream so it reads like a terminal.
|
||||
pushLogLine(`> ${cmd}`);
|
||||
if (output.trim()) for (const line of output.split("\n")) pushLogLine(line);
|
||||
return ok({ output });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { Readable } from "node:stream";
|
||||
import { fileStream } from "@/server/files";
|
||||
import { fail } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const p = req.nextUrl.searchParams.get("path");
|
||||
if (!p) return fail("Missing path");
|
||||
try {
|
||||
const { stream, size, name } = fileStream(p);
|
||||
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
||||
headers: {
|
||||
"Content-Type": "application/octet-stream",
|
||||
"Content-Length": String(size),
|
||||
"Content-Disposition": `attachment; filename="${name.replace(/"/g, "")}"`,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
return fail((err as Error).message, 404);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { listDir, readTextFile, writeTextFile, deletePath, makeDir } from "@/server/files";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const dir = req.nextUrl.searchParams.get("dir");
|
||||
const file = req.nextUrl.searchParams.get("file");
|
||||
if (file !== null) return ok(readTextFile(file));
|
||||
return ok(listDir(dir ?? ""));
|
||||
});
|
||||
}
|
||||
|
||||
type Body =
|
||||
| { op: "write"; path: string; content: string }
|
||||
| { op: "mkdir"; path: string }
|
||||
| { op: "delete"; path: string };
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const body = await readJson<Body>(req);
|
||||
if (!body.path && body.path !== "") return fail("Missing path");
|
||||
switch (body.op) {
|
||||
case "write":
|
||||
writeTextFile(body.path, body.content ?? "");
|
||||
return ok({ written: body.path });
|
||||
case "mkdir":
|
||||
makeDir(body.path);
|
||||
return ok({ created: body.path });
|
||||
case "delete":
|
||||
deletePath(body.path);
|
||||
return ok({ deleted: body.path });
|
||||
default:
|
||||
return fail("Unknown operation");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import path from "node:path";
|
||||
import { writeBinaryFile } from "@/server/files";
|
||||
import { ok, fail, handle } from "@/server/api";
|
||||
|
||||
const MAX_UPLOAD = 256 * 1024 * 1024; // mod jars and datapacks, not world dumps
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const form = await req.formData();
|
||||
const dir = (form.get("dir") as string) ?? "";
|
||||
const file = form.get("file");
|
||||
if (!(file instanceof File)) return fail("Missing file");
|
||||
if (file.size > MAX_UPLOAD) return fail("File too large (256 MB max)");
|
||||
const name = path.basename(file.name);
|
||||
if (!name || name.startsWith(".")) return fail("Invalid file name");
|
||||
const buf = Buffer.from(await file.arrayBuffer());
|
||||
writeBinaryFile(path.join(dir, name), buf);
|
||||
return ok({ uploaded: name, size: file.size });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
export function GET() {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { sparkHealthReport } from "@/server/mc";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export async function POST() {
|
||||
return handle(async () => {
|
||||
const report = await sparkHealthReport();
|
||||
return ok({ report });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { db } from "@/server/db";
|
||||
import { sparkPings, listOnlinePlayers } from "@/server/mc";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Lag diagnostics: separates "the server is lagging" (low TPS / high MSPT /
|
||||
* high CPU) from "a player's connection is lagging" (high ping) and renders
|
||||
* a verdict the admin can read at a glance.
|
||||
*/
|
||||
export async function GET() {
|
||||
return handle(async () => {
|
||||
const recent = db()
|
||||
.prepare("SELECT tps, mspt, cpu FROM metrics WHERE ts > ? ORDER BY ts DESC LIMIT 18")
|
||||
.all(Date.now() - 3 * 60 * 1000) as Array<{ tps: number | null; mspt: number | null; cpu: number | null }>;
|
||||
|
||||
const avg = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
||||
const tps = avg(recent.map((r) => r.tps).filter((v): v is number => v !== null));
|
||||
const mspt = avg(recent.map((r) => r.mspt).filter((v): v is number => v !== null));
|
||||
const cpu = avg(recent.map((r) => r.cpu).filter((v): v is number => v !== null));
|
||||
|
||||
let livePings: Record<string, number> = {};
|
||||
let online: string[] = [];
|
||||
try {
|
||||
[livePings, online] = await Promise.all([
|
||||
sparkPings(),
|
||||
listOnlinePlayers().then((l) => l.names),
|
||||
]);
|
||||
} catch {}
|
||||
|
||||
// ping history per player (last hour) for sparklines
|
||||
const history = db()
|
||||
.prepare("SELECT player, ts, ping FROM ping_samples WHERE ts > ? ORDER BY ts ASC")
|
||||
.all(Date.now() - 60 * 60 * 1000) as Array<{ player: string; ts: number; ping: number }>;
|
||||
const historyByPlayer: Record<string, Array<{ ts: number; ping: number }>> = {};
|
||||
for (const h of history) {
|
||||
(historyByPlayer[h.player] ??= []).push({ ts: h.ts, ping: h.ping });
|
||||
}
|
||||
|
||||
const serverHealthy = (tps === null || tps >= 18) && (mspt === null || mspt <= 40);
|
||||
const players = online.map((name) => {
|
||||
const ping = livePings[name] ?? null;
|
||||
return {
|
||||
name,
|
||||
ping,
|
||||
history: historyByPlayer[name] ?? [],
|
||||
verdict:
|
||||
ping === null
|
||||
? "unknown"
|
||||
: ping > 250
|
||||
? "bad-connection"
|
||||
: ping > 120
|
||||
? "weak-connection"
|
||||
: "good",
|
||||
};
|
||||
});
|
||||
|
||||
let verdict: string;
|
||||
if (tps === null && mspt === null) {
|
||||
verdict = "no-data";
|
||||
} else if (!serverHealthy) {
|
||||
verdict = "server-lagging";
|
||||
} else if (players.some((p) => p.verdict === "bad-connection" || p.verdict === "weak-connection")) {
|
||||
verdict = "player-connections";
|
||||
} else {
|
||||
verdict = "all-good";
|
||||
}
|
||||
|
||||
return ok({ tps, mspt, cpu, serverHealthy, verdict, players });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/server/db";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const RANGES: Record<string, { ms: number; bucketMs: number }> = {
|
||||
"1h": { ms: 60 * 60 * 1000, bucketMs: 10 * 1000 },
|
||||
"6h": { ms: 6 * 60 * 60 * 1000, bucketMs: 60 * 1000 },
|
||||
"24h": { ms: 24 * 60 * 60 * 1000, bucketMs: 5 * 60 * 1000 },
|
||||
"7d": { ms: 7 * 24 * 60 * 60 * 1000, bucketMs: 30 * 60 * 1000 },
|
||||
};
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const range = RANGES[req.nextUrl.searchParams.get("range") ?? "1h"] ?? RANGES["1h"];
|
||||
const since = Date.now() - range.ms;
|
||||
// Bucket-average so long ranges stay light on the wire.
|
||||
const rows = db()
|
||||
.prepare(
|
||||
`SELECT (ts / ?) * ? AS bucket,
|
||||
AVG(cpu) AS cpu, AVG(mem_used) AS mem_used, MAX(mem_limit) AS mem_limit,
|
||||
MAX(net_rx) AS net_rx, MAX(net_tx) AS net_tx,
|
||||
AVG(tps) AS tps, AVG(mspt) AS mspt, MAX(players) AS players,
|
||||
AVG(disk_used) AS disk_used, AVG(disk_free) AS disk_free
|
||||
FROM metrics WHERE ts > ?
|
||||
GROUP BY bucket ORDER BY bucket ASC`
|
||||
)
|
||||
.all(range.bucketMs, range.bucketMs, since);
|
||||
return ok(rows);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { applyMcConfig } from "@/server/compose";
|
||||
import { notify } from "@/server/notify";
|
||||
import { ok, fail, handle } from "@/server/api";
|
||||
|
||||
/** Recreate the mc container so the itzg image re-resolves MODRINTH_PROJECTS. */
|
||||
export async function POST() {
|
||||
return handle(async () => {
|
||||
const res = await applyMcConfig();
|
||||
if (!res.ok) {
|
||||
notify("error", "Mod apply failed", res.output.slice(-800));
|
||||
return fail(`compose failed: ${res.output.slice(-400)}`, 500);
|
||||
}
|
||||
notify("success", "Mod changes applied", "Server container recreated; mods re-resolved.", { discord: false });
|
||||
return ok({ output: res.output.slice(-1000) });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { addManagedMod, removeManagedMod, toggleMod, deleteModFile } from "@/server/modrinth";
|
||||
import { logActivity } from "@/server/db";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
type Body = {
|
||||
op: "add" | "remove" | "toggle" | "delete";
|
||||
slug?: string;
|
||||
file?: string;
|
||||
};
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const body = await readJson<Body>(req);
|
||||
switch (body.op) {
|
||||
case "add": {
|
||||
if (!body.slug) return fail("Missing slug");
|
||||
const managed = addManagedMod(body.slug);
|
||||
logActivity("mod_add", null, body.slug);
|
||||
return ok({ managed, needsApply: true });
|
||||
}
|
||||
case "remove": {
|
||||
if (!body.slug) return fail("Missing slug");
|
||||
const managed = removeManagedMod(body.slug);
|
||||
logActivity("mod_remove", null, body.slug);
|
||||
return ok({ managed, needsApply: true });
|
||||
}
|
||||
case "toggle": {
|
||||
if (!body.file) return fail("Missing file");
|
||||
toggleMod(body.file);
|
||||
return ok({ needsRestart: true });
|
||||
}
|
||||
case "delete": {
|
||||
if (!body.file) return fail("Missing file");
|
||||
deleteModFile(body.file);
|
||||
logActivity("mod_remove", null, body.file);
|
||||
return ok({ needsRestart: true });
|
||||
}
|
||||
default:
|
||||
return fail("Unknown operation");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { listInstalledMods, managedSlugs, currentMcVersion } from "@/server/modrinth";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return handle(async () => {
|
||||
return ok({
|
||||
installed: await listInstalledMods(),
|
||||
managed: managedSlugs(),
|
||||
mcVersion: currentMcVersion(),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { searchMods } from "@/server/modrinth";
|
||||
import { ok, fail, handle } from "@/server/api";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const q = req.nextUrl.searchParams.get("q")?.trim();
|
||||
if (!q) return fail("Missing query");
|
||||
return ok(await searchMods(q));
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/server/db";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 200);
|
||||
const rows = db().prepare("SELECT * FROM notifications ORDER BY ts DESC LIMIT ?").all(limit);
|
||||
const unread = (db().prepare("SELECT COUNT(*) AS n FROM notifications WHERE read = 0").get() as { n: number }).n;
|
||||
return ok({ notifications: rows, unread });
|
||||
});
|
||||
}
|
||||
|
||||
type Body = { op: "read"; id: number } | { op: "read-all" } | { op: "clear" };
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const body = await readJson<Body>(req);
|
||||
switch (body.op) {
|
||||
case "read":
|
||||
db().prepare("UPDATE notifications SET read = 1 WHERE id = ?").run(body.id);
|
||||
return ok({});
|
||||
case "read-all":
|
||||
db().prepare("UPDATE notifications SET read = 1").run();
|
||||
return ok({});
|
||||
case "clear":
|
||||
db().prepare("DELETE FROM notifications").run();
|
||||
return ok({});
|
||||
default:
|
||||
return fail("Unknown operation");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { listOnlinePlayers } from "@/server/mc";
|
||||
import { readAllPlayerStats } from "@/server/playerstats";
|
||||
import { db } from "@/server/db";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return handle(async () => {
|
||||
let online: string[] = [];
|
||||
let max = 0;
|
||||
try {
|
||||
const list = await listOnlinePlayers();
|
||||
online = list.names;
|
||||
max = list.max;
|
||||
} catch {}
|
||||
|
||||
const stats = readAllPlayerStats();
|
||||
|
||||
// latest ping per player (last 10 minutes)
|
||||
const pings = db()
|
||||
.prepare(
|
||||
`SELECT player, ping FROM ping_samples
|
||||
WHERE ts > ? AND id IN (SELECT MAX(id) FROM ping_samples GROUP BY player)`
|
||||
)
|
||||
.all(Date.now() - 10 * 60 * 1000) as Array<{ player: string; ping: number }>;
|
||||
const pingMap = Object.fromEntries(pings.map((p) => [p.player.toLowerCase(), p.ping]));
|
||||
|
||||
const approved = db()
|
||||
.prepare("SELECT username, status, last_seen_at FROM approved_players")
|
||||
.all() as Array<{ username: string; status: string; last_seen_at: number | null }>;
|
||||
const approvedMap = Object.fromEntries(approved.map((a) => [a.username.toLowerCase(), a]));
|
||||
|
||||
return ok({
|
||||
online,
|
||||
max,
|
||||
players: stats.map((s) => ({
|
||||
...s,
|
||||
isOnline: online.some((n) => n.toLowerCase() === s.name.toLowerCase()),
|
||||
ping: pingMap[s.name.toLowerCase()] ?? null,
|
||||
authStatus: approvedMap[s.name.toLowerCase()]?.status ?? null,
|
||||
lastSeenAt: approvedMap[s.name.toLowerCase()]?.last_seen_at ?? null,
|
||||
})),
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { db } from "@/server/db";
|
||||
import { validateCron, reloadSchedules, runScheduleAction, type Schedule } from "@/server/scheduler";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
const ACTIONS = ["restart", "backup", "command", "announce"] as const;
|
||||
|
||||
export async function GET() {
|
||||
return handle(async () => {
|
||||
return ok(db().prepare("SELECT * FROM schedules ORDER BY id ASC").all());
|
||||
});
|
||||
}
|
||||
|
||||
type Body =
|
||||
| { op: "create"; name: string; cron: string; action: Schedule["action"]; payload?: string }
|
||||
| { op: "update"; id: number; name?: string; cron?: string; action?: Schedule["action"]; payload?: string }
|
||||
| { op: "toggle"; id: number }
|
||||
| { op: "delete"; id: number }
|
||||
| { op: "run"; id: number };
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const body = await readJson<Body>(req);
|
||||
switch (body.op) {
|
||||
case "create": {
|
||||
if (!body.name?.trim()) return fail("Name required");
|
||||
if (!validateCron(body.cron)) return fail("Invalid cron expression");
|
||||
if (!ACTIONS.includes(body.action)) return fail("Invalid action");
|
||||
db()
|
||||
.prepare("INSERT INTO schedules(name, cron, action, payload, created_at) VALUES(?, ?, ?, ?, ?)")
|
||||
.run(body.name.trim(), body.cron, body.action, body.payload ?? null, Date.now());
|
||||
reloadSchedules();
|
||||
return ok({ created: true });
|
||||
}
|
||||
case "update": {
|
||||
const row = db().prepare("SELECT * FROM schedules WHERE id = ?").get(body.id) as Schedule | undefined;
|
||||
if (!row) return fail("Schedule not found", 404);
|
||||
const cronExpr = body.cron ?? row.cron;
|
||||
if (!validateCron(cronExpr)) return fail("Invalid cron expression");
|
||||
const action = body.action ?? row.action;
|
||||
if (!ACTIONS.includes(action)) return fail("Invalid action");
|
||||
db()
|
||||
.prepare("UPDATE schedules SET name = ?, cron = ?, action = ?, payload = ? WHERE id = ?")
|
||||
.run(body.name ?? row.name, cronExpr, action, body.payload ?? row.payload, body.id);
|
||||
reloadSchedules();
|
||||
return ok({ updated: true });
|
||||
}
|
||||
case "toggle": {
|
||||
db().prepare("UPDATE schedules SET enabled = 1 - enabled WHERE id = ?").run(body.id);
|
||||
reloadSchedules();
|
||||
return ok({ toggled: true });
|
||||
}
|
||||
case "delete": {
|
||||
db().prepare("DELETE FROM schedules WHERE id = ?").run(body.id);
|
||||
reloadSchedules();
|
||||
return ok({ deleted: true });
|
||||
}
|
||||
case "run": {
|
||||
const row = db().prepare("SELECT * FROM schedules WHERE id = ?").get(body.id) as Schedule | undefined;
|
||||
if (!row) return fail("Schedule not found", 404);
|
||||
const result = await runScheduleAction(row.action, row.payload);
|
||||
db()
|
||||
.prepare("UPDATE schedules SET last_run_at = ?, last_result = ? WHERE id = ?")
|
||||
.run(Date.now(), result.slice(0, 1000), row.id);
|
||||
return ok({ result });
|
||||
}
|
||||
default:
|
||||
return fail("Unknown operation");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { powerContainer, type PowerAction } from "@/server/docker";
|
||||
import { logActivity } from "@/server/db";
|
||||
import { notify } from "@/server/notify";
|
||||
import { env } from "@/server/env";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
const ACTIONS: PowerAction[] = ["start", "stop", "restart", "kill"];
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const { action } = await readJson<{ action?: PowerAction }>(req);
|
||||
if (!action || !ACTIONS.includes(action)) return fail("Invalid power action");
|
||||
await powerContainer(env.mcContainer, action);
|
||||
logActivity("power", null, action);
|
||||
notify("info", `Server ${action} requested from the panel`, "", { discord: false });
|
||||
return ok({ action });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { inspectContainer, containerEnv } from "@/server/docker";
|
||||
import { listOnlinePlayers } from "@/server/mc";
|
||||
import { db } from "@/server/db";
|
||||
import { env } from "@/server/env";
|
||||
import { getWorldSizeBytes } from "@/server/sampler";
|
||||
import { readWorldInfo } from "@/server/playerstats";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return handle(async () => {
|
||||
let container;
|
||||
let dockerAvailable = true;
|
||||
try {
|
||||
container = await inspectContainer(env.mcContainer);
|
||||
} catch {
|
||||
dockerAvailable = false;
|
||||
container = { exists: false, running: false, status: "unknown" as const };
|
||||
}
|
||||
|
||||
let players: { online: number; max: number; names: string[] } = { online: 0, max: 0, names: [] };
|
||||
let version: string | null = null;
|
||||
let serverType: string | null = null;
|
||||
if (container.running) {
|
||||
try {
|
||||
players = await listOnlinePlayers();
|
||||
} catch {}
|
||||
try {
|
||||
const cenv = await containerEnv(env.mcContainer);
|
||||
version = cenv["VERSION"] ?? null;
|
||||
serverType = cenv["TYPE"] ?? null;
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const latest = db()
|
||||
.prepare("SELECT * FROM metrics ORDER BY ts DESC LIMIT 1")
|
||||
.get() as Record<string, number | null> | undefined;
|
||||
|
||||
const pendingCount = (
|
||||
db().prepare("SELECT COUNT(*) AS n FROM approved_players WHERE status = 'pending'").get() as { n: number }
|
||||
).n;
|
||||
|
||||
return ok({
|
||||
dockerAvailable,
|
||||
container,
|
||||
players,
|
||||
version,
|
||||
serverType,
|
||||
metrics: latest ?? null,
|
||||
pendingApprovals: pendingCount,
|
||||
world: readWorldInfo(getWorldSizeBytes()),
|
||||
uptimeMs:
|
||||
container.running && "startedAt" in container && container.startedAt
|
||||
? Date.now() - new Date(container.startedAt).getTime()
|
||||
: null,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { getSetting, setSetting, getBoolSetting } from "@/server/db";
|
||||
import { env } from "@/server/env";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return handle(async () => {
|
||||
return ok({
|
||||
registrationWindowMinutes: Number(
|
||||
getSetting("registration_window_minutes", String(env.registrationWindowMinutes))
|
||||
),
|
||||
notifyJoins: getBoolSetting("notify_joins", true),
|
||||
discordEnabled: getBoolSetting("discord_enabled", true),
|
||||
discordJoins: getBoolSetting("discord_joins", true),
|
||||
discordWebhookUrl: getSetting("discord_webhook_url", env.discordWebhookUrl),
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const body = await readJson<Record<string, unknown>>(req);
|
||||
if (body.registrationWindowMinutes !== undefined) {
|
||||
const n = Number(body.registrationWindowMinutes);
|
||||
if (!Number.isFinite(n) || n < 0.5 || n > 120) return fail("Window must be between 0.5 and 120 minutes");
|
||||
setSetting("registration_window_minutes", String(n));
|
||||
}
|
||||
if (body.notifyJoins !== undefined) setSetting("notify_joins", body.notifyJoins ? "1" : "0");
|
||||
if (body.discordEnabled !== undefined) setSetting("discord_enabled", body.discordEnabled ? "1" : "0");
|
||||
if (body.discordJoins !== undefined) setSetting("discord_joins", body.discordJoins ? "1" : "0");
|
||||
if (body.discordWebhookUrl !== undefined) {
|
||||
const url = String(body.discordWebhookUrl);
|
||||
if (url && !/^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\//.test(url)) {
|
||||
return fail("Not a Discord webhook URL");
|
||||
}
|
||||
setSetting("discord_webhook_url", url);
|
||||
}
|
||||
return ok({ saved: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { notify } from "@/server/notify";
|
||||
import { ok, handle } from "@/server/api";
|
||||
|
||||
export async function POST() {
|
||||
return handle(async () => {
|
||||
notify("info", "Test notification", "If you can read this in Discord, the webhook works. 🎉");
|
||||
return ok({ sent: true });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { bus } from "@/server/bus";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/**
|
||||
* Multiplexed live event stream (SSE) for the UI: sampler metrics ticks,
|
||||
* parsed game events, and new notifications.
|
||||
*/
|
||||
export async function GET(req: NextRequest) {
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const send = (event: string, data: unknown) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
const onMetrics = (m: unknown) => send("metrics", m);
|
||||
const onEvent = (e: unknown) => send("mc-event", e);
|
||||
const onNotification = (n: unknown) => send("notification", n);
|
||||
bus().on("metrics", onMetrics);
|
||||
bus().on("mc-event", onEvent);
|
||||
bus().on("notification", onNotification);
|
||||
const keepalive = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
}, 15000);
|
||||
req.signal.addEventListener("abort", () => {
|
||||
closed = true;
|
||||
bus().off("metrics", onMetrics);
|
||||
bus().off("mc-event", onEvent);
|
||||
bus().off("notification", onNotification);
|
||||
clearInterval(keepalive);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { bus, logRing } from "@/server/bus";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
/** Live console stream (SSE). Backfills recent lines, then follows. */
|
||||
export async function GET(req: NextRequest) {
|
||||
const encoder = new TextEncoder();
|
||||
let closed = false;
|
||||
|
||||
const stream = new ReadableStream({
|
||||
start(controller) {
|
||||
const send = (line: string) => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(`data: ${JSON.stringify(line)}\n\n`));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
};
|
||||
for (const line of logRing()) send(line);
|
||||
const onLog = (line: string) => send(line);
|
||||
bus().on("log", onLog);
|
||||
const keepalive = setInterval(() => {
|
||||
if (closed) return;
|
||||
try {
|
||||
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||
} catch {
|
||||
closed = true;
|
||||
}
|
||||
}, 15000);
|
||||
req.signal.addEventListener("abort", () => {
|
||||
closed = true;
|
||||
bus().off("log", onLog);
|
||||
clearInterval(keepalive);
|
||||
try {
|
||||
controller.close();
|
||||
} catch {}
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
return new Response(stream, {
|
||||
headers: {
|
||||
"Content-Type": "text/event-stream",
|
||||
"Cache-Control": "no-cache, no-transform",
|
||||
Connection: "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { readWorldInfo } from "@/server/playerstats";
|
||||
import { getWorldSizeBytes } from "@/server/sampler";
|
||||
import { inspectContainer, powerContainer } from "@/server/docker";
|
||||
import { safetyBackup } from "@/server/backups";
|
||||
import { logActivity } from "@/server/db";
|
||||
import { notify } from "@/server/notify";
|
||||
import { env } from "@/server/env";
|
||||
import { ok, fail, readJson, handle } from "@/server/api";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export async function GET() {
|
||||
return handle(async () => ok(readWorldInfo(getWorldSizeBytes())));
|
||||
}
|
||||
|
||||
/** Danger zone: archive the current world and generate a fresh one. */
|
||||
export async function POST(req: NextRequest) {
|
||||
return handle(async () => {
|
||||
const { op, confirm } = await readJson<{ op?: string; confirm?: string }>(req);
|
||||
if (op !== "reset") return fail("Unknown operation");
|
||||
if (confirm !== "reset the world") return fail('Type "reset the world" to confirm');
|
||||
|
||||
const worldDir = path.join(env.mcDataDir, "world");
|
||||
if (!fs.existsSync(worldDir)) return fail("No world folder found");
|
||||
|
||||
await safetyBackup("world reset");
|
||||
const state = await inspectContainer(env.mcContainer);
|
||||
if (state.running) await powerContainer(env.mcContainer, "stop");
|
||||
|
||||
const archived = `world.old-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||
fs.renameSync(worldDir, path.join(env.mcDataDir, archived));
|
||||
|
||||
await powerContainer(env.mcContainer, "start");
|
||||
logActivity("world_reset", null, `old world kept as ${archived}`);
|
||||
notify("warn", "World reset", `A fresh world is generating. Old world kept as ${archived}.`);
|
||||
return ok({ archived });
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* CraftDeck design tokens — dark-only admin panel.
|
||||
* Chart series + status colors validated with the dataviz palette checker
|
||||
* against the card surface (#11161d).
|
||||
*/
|
||||
@theme {
|
||||
--color-bg: #0b0f14;
|
||||
--color-surface: #11161d;
|
||||
--color-surface2: #171e27;
|
||||
--color-surface3: #1f2833;
|
||||
--color-border: #232c37;
|
||||
--color-ink: #e6ebf2;
|
||||
--color-ink2: #9aa7b4;
|
||||
--color-muted: #64748b;
|
||||
--color-accent: #4ade80;
|
||||
--color-accent-deep: #16a34a;
|
||||
|
||||
--color-good: #0ca30c;
|
||||
--color-warn: #fab219;
|
||||
--color-serious: #ec835a;
|
||||
--color-crit: #d03b3b;
|
||||
--color-info: #60a5fa;
|
||||
|
||||
--color-series1: #3987e5;
|
||||
--color-series2: #199e70;
|
||||
--color-series3: #c98500;
|
||||
|
||||
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--color-bg);
|
||||
color: var(--color-ink);
|
||||
font-family: var(--font-sans);
|
||||
}
|
||||
|
||||
/* recessive scrollbars for console/log panes */
|
||||
.scroll-slim::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
.scroll-slim::-webkit-scrollbar-thumb {
|
||||
background: var(--color-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.scroll-slim::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||
<rect width="32" height="32" rx="7" fill="#0b0f14"/>
|
||||
<rect x="7" y="7" width="8" height="8" fill="#4ade80"/>
|
||||
<rect x="17" y="7" width="8" height="8" fill="#199e70"/>
|
||||
<rect x="7" y="17" width="8" height="8" fill="#199e70"/>
|
||||
<rect x="17" y="17" width="8" height="8" fill="#166534"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "CraftDeck — Minecraft Server Panel",
|
||||
description: "Self-hosted management dashboard for your Minecraft server",
|
||||
};
|
||||
|
||||
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className="antialiased">{children}</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { api } from "@/lib/api";
|
||||
import { Button, Input } from "@/components/ui";
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter();
|
||||
const [username, setUsername] = useState("admin");
|
||||
const [password, setPassword] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await api<{ mustChange: boolean }>("/api/auth/login", { body: { username, password } });
|
||||
router.push(res.mustChange ? "/settings" : "/");
|
||||
} catch (err) {
|
||||
setError((err as Error).message);
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-bg p-4">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="mb-6 flex flex-col items-center gap-2">
|
||||
<svg viewBox="0 0 32 32" className="h-12 w-12">
|
||||
<rect width="32" height="32" rx="7" fill="#11161d" />
|
||||
<rect x="7" y="7" width="8" height="8" fill="#4ade80" />
|
||||
<rect x="17" y="7" width="8" height="8" fill="#199e70" />
|
||||
<rect x="7" y="17" width="8" height="8" fill="#199e70" />
|
||||
<rect x="17" y="17" width="8" height="8" fill="#166534" />
|
||||
</svg>
|
||||
<h1 className="text-lg font-bold">CraftDeck</h1>
|
||||
<p className="text-xs text-muted">Sign in to manage your Minecraft server</p>
|
||||
</div>
|
||||
<form onSubmit={submit} className="space-y-3 rounded-xl border border-border bg-surface p-5">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-ink2">Username</label>
|
||||
<Input
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
autoComplete="username"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-ink2">Password</label>
|
||||
<Input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
autoComplete="current-password"
|
||||
autoFocus
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
{error && <div className="rounded-lg bg-crit/10 px-3 py-2 text-xs text-crit">{error}</div>}
|
||||
<Button type="submit" variant="primary" busy={busy} className="w-full">
|
||||
Sign in
|
||||
</Button>
|
||||
</form>
|
||||
<p className="mt-4 text-center text-[11px] leading-relaxed text-muted">
|
||||
First run? The initial password is in your <code>.env</code> (PANEL_ADMIN_PASSWORD)
|
||||
<br />
|
||||
or printed in the dashboard container logs.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,256 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { fmtTime } from "@/lib/format";
|
||||
|
||||
/**
|
||||
* SVG chart components following the dataviz method: recessive grid, thin
|
||||
* 2px lines, muted axis ink, hover crosshair + tooltip, legend + direct
|
||||
* labels when there are ≥2 series, text in ink tokens (never series color).
|
||||
*/
|
||||
|
||||
export type Point = { ts: number; v: number | null };
|
||||
export type Series = { name: string; color: string; points: Point[] };
|
||||
|
||||
/* ── mini sparkline (stat tiles) ───────────────────────────────────── */
|
||||
|
||||
export function Sparkline({
|
||||
points,
|
||||
color = "var(--color-series1)",
|
||||
height = 36,
|
||||
yMax,
|
||||
}: {
|
||||
points: Point[];
|
||||
color?: string;
|
||||
height?: number;
|
||||
yMax?: number;
|
||||
}) {
|
||||
const width = 220;
|
||||
const valid = points.filter((p) => p.v !== null) as Array<{ ts: number; v: number }>;
|
||||
if (valid.length < 2) {
|
||||
return <div className="h-9 text-[11px] leading-9 text-muted">collecting data…</div>;
|
||||
}
|
||||
const xs = valid.map((p) => p.ts);
|
||||
const ys = valid.map((p) => p.v);
|
||||
const xMin = Math.min(...xs);
|
||||
const xMax = Math.max(...xs);
|
||||
const yTop = yMax ?? (Math.max(...ys) * 1.1 || 1);
|
||||
const px = (t: number) => ((t - xMin) / Math.max(xMax - xMin, 1)) * width;
|
||||
const py = (v: number) => height - 2 - (v / yTop) * (height - 6);
|
||||
const d = valid.map((p, i) => `${i === 0 ? "M" : "L"}${px(p.ts).toFixed(1)},${py(p.v).toFixed(1)}`).join(" ");
|
||||
const area = `${d} L${px(xMax).toFixed(1)},${height} L${px(xMin).toFixed(1)},${height} Z`;
|
||||
return (
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="h-9 w-full" preserveAspectRatio="none" aria-hidden>
|
||||
<path d={area} fill={color} opacity="0.12" />
|
||||
<path d={d} fill="none" stroke={color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── full time-series chart ────────────────────────────────────────── */
|
||||
|
||||
export function TimeSeriesChart({
|
||||
series,
|
||||
height = 200,
|
||||
unit = "",
|
||||
yMax: yMaxProp,
|
||||
formatValue = (v: number) => `${Math.round(v * 10) / 10}${unit}`,
|
||||
}: {
|
||||
series: Series[];
|
||||
height?: number;
|
||||
unit?: string;
|
||||
yMax?: number;
|
||||
formatValue?: (v: number) => string;
|
||||
}) {
|
||||
const width = 760;
|
||||
const pad = { l: 44, r: 70, t: 10, b: 22 };
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const [hover, setHover] = useState<number | null>(null); // hovered timestamp
|
||||
|
||||
const { xMin, xMax, yMax, hasData } = useMemo(() => {
|
||||
const all = series.flatMap((s) => s.points.filter((p) => p.v !== null));
|
||||
if (all.length < 2) return { xMin: 0, xMax: 1, yMax: 1, hasData: false };
|
||||
const xs = series.flatMap((s) => s.points.map((p) => p.ts));
|
||||
return {
|
||||
xMin: Math.min(...xs),
|
||||
xMax: Math.max(...xs),
|
||||
yMax: yMaxProp ?? (Math.max(...all.map((p) => p.v as number)) * 1.15 || 1),
|
||||
hasData: true,
|
||||
};
|
||||
}, [series, yMaxProp]);
|
||||
|
||||
if (!hasData) {
|
||||
return (
|
||||
<div className="flex items-center justify-center text-xs text-muted" style={{ height }}>
|
||||
Not enough data yet — the sampler records a point every 10 seconds.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const plotW = width - pad.l - pad.r;
|
||||
const plotH = height - pad.t - pad.b;
|
||||
const px = (t: number) => pad.l + ((t - xMin) / Math.max(xMax - xMin, 1)) * plotW;
|
||||
const py = (v: number) => pad.t + plotH - (Math.min(v, yMax) / yMax) * plotH;
|
||||
|
||||
const gridYs = [0, 0.25, 0.5, 0.75, 1].map((f) => yMax * f);
|
||||
const timeTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => xMin + (xMax - xMin) * f);
|
||||
|
||||
// nearest points for tooltip
|
||||
const hoverData =
|
||||
hover === null
|
||||
? null
|
||||
: series
|
||||
.map((s) => {
|
||||
let best: Point | null = null;
|
||||
for (const p of s.points) {
|
||||
if (p.v === null) continue;
|
||||
if (!best || Math.abs(p.ts - hover) < Math.abs(best.ts - hover)) best = p;
|
||||
}
|
||||
return { name: s.name, color: s.color, point: best };
|
||||
})
|
||||
.filter((h) => h.point !== null);
|
||||
|
||||
const onMove = (e: React.MouseEvent) => {
|
||||
const rect = ref.current?.getBoundingClientRect();
|
||||
if (!rect) return;
|
||||
const fx = (e.clientX - rect.left) / rect.width; // 0..1 across rendered width
|
||||
const svgX = fx * width;
|
||||
if (svgX < pad.l || svgX > width - pad.r) return setHover(null);
|
||||
setHover(xMin + ((svgX - pad.l) / plotW) * (xMax - xMin));
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={ref} className="relative" onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
|
||||
<svg viewBox={`0 0 ${width} ${height}`} className="w-full" style={{ height }}>
|
||||
{/* recessive grid */}
|
||||
{gridYs.map((v) => (
|
||||
<g key={v}>
|
||||
<line x1={pad.l} x2={width - pad.r} y1={py(v)} y2={py(v)} stroke="var(--color-border)" strokeWidth="1" />
|
||||
<text x={pad.l - 6} y={py(v) + 3} textAnchor="end" fontSize="10" fill="var(--color-muted)">
|
||||
{formatValue(v)}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
{timeTicks.map((t) => (
|
||||
<text key={t} x={px(t)} y={height - 6} textAnchor="middle" fontSize="10" fill="var(--color-muted)">
|
||||
{fmtTime(t)}
|
||||
</text>
|
||||
))}
|
||||
|
||||
{/* series lines (2px) + direct labels at line ends */}
|
||||
{series.map((s) => {
|
||||
const valid = s.points.filter((p) => p.v !== null) as Array<{ ts: number; v: number }>;
|
||||
if (valid.length < 2) return null;
|
||||
const d = valid
|
||||
.map((p, i) => `${i === 0 ? "M" : "L"}${px(p.ts).toFixed(1)},${py(p.v).toFixed(1)}`)
|
||||
.join(" ");
|
||||
const last = valid[valid.length - 1];
|
||||
return (
|
||||
<g key={s.name}>
|
||||
<path d={d} fill="none" stroke={s.color} strokeWidth="2" strokeLinejoin="round" />
|
||||
{series.length > 1 && (
|
||||
<text
|
||||
x={width - pad.r + 6}
|
||||
y={py(last.v) + 3}
|
||||
fontSize="10"
|
||||
fill="var(--color-ink2)"
|
||||
>
|
||||
{s.name}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* hover crosshair + markers */}
|
||||
{hover !== null && hoverData && (
|
||||
<g>
|
||||
<line
|
||||
x1={px(hover)}
|
||||
x2={px(hover)}
|
||||
y1={pad.t}
|
||||
y2={pad.t + plotH}
|
||||
stroke="var(--color-muted)"
|
||||
strokeWidth="1"
|
||||
strokeDasharray="3 3"
|
||||
/>
|
||||
{hoverData.map((h) => (
|
||||
<circle
|
||||
key={h.name}
|
||||
cx={px(h.point!.ts)}
|
||||
cy={py(h.point!.v as number)}
|
||||
r="4"
|
||||
fill={h.color}
|
||||
stroke="var(--color-surface)"
|
||||
strokeWidth="2"
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{/* tooltip */}
|
||||
{hover !== null && hoverData && hoverData.length > 0 && (
|
||||
<div
|
||||
className="pointer-events-none absolute top-2 z-10 rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-xs shadow-xl"
|
||||
style={{
|
||||
left: `${Math.min(Math.max((px(hover) / width) * 100, 8), 78)}%`,
|
||||
}}
|
||||
>
|
||||
<div className="mb-0.5 font-medium text-ink2">{fmtTime(hoverData[0].point!.ts)}</div>
|
||||
{hoverData.map((h) => (
|
||||
<div key={h.name} className="flex items-center gap-1.5 text-ink">
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ background: h.color }} />
|
||||
<span className="text-ink2">{h.name}</span>
|
||||
<span className="ml-auto pl-3 font-medium">{formatValue(h.point!.v as number)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* legend (≥2 series) */}
|
||||
{series.length > 1 && (
|
||||
<div className="mt-1 flex flex-wrap gap-3 pl-11">
|
||||
{series.map((s) => (
|
||||
<span key={s.name} className="flex items-center gap-1.5 text-[11px] text-ink2">
|
||||
<span className="inline-block h-2 w-2 rounded-full" style={{ background: s.color }} />
|
||||
{s.name}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── horizontal usage bar (disk etc.) ──────────────────────────────── */
|
||||
|
||||
export function UsageBar({
|
||||
used,
|
||||
total,
|
||||
label,
|
||||
format,
|
||||
}: {
|
||||
used: number;
|
||||
total: number;
|
||||
label?: string;
|
||||
format: (v: number) => string;
|
||||
}) {
|
||||
const frac = total > 0 ? Math.min(used / total, 1) : 0;
|
||||
const color = frac > 0.9 ? "var(--color-crit)" : frac > 0.75 ? "var(--color-warn)" : "var(--color-series1)";
|
||||
return (
|
||||
<div>
|
||||
{label && (
|
||||
<div className="mb-1 flex justify-between text-xs text-ink2">
|
||||
<span>{label}</span>
|
||||
<span>
|
||||
{format(used)} / {format(total)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="h-2 overflow-hidden rounded-full bg-surface3">
|
||||
<div className="h-full rounded-full" style={{ width: `${frac * 100}%`, background: color }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import {
|
||||
Gauge,
|
||||
TerminalSquare,
|
||||
Users,
|
||||
ShieldCheck,
|
||||
Activity,
|
||||
Package,
|
||||
Archive,
|
||||
FolderOpen,
|
||||
Sliders,
|
||||
CalendarClock,
|
||||
Settings,
|
||||
Bell,
|
||||
LogOut,
|
||||
CircleDot,
|
||||
} from "lucide-react";
|
||||
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||
import { Badge, Toaster } from "@/components/ui";
|
||||
import { timeAgo } from "@/lib/format";
|
||||
|
||||
const NAV = [
|
||||
{ href: "/", label: "Overview", icon: Gauge },
|
||||
{ href: "/console", label: "Console", icon: TerminalSquare },
|
||||
{ href: "/players", label: "Players", icon: Users },
|
||||
{ href: "/access", label: "Access & Auth", icon: ShieldCheck },
|
||||
{ href: "/performance", label: "Performance", icon: Activity },
|
||||
{ href: "/mods", label: "Mods", icon: Package },
|
||||
{ href: "/backups", label: "Backups", icon: Archive },
|
||||
{ href: "/files", label: "Files", icon: FolderOpen },
|
||||
{ href: "/config", label: "Configuration", icon: Sliders },
|
||||
{ href: "/schedules", label: "Schedules", icon: CalendarClock },
|
||||
{ href: "/settings", label: "Settings", icon: Settings },
|
||||
];
|
||||
|
||||
type ServerSummary = {
|
||||
container: { running: boolean; status: string };
|
||||
players: { online: number; max: number };
|
||||
pendingApprovals: number;
|
||||
};
|
||||
|
||||
type NotificationRow = {
|
||||
id: number;
|
||||
ts: number;
|
||||
level: "info" | "success" | "warn" | "error";
|
||||
title: string;
|
||||
body: string | null;
|
||||
read: number;
|
||||
};
|
||||
|
||||
export function Shell({ children }: { children: React.ReactNode }) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
const { data: server, refresh } = useApi<ServerSummary>("/api/server", 15000);
|
||||
const { data: me } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me");
|
||||
const [running, setRunning] = useState<boolean | null>(null);
|
||||
const [players, setPlayers] = useState<number | null>(null);
|
||||
|
||||
useSSE("/api/stream/events", {
|
||||
metrics: (m) => {
|
||||
const d = m as { running: boolean; players: number | null };
|
||||
setRunning(d.running);
|
||||
if (d.players !== null) setPlayers(d.players);
|
||||
},
|
||||
notification: (n) => {
|
||||
const d = n as NotificationRow;
|
||||
toast(d.title, d.level === "error" ? "error" : d.level === "warn" ? "warn" : d.level);
|
||||
setUnreadBump((x) => x + 1);
|
||||
},
|
||||
});
|
||||
|
||||
const [unreadBump, setUnreadBump] = useState(0);
|
||||
const isRunning = running ?? server?.container.running ?? false;
|
||||
const playerCount = players ?? server?.players.online ?? 0;
|
||||
|
||||
const logout = async () => {
|
||||
await api("/api/auth/logout", { method: "POST" });
|
||||
router.push("/login");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* sidebar */}
|
||||
<aside className="fixed inset-y-0 left-0 z-40 flex w-52 flex-col border-r border-border bg-surface">
|
||||
<div className="flex items-center gap-2.5 px-4 py-4">
|
||||
<svg viewBox="0 0 32 32" className="h-7 w-7 rounded-md">
|
||||
<rect width="32" height="32" rx="7" fill="#0b0f14" />
|
||||
<rect x="7" y="7" width="8" height="8" fill="#4ade80" />
|
||||
<rect x="17" y="7" width="8" height="8" fill="#199e70" />
|
||||
<rect x="7" y="17" width="8" height="8" fill="#199e70" />
|
||||
<rect x="17" y="17" width="8" height="8" fill="#166534" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="text-sm font-bold leading-none">CraftDeck</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted">server panel</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-4">
|
||||
{NAV.map((item) => {
|
||||
const active = pathname === item.href;
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] font-medium transition-colors ${
|
||||
active ? "bg-surface3 text-ink" : "text-ink2 hover:bg-surface2 hover:text-ink"
|
||||
}`}
|
||||
>
|
||||
<Icon size={15} className={active ? "text-accent" : ""} />
|
||||
{item.label}
|
||||
{item.href === "/access" && (server?.pendingApprovals ?? 0) > 0 && (
|
||||
<span className="ml-auto rounded-full bg-warn/20 px-1.5 text-[10px] font-semibold text-warn">
|
||||
{server!.pendingApprovals}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
<div className="border-t border-border p-3 text-[11px] text-muted">
|
||||
Signed in as <span className="text-ink2">{me?.username ?? "…"}</span>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
{/* main */}
|
||||
<div className="ml-52 flex min-h-screen flex-1 flex-col">
|
||||
<header className="sticky top-0 z-30 flex items-center justify-between border-b border-border bg-bg/90 px-6 py-3 backdrop-blur">
|
||||
<div className="flex items-center gap-3">
|
||||
<span
|
||||
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${
|
||||
isRunning ? "border-good/40 bg-good/10 text-good" : "border-crit/40 bg-crit/10 text-crit"
|
||||
}`}
|
||||
>
|
||||
<CircleDot size={12} />
|
||||
{isRunning ? "Online" : "Offline"}
|
||||
</span>
|
||||
{isRunning && (
|
||||
<span className="text-xs text-ink2">
|
||||
{playerCount} player{playerCount === 1 ? "" : "s"} online
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<NotificationBell bump={unreadBump} />
|
||||
<button
|
||||
onClick={logout}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-xs text-ink2 hover:text-ink"
|
||||
>
|
||||
<LogOut size={13} /> Log out
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{me?.mustChange && pathname !== "/settings" && (
|
||||
<div className="border-b border-warn/30 bg-warn/10 px-6 py-2 text-xs text-warn">
|
||||
You are using the initial admin password —{" "}
|
||||
<Link href="/settings" className="underline">
|
||||
change it now
|
||||
</Link>
|
||||
.
|
||||
</div>
|
||||
)}
|
||||
|
||||
<main className="flex-1 p-6">{children}</main>
|
||||
</div>
|
||||
<Toaster />
|
||||
{/* refresh server summary when the tab regains focus */}
|
||||
<FocusRefresher onFocus={refresh} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FocusRefresher({ onFocus }: { onFocus: () => void }) {
|
||||
useEffect(() => {
|
||||
window.addEventListener("focus", onFocus);
|
||||
return () => window.removeEventListener("focus", onFocus);
|
||||
}, [onFocus]);
|
||||
return null;
|
||||
}
|
||||
|
||||
function NotificationBell({ bump }: { bump: number }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [items, setItems] = useState<NotificationRow[]>([]);
|
||||
const [unread, setUnread] = useState(0);
|
||||
const panelRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const d = await api<{ notifications: NotificationRow[]; unread: number }>("/api/notifications?limit=30");
|
||||
setItems(d.notifications);
|
||||
setUnread(d.unread);
|
||||
} catch {}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [bump]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onClick = (e: MouseEvent) => {
|
||||
if (!panelRef.current?.contains(e.target as Node)) setOpen(false);
|
||||
};
|
||||
document.addEventListener("mousedown", onClick);
|
||||
return () => document.removeEventListener("mousedown", onClick);
|
||||
}, [open]);
|
||||
|
||||
const markAll = async () => {
|
||||
await api("/api/notifications", { body: { op: "read-all" } });
|
||||
void load();
|
||||
};
|
||||
|
||||
const levelColor = { info: "info", success: "good", warn: "warn", error: "crit" } as const;
|
||||
|
||||
return (
|
||||
<div className="relative" ref={panelRef}>
|
||||
<button
|
||||
onClick={() => {
|
||||
setOpen((v) => !v);
|
||||
if (!open) void load();
|
||||
}}
|
||||
className="relative flex items-center rounded-lg border border-border bg-surface2 p-2 text-ink2 hover:text-ink"
|
||||
>
|
||||
<Bell size={14} />
|
||||
{unread > 0 && (
|
||||
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-crit px-1 text-[9px] font-bold text-white">
|
||||
{unread > 99 ? "99+" : unread}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="absolute right-0 top-10 z-50 w-96 rounded-xl border border-border bg-surface shadow-2xl">
|
||||
<header className="flex items-center justify-between border-b border-border px-3.5 py-2.5">
|
||||
<span className="text-xs font-semibold">Notifications</span>
|
||||
<button onClick={markAll} className="text-[11px] text-ink2 hover:text-ink">
|
||||
Mark all read
|
||||
</button>
|
||||
</header>
|
||||
<div className="scroll-slim max-h-96 overflow-y-auto">
|
||||
{items.length === 0 && <div className="px-4 py-8 text-center text-xs text-muted">Nothing yet</div>}
|
||||
{items.map((n) => (
|
||||
<div
|
||||
key={n.id}
|
||||
className={`border-b border-border/50 px-3.5 py-2.5 ${n.read ? "opacity-55" : ""}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge color={levelColor[n.level]}>{n.level}</Badge>
|
||||
<span className="text-xs font-medium text-ink">{n.title}</span>
|
||||
<span className="ml-auto shrink-0 text-[10px] text-muted">{timeAgo(n.ts)}</span>
|
||||
</div>
|
||||
{n.body && <div className="mt-1 text-[11px] leading-relaxed text-ink2">{n.body}</div>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,303 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState, type ReactNode, type ButtonHTMLAttributes, type InputHTMLAttributes } from "react";
|
||||
import { X, Loader2 } from "lucide-react";
|
||||
import { toast, type ToastLevel } from "@/lib/api";
|
||||
|
||||
/* ── layout primitives ─────────────────────────────────────────────── */
|
||||
|
||||
export function Card({
|
||||
title,
|
||||
actions,
|
||||
children,
|
||||
className = "",
|
||||
pad = true,
|
||||
}: {
|
||||
title?: ReactNode;
|
||||
actions?: ReactNode;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
pad?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<section className={`rounded-xl border border-border bg-surface ${className}`}>
|
||||
{(title || actions) && (
|
||||
<header className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
|
||||
<h2 className="text-sm font-semibold text-ink">{title}</h2>
|
||||
<div className="flex items-center gap-2">{actions}</div>
|
||||
</header>
|
||||
)}
|
||||
<div className={pad ? "p-4" : ""}>{children}</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export function StatTile({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
accent,
|
||||
children,
|
||||
}: {
|
||||
label: string;
|
||||
value: ReactNode;
|
||||
sub?: ReactNode;
|
||||
accent?: "good" | "warn" | "crit" | "info" | undefined;
|
||||
children?: ReactNode; // e.g. a sparkline
|
||||
}) {
|
||||
const valueColor =
|
||||
accent === "good"
|
||||
? "text-good"
|
||||
: accent === "warn"
|
||||
? "text-warn"
|
||||
: accent === "crit"
|
||||
? "text-crit"
|
||||
: "text-ink";
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-surface p-4">
|
||||
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
|
||||
<div className={`mt-1 text-2xl font-semibold ${valueColor}`}>{value}</div>
|
||||
{sub && <div className="mt-0.5 text-xs text-ink2">{sub}</div>}
|
||||
{children && <div className="mt-2">{children}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── controls ──────────────────────────────────────────────────────── */
|
||||
|
||||
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||
variant?: "primary" | "ghost" | "danger" | "warn";
|
||||
size?: "sm" | "md";
|
||||
busy?: boolean;
|
||||
};
|
||||
|
||||
export function Button({
|
||||
variant = "ghost",
|
||||
size = "md",
|
||||
busy = false,
|
||||
className = "",
|
||||
children,
|
||||
disabled,
|
||||
...rest
|
||||
}: ButtonProps) {
|
||||
const base =
|
||||
"inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-colors disabled:opacity-45 disabled:cursor-not-allowed";
|
||||
const sizes = size === "sm" ? "px-2.5 py-1 text-xs" : "px-3.5 py-1.5 text-sm";
|
||||
const variants = {
|
||||
primary: "bg-accent-deep text-white hover:bg-accent-deep/80",
|
||||
ghost: "border border-border bg-surface2 text-ink hover:bg-surface3",
|
||||
danger: "border border-crit/40 bg-crit/10 text-crit hover:bg-crit/20",
|
||||
warn: "border border-warn/40 bg-warn/10 text-warn hover:bg-warn/20",
|
||||
}[variant];
|
||||
return (
|
||||
<button className={`${base} ${sizes} ${variants} ${className}`} disabled={disabled || busy} {...rest}>
|
||||
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||
{children}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||
const { className = "", ...rest } = props;
|
||||
return (
|
||||
<input
|
||||
className={`rounded-lg border border-border bg-surface2 px-3 py-1.5 text-sm text-ink placeholder:text-muted focus:border-accent-deep focus:outline-none ${className}`}
|
||||
{...rest}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function Select({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
className = "",
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (v: string) => void;
|
||||
options: Array<{ value: string; label: string }>;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<select
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={`rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-sm text-ink focus:border-accent-deep focus:outline-none ${className}`}
|
||||
>
|
||||
{options.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
);
|
||||
}
|
||||
|
||||
export function Toggle({
|
||||
checked,
|
||||
onChange,
|
||||
label,
|
||||
}: {
|
||||
checked: boolean;
|
||||
onChange: (v: boolean) => void;
|
||||
label?: string;
|
||||
}) {
|
||||
return (
|
||||
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-ink">
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={checked}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors ${checked ? "bg-accent-deep" : "bg-surface3"}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ${checked ? "left-4.5" : "left-0.5"}`}
|
||||
/>
|
||||
</button>
|
||||
{label}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
color = "muted",
|
||||
children,
|
||||
}: {
|
||||
color?: "good" | "warn" | "crit" | "info" | "muted" | "accent";
|
||||
children: ReactNode;
|
||||
}) {
|
||||
const map = {
|
||||
good: "bg-good/15 text-good border-good/30",
|
||||
warn: "bg-warn/15 text-warn border-warn/30",
|
||||
crit: "bg-crit/15 text-crit border-crit/30",
|
||||
info: "bg-info/15 text-info border-info/30",
|
||||
accent: "bg-accent/15 text-accent border-accent/30",
|
||||
muted: "bg-surface3 text-ink2 border-border",
|
||||
}[color];
|
||||
return (
|
||||
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${map}`}>
|
||||
{children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ icon, title, hint }: { icon?: ReactNode; title: string; hint?: string }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-1.5 py-10 text-center">
|
||||
{icon && <div className="text-muted">{icon}</div>}
|
||||
<div className="text-sm font-medium text-ink2">{title}</div>
|
||||
{hint && <div className="max-w-sm text-xs text-muted">{hint}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── modal ─────────────────────────────────────────────────────────── */
|
||||
|
||||
export function Modal({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
wide = false,
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title: string;
|
||||
children: ReactNode;
|
||||
wide?: boolean;
|
||||
}) {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => window.removeEventListener("keydown", onKey);
|
||||
}, [open, onClose]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||
<div
|
||||
className={`w-full ${wide ? "max-w-2xl" : "max-w-md"} rounded-xl border border-border bg-surface shadow-2xl`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||
<h3 className="text-sm font-semibold">{title}</h3>
|
||||
<button onClick={onClose} className="text-muted hover:text-ink">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</header>
|
||||
<div className="p-4">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── tabs ──────────────────────────────────────────────────────────── */
|
||||
|
||||
export function Tabs({
|
||||
tabs,
|
||||
active,
|
||||
onChange,
|
||||
}: {
|
||||
tabs: Array<{ id: string; label: string }>;
|
||||
active: string;
|
||||
onChange: (id: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex gap-1 rounded-lg border border-border bg-surface2 p-1">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => onChange(t.id)}
|
||||
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||
active === t.id ? "bg-surface3 text-ink" : "text-muted hover:text-ink2"
|
||||
}`}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── toaster ───────────────────────────────────────────────────────── */
|
||||
|
||||
type ToastItem = { id: number; message: string; level: ToastLevel };
|
||||
|
||||
export function Toaster() {
|
||||
const [items, setItems] = useState<ToastItem[]>([]);
|
||||
useEffect(() => {
|
||||
let nextId = 1;
|
||||
const onToast = (e: Event) => {
|
||||
const { message, level } = (e as CustomEvent).detail as { message: string; level: ToastLevel };
|
||||
const id = nextId++;
|
||||
setItems((xs) => [...xs, { id, message, level }]);
|
||||
setTimeout(() => setItems((xs) => xs.filter((x) => x.id !== id)), 5000);
|
||||
};
|
||||
window.addEventListener("craftdeck-toast", onToast);
|
||||
return () => window.removeEventListener("craftdeck-toast", onToast);
|
||||
}, []);
|
||||
|
||||
const colors: Record<ToastLevel, string> = {
|
||||
info: "border-info/40",
|
||||
success: "border-good/40",
|
||||
warn: "border-warn/40",
|
||||
error: "border-crit/40",
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-80 flex-col gap-2">
|
||||
{items.map((t) => (
|
||||
<div
|
||||
key={t.id}
|
||||
className={`pointer-events-auto rounded-lg border ${colors[t.level]} bg-surface2 px-3.5 py-2.5 text-sm text-ink shadow-xl`}
|
||||
>
|
||||
{t.message}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { toast };
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { boot } = await import("./server/boot");
|
||||
boot();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(message: string, public status: number) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
export async function api<T = unknown>(
|
||||
path: string,
|
||||
opts: { method?: string; body?: unknown } = {}
|
||||
): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
|
||||
headers: opts.body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
if (res.status === 401) {
|
||||
window.location.href = "/login";
|
||||
throw new ApiError("Unauthorized", 401);
|
||||
}
|
||||
let payload: { ok?: boolean; data?: T; error?: string };
|
||||
try {
|
||||
payload = await res.json();
|
||||
} catch {
|
||||
throw new ApiError(`Request failed (${res.status})`, res.status);
|
||||
}
|
||||
if (!res.ok || payload.ok === false) {
|
||||
throw new ApiError(payload.error ?? `Request failed (${res.status})`, res.status);
|
||||
}
|
||||
return payload.data as T;
|
||||
}
|
||||
|
||||
/** Poll a GET endpoint; refresh() for manual refetch. */
|
||||
export function useApi<T>(path: string, intervalMs = 0) {
|
||||
const [data, setData] = useState<T | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const alive = useRef(true);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const d = await api<T>(path);
|
||||
if (!alive.current) return;
|
||||
setData(d);
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
if (!alive.current) return;
|
||||
setError((err as Error).message);
|
||||
} finally {
|
||||
if (alive.current) setLoading(false);
|
||||
}
|
||||
}, [path]);
|
||||
|
||||
useEffect(() => {
|
||||
alive.current = true;
|
||||
setLoading(true);
|
||||
void refresh();
|
||||
const timer = intervalMs > 0 ? setInterval(() => void refresh(), intervalMs) : null;
|
||||
return () => {
|
||||
alive.current = false;
|
||||
if (timer) clearInterval(timer);
|
||||
};
|
||||
}, [refresh, intervalMs]);
|
||||
|
||||
return { data, error, loading, refresh };
|
||||
}
|
||||
|
||||
/** Subscribe to a server-sent-events endpoint. Reconnects automatically. */
|
||||
export function useSSE(path: string, handlers: Record<string, (data: unknown) => void>) {
|
||||
const handlersRef = useRef(handlers);
|
||||
handlersRef.current = handlers;
|
||||
|
||||
useEffect(() => {
|
||||
const source = new EventSource(path);
|
||||
const names = Object.keys(handlersRef.current);
|
||||
const bound: Array<[string, (e: MessageEvent) => void]> = [];
|
||||
for (const name of names) {
|
||||
const fn = (e: MessageEvent) => {
|
||||
try {
|
||||
handlersRef.current[name]?.(JSON.parse(e.data));
|
||||
} catch {}
|
||||
};
|
||||
bound.push([name, fn]);
|
||||
if (name === "message") source.onmessage = fn;
|
||||
else source.addEventListener(name, fn);
|
||||
}
|
||||
return () => {
|
||||
for (const [name, fn] of bound) {
|
||||
if (name !== "message") source.removeEventListener(name, fn);
|
||||
}
|
||||
source.close();
|
||||
};
|
||||
}, [path]);
|
||||
}
|
||||
|
||||
// ── toasts (event-based so any code can raise one) ─────────────────────
|
||||
|
||||
export type ToastLevel = "info" | "success" | "warn" | "error";
|
||||
|
||||
export function toast(message: string, level: ToastLevel = "info") {
|
||||
window.dispatchEvent(new CustomEvent("craftdeck-toast", { detail: { message, level } }));
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
export function fmtBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "—";
|
||||
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||
let v = bytes;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < units.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
||||
}
|
||||
|
||||
export function fmtDuration(ms: number | null | undefined): string {
|
||||
if (ms === null || ms === undefined || ms < 0) return "—";
|
||||
const s = Math.floor(ms / 1000);
|
||||
const d = Math.floor(s / 86400);
|
||||
const h = Math.floor((s % 86400) / 3600);
|
||||
const m = Math.floor((s % 3600) / 60);
|
||||
if (d > 0) return `${d}d ${h}h`;
|
||||
if (h > 0) return `${h}h ${m}m`;
|
||||
if (m > 0) return `${m}m ${s % 60}s`;
|
||||
return `${s}s`;
|
||||
}
|
||||
|
||||
export function timeAgo(ts: number | null | undefined): string {
|
||||
if (!ts) return "never";
|
||||
const diff = Date.now() - ts;
|
||||
if (diff < 30_000) return "just now";
|
||||
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||
}
|
||||
|
||||
export function fmtTime(ts: number): string {
|
||||
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||
}
|
||||
|
||||
export function fmtDateTime(ts: number | null | undefined): string {
|
||||
if (!ts) return "—";
|
||||
return new Date(ts).toLocaleString([], {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
export function fmtCountdown(untilTs: number): string {
|
||||
const left = untilTs - Date.now();
|
||||
if (left <= 0) return "0:00";
|
||||
const m = Math.floor(left / 60_000);
|
||||
const s = Math.floor((left % 60_000) / 1000);
|
||||
return `${m}:${String(s).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE, verifySessionToken } from "@/server/session";
|
||||
|
||||
const PUBLIC_PATHS = new Set(["/login", "/api/auth/login", "/api/health"]);
|
||||
|
||||
export async function proxy(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl;
|
||||
if (PUBLIC_PATHS.has(pathname)) return NextResponse.next();
|
||||
|
||||
const token = req.cookies.get(SESSION_COOKIE)?.value;
|
||||
const secret = process.env.PANEL_SESSION_SECRET || "craftdeck-dev-secret-do-not-use";
|
||||
const session = await verifySessionToken(secret, token);
|
||||
|
||||
if (!session) {
|
||||
if (pathname.startsWith("/api/")) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
const url = req.nextUrl.clone();
|
||||
url.pathname = "/login";
|
||||
url.search = "";
|
||||
return NextResponse.redirect(url);
|
||||
}
|
||||
return NextResponse.next();
|
||||
}
|
||||
|
||||
export const config = {
|
||||
// Everything except Next.js internals and static assets.
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon.svg).*)"],
|
||||
};
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { DockerError } from "./docker";
|
||||
import { RconError } from "./rcon";
|
||||
|
||||
/**
|
||||
* Small helpers for route handlers. Authentication is enforced globally in
|
||||
* middleware.ts; these standardize success/error payloads and translate
|
||||
* infrastructure failures into friendly 503s.
|
||||
*/
|
||||
|
||||
export function ok(data: unknown, init?: ResponseInit): NextResponse {
|
||||
return NextResponse.json({ ok: true, data }, init);
|
||||
}
|
||||
|
||||
export function fail(message: string, status = 400): NextResponse {
|
||||
return NextResponse.json({ ok: false, error: message }, { status });
|
||||
}
|
||||
|
||||
export function handle(fn: () => Promise<NextResponse> | NextResponse) {
|
||||
return (async () => {
|
||||
try {
|
||||
return await fn();
|
||||
} catch (err) {
|
||||
if (err instanceof DockerError && err.kind === "unavailable") {
|
||||
return fail("Docker is unreachable from the panel", 503);
|
||||
}
|
||||
if (err instanceof DockerError && err.kind === "not_found") {
|
||||
return fail("Container not found — is the stack deployed?", 503);
|
||||
}
|
||||
if (err instanceof RconError) {
|
||||
return fail(
|
||||
err.kind === "auth"
|
||||
? "RCON authentication failed — check RCON_PASSWORD"
|
||||
: "Server console unreachable (is the server running?)",
|
||||
503
|
||||
);
|
||||
}
|
||||
return fail((err as Error).message || "Internal error", 500);
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export async function readJson<T>(req: Request): Promise<T> {
|
||||
try {
|
||||
return (await req.json()) as T;
|
||||
} catch {
|
||||
throw new Error("Invalid JSON body");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { db, getSetting, logActivity } from "./db";
|
||||
import { bus } from "./bus";
|
||||
import { notify } from "./notify";
|
||||
import { env } from "./env";
|
||||
import { generatePassword } from "./passwords";
|
||||
import {
|
||||
isValidUsername,
|
||||
whitelistAdd,
|
||||
whitelistRemove,
|
||||
whitelistList,
|
||||
kickPlayer,
|
||||
easyauthList,
|
||||
easyauthRemove,
|
||||
easyauthSetPassword,
|
||||
} from "./mc";
|
||||
|
||||
/**
|
||||
* The approval state machine for offline-mode security:
|
||||
*
|
||||
* approve → whitelisted + "pending" with a short registration window
|
||||
* pending + /register in time → "registered" (can play forever)
|
||||
* pending + window lapses → whitelist removed, "expired"
|
||||
* revoke → whitelist + EasyAuth account removed
|
||||
*
|
||||
* Enforcement runs in a background watcher that reconciles against
|
||||
* EasyAuth's registered-account list via RCON.
|
||||
*/
|
||||
|
||||
export 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;
|
||||
};
|
||||
|
||||
function windowMs(): number {
|
||||
const minutes = Number(getSetting("registration_window_minutes", String(env.registrationWindowMinutes)));
|
||||
return Math.max(0.5, minutes) * 60 * 1000;
|
||||
}
|
||||
|
||||
export function listApproved(): ApprovedPlayer[] {
|
||||
return db()
|
||||
.prepare("SELECT * FROM approved_players ORDER BY approved_at DESC")
|
||||
.all() as unknown as ApprovedPlayer[];
|
||||
}
|
||||
|
||||
export async function approvePlayer(username: string, note?: string): Promise<ApprovedPlayer> {
|
||||
if (!isValidUsername(username)) throw new Error("Invalid username (3–16 chars, letters/digits/underscore)");
|
||||
const existing = db()
|
||||
.prepare("SELECT * FROM approved_players WHERE username = ?")
|
||||
.get(username) as unknown as ApprovedPlayer | undefined;
|
||||
if (existing?.status === "registered") throw new Error(`${username} is already registered`);
|
||||
if (existing?.status === "pending") throw new Error(`${username} already has an open registration window`);
|
||||
|
||||
await whitelistAdd(username); // throws if the server is unreachable — approval requires a running server
|
||||
|
||||
const now = Date.now();
|
||||
const expires = now + windowMs();
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT INTO approved_players(username, status, approved_at, window_expires_at, note)
|
||||
VALUES(?, 'pending', ?, ?, ?)
|
||||
ON CONFLICT(username) DO UPDATE SET
|
||||
status = 'pending', approved_at = excluded.approved_at,
|
||||
window_expires_at = excluded.window_expires_at, registered_at = NULL,
|
||||
note = COALESCE(excluded.note, approved_players.note)`
|
||||
)
|
||||
.run(username, now, expires, note ?? null);
|
||||
logActivity("approve", username, `Registration window open for ${Math.round(windowMs() / 60000)} min`);
|
||||
notify("info", `Approved ${username}`, "They can now join and /register — the window is ticking.");
|
||||
return db().prepare("SELECT * FROM approved_players WHERE username = ?").get(username) as unknown as ApprovedPlayer;
|
||||
}
|
||||
|
||||
export async function revokePlayer(username: string): Promise<void> {
|
||||
if (!isValidUsername(username)) throw new Error("Invalid username");
|
||||
try {
|
||||
await whitelistRemove(username);
|
||||
} catch {}
|
||||
try {
|
||||
await easyauthRemove(username);
|
||||
} catch {}
|
||||
try {
|
||||
await kickPlayer(username, "Your access was revoked");
|
||||
} catch {}
|
||||
db()
|
||||
.prepare("UPDATE approved_players SET status = 'revoked', window_expires_at = NULL WHERE username = ?")
|
||||
.run(username);
|
||||
logActivity("revoke", username);
|
||||
notify("warn", `Revoked ${username}`, "Whitelist entry and account removed.");
|
||||
}
|
||||
|
||||
/** Re-open a registration window for an expired/revoked player. */
|
||||
export async function reopenWindow(username: string): Promise<void> {
|
||||
const row = db()
|
||||
.prepare("SELECT * FROM approved_players WHERE username = ?")
|
||||
.get(username) as unknown as ApprovedPlayer | undefined;
|
||||
if (!row) throw new Error("Unknown player");
|
||||
if (row.status === "registered") throw new Error("Player is already registered");
|
||||
await whitelistAdd(username);
|
||||
db()
|
||||
.prepare("UPDATE approved_players SET status = 'pending', approved_at = ?, window_expires_at = ? WHERE username = ?")
|
||||
.run(Date.now(), Date.now() + windowMs(), username);
|
||||
logActivity("approve", username, "Registration window re-opened");
|
||||
notify("info", `Re-opened window for ${username}`);
|
||||
}
|
||||
|
||||
/** Set a temporary password for a registered player (returned once, never stored). */
|
||||
export async function resetPassword(username: string): Promise<string> {
|
||||
const temp = generatePassword(10);
|
||||
await easyauthSetPassword(username, temp);
|
||||
try {
|
||||
await kickPlayer(username, "Your password was reset — log in with the new one");
|
||||
} catch {}
|
||||
logActivity("password_reset", username);
|
||||
notify("info", `Password reset for ${username}`, "Share the temporary password with them privately.", {
|
||||
discord: false,
|
||||
});
|
||||
return temp;
|
||||
}
|
||||
|
||||
/** Delete the EasyAuth account but keep them whitelisted with a fresh window. */
|
||||
export async function forceReregister(username: string): Promise<void> {
|
||||
await easyauthRemove(username);
|
||||
try {
|
||||
await kickPlayer(username, "Please rejoin and /register a new password");
|
||||
} catch {}
|
||||
await whitelistAdd(username);
|
||||
db()
|
||||
.prepare("UPDATE approved_players SET status = 'pending', window_expires_at = ?, registered_at = NULL WHERE username = ?")
|
||||
.run(Date.now() + windowMs(), username);
|
||||
logActivity("approve", username, "Forced re-registration");
|
||||
}
|
||||
|
||||
/** Import whitelist entries that were added outside the panel. */
|
||||
export async function importFromWhitelist(): Promise<number> {
|
||||
const [wl, registered] = await Promise.all([whitelistList(), easyauthList()]);
|
||||
const registeredSet = new Set(registered.map((n) => n.toLowerCase()));
|
||||
let added = 0;
|
||||
const insert = db().prepare(
|
||||
`INSERT OR IGNORE INTO approved_players(username, status, approved_at, registered_at, note)
|
||||
VALUES(?, ?, ?, ?, 'imported from whitelist')`
|
||||
);
|
||||
for (const name of wl) {
|
||||
const isReg = registeredSet.has(name.toLowerCase());
|
||||
const res = insert.run(name, isReg ? "registered" : "expired", Date.now(), isReg ? Date.now() : null);
|
||||
if (res.changes > 0) added++;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
// ── background watcher ──────────────────────────────────────────────────
|
||||
|
||||
const g = globalThis as unknown as { __craftdeckAuthWatch?: { timer: NodeJS.Timeout } };
|
||||
|
||||
async function reconcile() {
|
||||
const pending = db()
|
||||
.prepare("SELECT * FROM approved_players WHERE status = 'pending'")
|
||||
.all() as unknown as ApprovedPlayer[];
|
||||
if (pending.length === 0) return;
|
||||
|
||||
let registered: Set<string> | null = null;
|
||||
try {
|
||||
registered = new Set((await easyauthList()).map((n) => n.toLowerCase()));
|
||||
} catch {
|
||||
// Server offline: don't expire windows while nobody could register anyway —
|
||||
// extend windows by the poll interval instead of punishing the player.
|
||||
db()
|
||||
.prepare("UPDATE approved_players SET window_expires_at = window_expires_at + 5000 WHERE status = 'pending'")
|
||||
.run();
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
for (const p of pending) {
|
||||
if (registered.has(p.username.toLowerCase())) {
|
||||
db()
|
||||
.prepare("UPDATE approved_players SET status = 'registered', registered_at = ?, window_expires_at = NULL WHERE id = ?")
|
||||
.run(now, p.id);
|
||||
logActivity("register", p.username);
|
||||
notify("success", `${p.username} registered`, "They can now log in with their password.");
|
||||
bus().emit("mc-event", { ts: now, type: "register", player: p.username });
|
||||
} else if (p.window_expires_at !== null && p.window_expires_at < now) {
|
||||
try {
|
||||
await whitelistRemove(p.username);
|
||||
} catch {}
|
||||
try {
|
||||
await kickPlayer(p.username, "Registration window expired — ask the admin to approve you again");
|
||||
} catch {}
|
||||
db().prepare("UPDATE approved_players SET status = 'expired' WHERE id = ?").run(p.id);
|
||||
logActivity("expire", p.username, "Registration window expired");
|
||||
notify("warn", `Window expired for ${p.username}`, "They didn't register in time. Re-approve when they're ready.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function startAuthWatcher() {
|
||||
if (g.__craftdeckAuthWatch) return;
|
||||
g.__craftdeckAuthWatch = {
|
||||
timer: setInterval(() => void reconcile().catch(() => {}), 5000),
|
||||
};
|
||||
// React immediately to registration hints from the log watcher.
|
||||
bus().on("mc-event", (e: { type: string }) => {
|
||||
if (e.type === "register-hint") void reconcile().catch(() => {});
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { execInContainer, powerContainer, inspectContainer } from "./docker";
|
||||
import { db, logActivity } from "./db";
|
||||
import { notify } from "./notify";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Backup management on top of the itzg/mc-backup sidecar (scheduled tars)
|
||||
* plus panel-driven ad-hoc backups, restores, and deletion.
|
||||
*/
|
||||
|
||||
const BACKUP_NAME_RE = /^[\w][\w.\-]*\.(tgz|tar\.gz)$/;
|
||||
|
||||
export type BackupInfo = { name: string; size: number; mtime: number };
|
||||
|
||||
function backupPath(name: string): string {
|
||||
if (!BACKUP_NAME_RE.test(name) || name !== path.basename(name)) {
|
||||
throw new Error("Invalid backup file name");
|
||||
}
|
||||
return path.join(env.backupsDir, name);
|
||||
}
|
||||
|
||||
export function listBackups(): BackupInfo[] {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(env.backupsDir);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return entries
|
||||
.filter((n) => BACKUP_NAME_RE.test(n))
|
||||
.map((name) => {
|
||||
const st = fs.statSync(path.join(env.backupsDir, name));
|
||||
return { name, size: st.size, mtime: st.mtimeMs };
|
||||
})
|
||||
.sort((a, b) => b.mtime - a.mtime);
|
||||
}
|
||||
|
||||
export async function triggerBackup(): Promise<string> {
|
||||
const before = new Set(listBackups().map((b) => b.name));
|
||||
const res = await execInContainer(env.backupContainer, ["backup", "now"], 30 * 60 * 1000);
|
||||
if (res.exitCode !== 0) {
|
||||
notify("error", "Backup failed", res.output.slice(-800));
|
||||
throw new Error(`Backup failed: ${res.output.slice(-300)}`);
|
||||
}
|
||||
const created = listBackups().find((b) => !before.has(b.name));
|
||||
logActivity("backup", null, created?.name ?? "manual backup");
|
||||
notify("success", "Backup completed", created ? created.name : "", { discord: false });
|
||||
return created?.name ?? "backup completed";
|
||||
}
|
||||
|
||||
export async function restoreBackup(name: string): Promise<void> {
|
||||
const file = backupPath(name);
|
||||
if (!fs.existsSync(file)) throw new Error("Backup file not found");
|
||||
|
||||
const state = await inspectContainer(env.mcContainer);
|
||||
if (state.running) await powerContainer(env.mcContainer, "stop");
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
execFile(
|
||||
"tar",
|
||||
["-xpzf", file, "-C", env.mcDataDir],
|
||||
{ timeout: 30 * 60 * 1000 },
|
||||
(err, _stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve())
|
||||
);
|
||||
});
|
||||
|
||||
await powerContainer(env.mcContainer, "start");
|
||||
logActivity("restore", null, name);
|
||||
notify("success", "Backup restored", `World restored from ${name}. Server starting.`);
|
||||
}
|
||||
|
||||
export function deleteBackup(name: string): void {
|
||||
fs.unlinkSync(backupPath(name));
|
||||
logActivity("backup_delete", null, name);
|
||||
}
|
||||
|
||||
export function backupsDirSize(): number {
|
||||
return listBackups().reduce((sum, b) => sum + b.size, 0);
|
||||
}
|
||||
|
||||
export function getBackupStream(name: string): { stream: fs.ReadStream; size: number } {
|
||||
const file = backupPath(name);
|
||||
const st = fs.statSync(file);
|
||||
return { stream: fs.createReadStream(file), size: st.size };
|
||||
}
|
||||
|
||||
/** Take a safety backup and wait for it, used before risky operations. */
|
||||
export async function safetyBackup(reason: string): Promise<void> {
|
||||
try {
|
||||
await triggerBackup();
|
||||
} catch (err) {
|
||||
db();
|
||||
notify("warn", "Safety backup failed", `Proceeding with ${reason} anyway: ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import fs from "node:fs";
|
||||
import { db } from "./db";
|
||||
import { env } from "./env";
|
||||
import { hashPassword, generatePassword } from "./passwords";
|
||||
import { startLogWatcher } from "./logwatch";
|
||||
import { startSampler } from "./sampler";
|
||||
import { startAuthWatcher } from "./authflow";
|
||||
import { startScheduler } from "./scheduler";
|
||||
|
||||
/**
|
||||
* One-time process bootstrap, invoked from instrumentation.ts when the
|
||||
* Next.js server starts. Guarded against dev-mode HMR re-execution.
|
||||
*/
|
||||
|
||||
const g = globalThis as unknown as { __craftdeckBooted?: boolean };
|
||||
|
||||
export function boot() {
|
||||
if (g.__craftdeckBooted) return;
|
||||
g.__craftdeckBooted = true;
|
||||
|
||||
for (const dir of [env.appDataDir, env.mcDataDir, env.backupsDir]) {
|
||||
try {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
} catch {}
|
||||
}
|
||||
|
||||
seedAdminUser();
|
||||
startLogWatcher();
|
||||
startSampler();
|
||||
startAuthWatcher();
|
||||
startScheduler();
|
||||
|
||||
console.log(`[craftdeck] panel services started (data: ${env.appDataDir})`);
|
||||
}
|
||||
|
||||
function seedAdminUser() {
|
||||
const count = (db().prepare("SELECT COUNT(*) AS n FROM panel_users").get() as { n: number }).n;
|
||||
if (count > 0) return;
|
||||
let password = env.initialAdminPassword;
|
||||
let generated = false;
|
||||
if (!password) {
|
||||
password = generatePassword(14);
|
||||
generated = true;
|
||||
}
|
||||
db()
|
||||
.prepare("INSERT INTO panel_users(username, pass_hash, must_change, created_at) VALUES(?, ?, 1, ?)")
|
||||
.run("admin", hashPassword(password), Date.now());
|
||||
if (generated) {
|
||||
console.log("┌──────────────────────────────────────────────────────┐");
|
||||
console.log("│ CraftDeck first run — dashboard login │");
|
||||
console.log(`│ username: admin │`);
|
||||
console.log(`│ password: ${password.padEnd(40)}│`);
|
||||
console.log("│ You will be asked to change it on first login. │");
|
||||
console.log("└──────────────────────────────────────────────────────┘");
|
||||
} else {
|
||||
console.log("[craftdeck] admin user seeded from PANEL_ADMIN_PASSWORD (change it on first login)");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
|
||||
/**
|
||||
* In-process event bus for fanning out live data to SSE subscribers.
|
||||
* Events:
|
||||
* "log" (line: string) raw server console line
|
||||
* "mc-event" (e: McEvent) parsed game event
|
||||
* "metrics" (m: Record<string, unknown>) fresh sampler tick
|
||||
* "notification" (n: Record<string, unknown>) new panel notification
|
||||
*/
|
||||
export type McEvent = {
|
||||
ts: number;
|
||||
type: string;
|
||||
player?: string;
|
||||
detail?: string;
|
||||
};
|
||||
|
||||
const g = globalThis as unknown as { __craftdeckBus?: EventEmitter };
|
||||
|
||||
export function bus(): EventEmitter {
|
||||
if (!g.__craftdeckBus) {
|
||||
const e = new EventEmitter();
|
||||
e.setMaxListeners(100); // one listener per open SSE connection
|
||||
g.__craftdeckBus = e;
|
||||
}
|
||||
return g.__craftdeckBus;
|
||||
}
|
||||
|
||||
/** Ring buffer of recent console lines so new SSE clients get instant backfill. */
|
||||
const gl = globalThis as unknown as { __craftdeckLogRing?: string[] };
|
||||
|
||||
export function logRing(): string[] {
|
||||
if (!gl.__craftdeckLogRing) gl.__craftdeckLogRing = [];
|
||||
return gl.__craftdeckLogRing;
|
||||
}
|
||||
|
||||
export function pushLogLine(line: string) {
|
||||
const ring = logRing();
|
||||
ring.push(line);
|
||||
if (ring.length > 500) ring.splice(0, ring.length - 500);
|
||||
bus().emit("log", line);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Apply configuration changes by re-running docker compose for a service.
|
||||
* The dashboard image bundles the docker CLI + compose plugin and mounts the
|
||||
* project directory, so compose picks up .env edits and recreates only what
|
||||
* changed.
|
||||
*/
|
||||
|
||||
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
"docker",
|
||||
["compose", "--project-directory", env.projectDir, ...args],
|
||||
{ timeout: 10 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 },
|
||||
(err, stdout, stderr) => {
|
||||
resolve({ ok: !err, output: [stdout, stderr, err?.message].filter(Boolean).join("\n") });
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/** Recreate the Minecraft container so new .env values take effect. */
|
||||
export function applyMcConfig(): Promise<{ ok: boolean; output: string }> {
|
||||
return runCompose(["up", "-d", "--no-deps", env.mcContainer]);
|
||||
}
|
||||
|
||||
/** Recreate the backup sidecar (after backup schedule changes). */
|
||||
export function applyBackupConfig(): Promise<{ ok: boolean; output: string }> {
|
||||
return runCompose(["up", "-d", "--no-deps", "backup"]);
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Panel database (node:sqlite — no native deps). Single connection reused
|
||||
* across HMR reloads via globalThis.
|
||||
*/
|
||||
const g = globalThis as unknown as { __craftdeckDb?: DatabaseSync };
|
||||
|
||||
function open(): DatabaseSync {
|
||||
fs.mkdirSync(env.appDataDir, { recursive: true });
|
||||
const db = new DatabaseSync(path.join(env.appDataDir, "craftdeck.db"));
|
||||
db.exec("PRAGMA journal_mode = WAL");
|
||||
db.exec("PRAGMA busy_timeout = 5000");
|
||||
migrate(db);
|
||||
return db;
|
||||
}
|
||||
|
||||
function migrate(db: DatabaseSync) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS panel_users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
pass_hash TEXT NOT NULL,
|
||||
must_change INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS approved_players (
|
||||
id INTEGER PRIMARY KEY,
|
||||
username TEXT UNIQUE COLLATE NOCASE NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'pending', -- pending | registered | expired | revoked
|
||||
approved_at INTEGER,
|
||||
window_expires_at INTEGER,
|
||||
registered_at INTEGER,
|
||||
last_seen_at INTEGER,
|
||||
note TEXT
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS activity (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ts INTEGER NOT NULL,
|
||||
type TEXT NOT NULL, -- join | leave | register | approve | expire | revoke | server_online | server_offline | backup | chat_command | crash
|
||||
player TEXT,
|
||||
detail TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_activity_ts ON activity(ts DESC);
|
||||
CREATE TABLE IF NOT EXISTS metrics (
|
||||
ts INTEGER PRIMARY KEY,
|
||||
cpu REAL, mem_used INTEGER, mem_limit INTEGER,
|
||||
net_rx INTEGER, net_tx INTEGER,
|
||||
tps REAL, mspt REAL, players INTEGER,
|
||||
disk_used INTEGER, disk_free INTEGER
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS ping_samples (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ts INTEGER NOT NULL,
|
||||
player TEXT NOT NULL,
|
||||
ping INTEGER NOT NULL
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_ping_ts ON ping_samples(ts DESC);
|
||||
CREATE TABLE IF NOT EXISTS notifications (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ts INTEGER NOT NULL,
|
||||
level TEXT NOT NULL, -- info | success | warn | error
|
||||
title TEXT NOT NULL,
|
||||
body TEXT,
|
||||
read INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS schedules (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
cron TEXT NOT NULL,
|
||||
action TEXT NOT NULL, -- restart | backup | command | announce
|
||||
payload TEXT,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
last_run_at INTEGER,
|
||||
last_result TEXT,
|
||||
created_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
}
|
||||
|
||||
export function db(): DatabaseSync {
|
||||
if (!g.__craftdeckDb) g.__craftdeckDb = open();
|
||||
return g.__craftdeckDb;
|
||||
}
|
||||
|
||||
// ── settings helpers ────────────────────────────────────────────────────
|
||||
|
||||
export function getSetting(key: string, fallback: string): string {
|
||||
const row = db().prepare("SELECT value FROM settings WHERE key = ?").get(key) as
|
||||
| { value: string }
|
||||
| undefined;
|
||||
return row?.value ?? fallback;
|
||||
}
|
||||
|
||||
export function setSetting(key: string, value: string) {
|
||||
db()
|
||||
.prepare(
|
||||
"INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||
)
|
||||
.run(key, value);
|
||||
}
|
||||
|
||||
export function getBoolSetting(key: string, fallback: boolean): boolean {
|
||||
return getSetting(key, fallback ? "1" : "0") === "1";
|
||||
}
|
||||
|
||||
// ── activity helpers ────────────────────────────────────────────────────
|
||||
|
||||
export function logActivity(type: string, player?: string | null, detail?: string | null) {
|
||||
db()
|
||||
.prepare("INSERT INTO activity(ts, type, player, detail) VALUES(?, ?, ?, ?)")
|
||||
.run(Date.now(), type, player ?? null, detail ?? null);
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import http from "node:http";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Minimal Docker Engine API client over the unix socket (no dependencies).
|
||||
* Everything degrades to typed errors so the UI can render "docker
|
||||
* unavailable" / "container missing" states instead of crashing.
|
||||
*/
|
||||
|
||||
export class DockerError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public kind: "unavailable" | "not_found" | "api" = "api",
|
||||
public statusCode?: number
|
||||
) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
type ReqOptions = {
|
||||
method?: string;
|
||||
body?: unknown;
|
||||
timeoutMs?: number;
|
||||
};
|
||||
|
||||
function request(
|
||||
path: string,
|
||||
opts: ReqOptions = {}
|
||||
): Promise<{ status: number; body: Buffer }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const payload = opts.body !== undefined ? JSON.stringify(opts.body) : undefined;
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath: env.dockerSocket,
|
||||
path,
|
||||
method: opts.method ?? "GET",
|
||||
headers: payload
|
||||
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
|
||||
: {},
|
||||
},
|
||||
(res) => {
|
||||
const chunks: Buffer[] = [];
|
||||
res.on("data", (c) => chunks.push(c));
|
||||
res.on("end", () =>
|
||||
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks) })
|
||||
);
|
||||
}
|
||||
);
|
||||
req.setTimeout(opts.timeoutMs ?? 30_000, () => req.destroy(new Error("docker API timeout")));
|
||||
req.on("error", (err: NodeJS.ErrnoException) => {
|
||||
if (err.code === "ENOENT" || err.code === "ECONNREFUSED" || err.code === "EACCES") {
|
||||
reject(new DockerError(`Docker socket unavailable (${err.code})`, "unavailable"));
|
||||
} else {
|
||||
reject(new DockerError(err.message));
|
||||
}
|
||||
});
|
||||
if (payload) req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
async function json<T>(path: string, opts: ReqOptions = {}): Promise<T> {
|
||||
const { status, body } = await request(path, opts);
|
||||
if (status === 404) throw new DockerError(`Not found: ${path}`, "not_found", 404);
|
||||
if (status >= 400) {
|
||||
let msg = body.toString("utf8");
|
||||
try {
|
||||
msg = (JSON.parse(msg) as { message?: string }).message ?? msg;
|
||||
} catch {}
|
||||
throw new DockerError(msg, "api", status);
|
||||
}
|
||||
return body.length ? (JSON.parse(body.toString("utf8")) as T) : (undefined as T);
|
||||
}
|
||||
|
||||
// ── container state ─────────────────────────────────────────────────────
|
||||
|
||||
export type ContainerState = {
|
||||
exists: boolean;
|
||||
running: boolean;
|
||||
status: string; // running | exited | restarting | created | missing
|
||||
health?: string; // healthy | unhealthy | starting
|
||||
startedAt?: string;
|
||||
exitCode?: number;
|
||||
image?: string;
|
||||
};
|
||||
|
||||
type InspectResponse = {
|
||||
State: {
|
||||
Status: string;
|
||||
Running: boolean;
|
||||
StartedAt: string;
|
||||
ExitCode: number;
|
||||
Health?: { Status: string };
|
||||
};
|
||||
Config: { Image: string; Env: string[] };
|
||||
};
|
||||
|
||||
export async function inspectContainer(name: string): Promise<ContainerState> {
|
||||
try {
|
||||
const data = await json<InspectResponse>(`/containers/${name}/json`);
|
||||
return {
|
||||
exists: true,
|
||||
running: data.State.Running,
|
||||
status: data.State.Status,
|
||||
health: data.State.Health?.Status,
|
||||
startedAt: data.State.StartedAt,
|
||||
exitCode: data.State.ExitCode,
|
||||
image: data.Config.Image,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof DockerError && err.kind === "not_found") {
|
||||
return { exists: false, running: false, status: "missing" };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function containerEnv(name: string): Promise<Record<string, string>> {
|
||||
const data = await json<InspectResponse>(`/containers/${name}/json`);
|
||||
const out: Record<string, string> = {};
|
||||
for (const kv of data.Config.Env ?? []) {
|
||||
const i = kv.indexOf("=");
|
||||
if (i > 0) out[kv.slice(0, i)] = kv.slice(i + 1);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// ── power actions ───────────────────────────────────────────────────────
|
||||
|
||||
export type PowerAction = "start" | "stop" | "restart" | "kill";
|
||||
|
||||
export async function powerContainer(name: string, action: PowerAction): Promise<void> {
|
||||
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
|
||||
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
|
||||
method: "POST",
|
||||
// stop/restart of a busy MC server can take a while; wait past t=90
|
||||
timeoutMs: 150_000,
|
||||
});
|
||||
// 204 = done, 304 = already in desired state
|
||||
if (status !== 204 && status !== 304) {
|
||||
throw new DockerError(body.toString("utf8") || `${action} failed (${status})`, "api", status);
|
||||
}
|
||||
}
|
||||
|
||||
// ── stats ───────────────────────────────────────────────────────────────
|
||||
|
||||
export type ContainerStats = {
|
||||
cpuPercent: number;
|
||||
memUsed: number;
|
||||
memLimit: number;
|
||||
netRx: number; // cumulative bytes
|
||||
netTx: number;
|
||||
};
|
||||
|
||||
type StatsResponse = {
|
||||
cpu_stats: {
|
||||
cpu_usage: { total_usage: number };
|
||||
system_cpu_usage?: number;
|
||||
online_cpus?: number;
|
||||
};
|
||||
precpu_stats: {
|
||||
cpu_usage: { total_usage: number };
|
||||
system_cpu_usage?: number;
|
||||
};
|
||||
memory_stats: { usage?: number; stats?: { inactive_file?: number }; limit?: number };
|
||||
networks?: Record<string, { rx_bytes: number; tx_bytes: number }>;
|
||||
};
|
||||
|
||||
export async function containerStats(name: string): Promise<ContainerStats> {
|
||||
// one-shot=false + stream=false makes the daemon take two samples so the
|
||||
// precpu fields are populated and CPU% is computable from a single call
|
||||
const s = await json<StatsResponse>(`/containers/${name}/stats?stream=false`);
|
||||
const cpuDelta = s.cpu_stats.cpu_usage.total_usage - s.precpu_stats.cpu_usage.total_usage;
|
||||
const sysDelta = (s.cpu_stats.system_cpu_usage ?? 0) - (s.precpu_stats.system_cpu_usage ?? 0);
|
||||
const cpus = s.cpu_stats.online_cpus ?? 1;
|
||||
const cpuPercent = sysDelta > 0 && cpuDelta > 0 ? (cpuDelta / sysDelta) * cpus * 100 : 0;
|
||||
const rawUsage = s.memory_stats.usage ?? 0;
|
||||
const memUsed = rawUsage - (s.memory_stats.stats?.inactive_file ?? 0);
|
||||
let netRx = 0;
|
||||
let netTx = 0;
|
||||
for (const nic of Object.values(s.networks ?? {})) {
|
||||
netRx += nic.rx_bytes;
|
||||
netTx += nic.tx_bytes;
|
||||
}
|
||||
return { cpuPercent, memUsed, memLimit: s.memory_stats.limit ?? 0, netRx, netTx };
|
||||
}
|
||||
|
||||
// ── logs (multiplexed stream demux) ─────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Docker multiplexes stdout/stderr into frames: 8-byte header
|
||||
* [type, 0, 0, 0, len_be32] followed by len payload bytes.
|
||||
*/
|
||||
function createDemuxer(onLine: (line: string) => void) {
|
||||
let buf = Buffer.alloc(0);
|
||||
let textCarry = "";
|
||||
return (chunk: Buffer) => {
|
||||
buf = Buffer.concat([buf, chunk]);
|
||||
while (buf.length >= 8) {
|
||||
const first = buf[0];
|
||||
// Frames start with stream type 0/1/2; if not, the container was
|
||||
// started with a TTY and the stream is raw text.
|
||||
if (first > 2) {
|
||||
textCarry += buf.toString("utf8");
|
||||
buf = Buffer.alloc(0);
|
||||
break;
|
||||
}
|
||||
const len = buf.readUInt32BE(4);
|
||||
if (buf.length < 8 + len) break;
|
||||
textCarry += buf.subarray(8, 8 + len).toString("utf8");
|
||||
buf = buf.subarray(8 + len);
|
||||
}
|
||||
let nl;
|
||||
while ((nl = textCarry.indexOf("\n")) >= 0) {
|
||||
onLine(textCarry.slice(0, nl).replace(/\r$/, ""));
|
||||
textCarry = textCarry.slice(nl + 1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export type LogStreamHandle = { stop: () => void };
|
||||
|
||||
export function streamContainerLogs(
|
||||
name: string,
|
||||
opts: { tail: number; follow: boolean },
|
||||
onLine: (line: string) => void,
|
||||
onEnd?: (err?: Error) => void
|
||||
): LogStreamHandle {
|
||||
const req = http.request({
|
||||
socketPath: env.dockerSocket,
|
||||
path: `/containers/${name}/logs?stdout=1&stderr=1&tail=${opts.tail}&follow=${opts.follow ? 1 : 0}`,
|
||||
method: "GET",
|
||||
});
|
||||
let stopped = false;
|
||||
req.on("response", (res) => {
|
||||
if ((res.statusCode ?? 500) >= 400) {
|
||||
res.resume();
|
||||
onEnd?.(new DockerError(`logs failed (${res.statusCode})`, "api", res.statusCode));
|
||||
return;
|
||||
}
|
||||
const demux = createDemuxer(onLine);
|
||||
res.on("data", (c: Buffer) => demux(c));
|
||||
res.on("end", () => onEnd?.());
|
||||
res.on("error", (e) => onEnd?.(e as Error));
|
||||
});
|
||||
req.on("error", (e) => {
|
||||
if (!stopped) onEnd?.(e as Error);
|
||||
});
|
||||
req.end();
|
||||
return {
|
||||
stop: () => {
|
||||
stopped = true;
|
||||
req.destroy();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── exec ────────────────────────────────────────────────────────────────
|
||||
|
||||
export async function execInContainer(
|
||||
name: string,
|
||||
cmd: string[],
|
||||
timeoutMs = 15 * 60 * 1000
|
||||
): Promise<{ exitCode: number; output: string }> {
|
||||
const { Id } = await json<{ Id: string }>(`/containers/${name}/exec`, {
|
||||
method: "POST",
|
||||
body: { AttachStdout: true, AttachStderr: true, Cmd: cmd },
|
||||
});
|
||||
|
||||
const output = await new Promise<string>((resolve, reject) => {
|
||||
const req = http.request(
|
||||
{
|
||||
socketPath: env.dockerSocket,
|
||||
path: `/exec/${Id}/start`,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
},
|
||||
(res) => {
|
||||
let text = "";
|
||||
const demux = createDemuxer((line) => {
|
||||
text += line + "\n";
|
||||
});
|
||||
res.on("data", (c: Buffer) => demux(c));
|
||||
res.on("end", () => resolve(text));
|
||||
res.on("error", reject);
|
||||
}
|
||||
);
|
||||
req.setTimeout(timeoutMs, () => req.destroy(new Error("exec timeout")));
|
||||
req.on("error", reject);
|
||||
req.write(JSON.stringify({ Detach: false, Tty: false }));
|
||||
req.end();
|
||||
});
|
||||
|
||||
const info = await json<{ ExitCode: number | null }>(`/exec/${Id}/json`);
|
||||
return { exitCode: info.ExitCode ?? 0, output };
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import path from "node:path";
|
||||
|
||||
/**
|
||||
* Central environment access. In production these are set by docker-compose;
|
||||
* in local dev sensible fallbacks point at ./dev-data so the app boots
|
||||
* without the container stack (integrations degrade to "offline" states).
|
||||
*/
|
||||
const dev = process.env.NODE_ENV !== "production";
|
||||
|
||||
function str(key: string, fallback: string): string {
|
||||
const v = process.env[key];
|
||||
return v !== undefined && v !== "" ? v : fallback;
|
||||
}
|
||||
|
||||
export const env = {
|
||||
dev,
|
||||
sessionSecret: str("PANEL_SESSION_SECRET", dev ? "craftdeck-dev-secret-do-not-use" : ""),
|
||||
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
|
||||
|
||||
rconHost: str("RCON_HOST", "127.0.0.1"),
|
||||
rconPort: Number(str("RCON_PORT", "25575")),
|
||||
rconPassword: str("RCON_PASSWORD", "minecraft"),
|
||||
|
||||
dockerSocket: str("DOCKER_SOCKET", "/var/run/docker.sock"),
|
||||
mcContainer: str("MC_CONTAINER", "mc"),
|
||||
backupContainer: str("BACKUP_CONTAINER", "mc-backup"),
|
||||
|
||||
mcDataDir: path.resolve(str("MC_DATA_DIR", dev ? "./dev-data/mc" : "/mc-data")),
|
||||
backupsDir: path.resolve(str("BACKUPS_DIR", dev ? "./dev-data/backups" : "/backups")),
|
||||
appDataDir: path.resolve(str("APP_DATA_DIR", dev ? "./dev-data/app" : "/app-data")),
|
||||
projectDir: path.resolve(str("PROJECT_DIR", dev ? ".." : "/project")),
|
||||
|
||||
registrationWindowMinutes: Number(str("REGISTRATION_WINDOW_MINUTES", "2")),
|
||||
discordWebhookUrl: str("DISCORD_WEBHOOK_URL", ""),
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Read/write the project .env file while preserving comments and ordering.
|
||||
* This file is the single source of truth for server configuration
|
||||
* (MC version, memory, mod list, backup schedule, ...).
|
||||
*/
|
||||
|
||||
export function envFilePath(): string {
|
||||
return path.join(env.projectDir, ".env");
|
||||
}
|
||||
|
||||
export function readEnvFile(): Record<string, string> {
|
||||
const out: Record<string, string> = {};
|
||||
let text: string;
|
||||
try {
|
||||
text = fs.readFileSync(envFilePath(), "utf8");
|
||||
} catch {
|
||||
return out;
|
||||
}
|
||||
for (const line of text.split("\n")) {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
|
||||
if (m) out[m[1]] = m[2].trim();
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function writeEnvFile(updates: Record<string, string>): void {
|
||||
const file = envFilePath();
|
||||
let lines: string[];
|
||||
try {
|
||||
lines = fs.readFileSync(file, "utf8").split("\n");
|
||||
} catch {
|
||||
lines = [];
|
||||
}
|
||||
const remaining = new Map(Object.entries(updates));
|
||||
const next = lines.map((line) => {
|
||||
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
||||
if (m && remaining.has(m[1])) {
|
||||
const value = remaining.get(m[1])!;
|
||||
remaining.delete(m[1]);
|
||||
return `${m[1]}=${value}`;
|
||||
}
|
||||
return line;
|
||||
});
|
||||
for (const [key, value] of remaining) next.push(`${key}=${value}`);
|
||||
fs.writeFileSync(file, next.join("\n"));
|
||||
}
|
||||
|
||||
/** Keys the config UI is allowed to edit (guards against arbitrary writes). */
|
||||
export const EDITABLE_ENV_KEYS = [
|
||||
"MC_VERSION",
|
||||
"SERVER_TYPE",
|
||||
"MC_MEMORY",
|
||||
"MC_CONTAINER_MEM_LIMIT",
|
||||
"MC_PORT",
|
||||
"MC_MOTD",
|
||||
"MC_DIFFICULTY",
|
||||
"MC_MAX_PLAYERS",
|
||||
"MC_VIEW_DISTANCE",
|
||||
"MC_SIMULATION_DISTANCE",
|
||||
"MC_SEED",
|
||||
"MODRINTH_PROJECTS",
|
||||
"MODRINTH_MODPACK",
|
||||
"BACKUP_INTERVAL",
|
||||
"BACKUP_RETENTION_DAYS",
|
||||
"REGISTRATION_WINDOW_MINUTES",
|
||||
"DISCORD_WEBHOOK_URL",
|
||||
"TZ",
|
||||
] as const;
|
||||
@@ -0,0 +1,124 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Sandboxed file access for the file browser / editors. Every path is
|
||||
* resolved against the server data root and verified to stay inside it
|
||||
* (including through symlinks).
|
||||
*/
|
||||
|
||||
const MAX_EDITABLE_BYTES = 2 * 1024 * 1024;
|
||||
|
||||
// The Minecraft server runs as uid/gid 1000 (itzg image default); files the
|
||||
// panel creates must stay writable by it.
|
||||
const MC_UID = 1000;
|
||||
const MC_GID = 1000;
|
||||
|
||||
export function resolveSafe(rel: string): string {
|
||||
const root = env.mcDataDir;
|
||||
const cleaned = path.normalize(rel).replace(/^([/\\])+/, "");
|
||||
const abs = path.resolve(root, cleaned);
|
||||
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
||||
throw new Error("Path escapes the server data directory");
|
||||
}
|
||||
// Make sure symlinks don't lead outside the root. Check the path itself
|
||||
// when it exists, otherwise its parent (for files about to be created).
|
||||
const target = fs.existsSync(abs) ? abs : path.dirname(abs);
|
||||
if (fs.existsSync(target)) {
|
||||
const real = fs.realpathSync(target);
|
||||
const realRoot = fs.realpathSync(root);
|
||||
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
|
||||
throw new Error("Path escapes the server data directory");
|
||||
}
|
||||
}
|
||||
return abs;
|
||||
}
|
||||
|
||||
export type DirEntry = {
|
||||
name: string;
|
||||
type: "file" | "dir";
|
||||
size: number;
|
||||
mtime: number;
|
||||
};
|
||||
|
||||
export function listDir(rel: string): DirEntry[] {
|
||||
const abs = resolveSafe(rel);
|
||||
return fs
|
||||
.readdirSync(abs, { withFileTypes: true })
|
||||
.map((e) => {
|
||||
let size = 0;
|
||||
let mtime = 0;
|
||||
try {
|
||||
const st = fs.statSync(path.join(abs, e.name));
|
||||
size = st.size;
|
||||
mtime = st.mtimeMs;
|
||||
} catch {}
|
||||
return {
|
||||
name: e.name,
|
||||
type: e.isDirectory() ? ("dir" as const) : ("file" as const),
|
||||
size,
|
||||
mtime,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
|
||||
}
|
||||
|
||||
export function readTextFile(rel: string): { content: string; truncated: boolean } {
|
||||
const abs = resolveSafe(rel);
|
||||
const st = fs.statSync(abs);
|
||||
if (st.size > MAX_EDITABLE_BYTES) {
|
||||
const fd = fs.openSync(abs, "r");
|
||||
const buf = Buffer.alloc(MAX_EDITABLE_BYTES);
|
||||
fs.readSync(fd, buf, 0, MAX_EDITABLE_BYTES, 0);
|
||||
fs.closeSync(fd);
|
||||
return { content: buf.toString("utf8"), truncated: true };
|
||||
}
|
||||
const buf = fs.readFileSync(abs);
|
||||
if (buf.subarray(0, 8000).includes(0)) throw new Error("Binary file — download it instead");
|
||||
return { content: buf.toString("utf8"), truncated: false };
|
||||
}
|
||||
|
||||
export function writeTextFile(rel: string, content: string): void {
|
||||
const abs = resolveSafe(rel);
|
||||
fs.writeFileSync(abs, content);
|
||||
fixOwnership(abs);
|
||||
}
|
||||
|
||||
export function writeBinaryFile(rel: string, data: Buffer): void {
|
||||
const abs = resolveSafe(rel);
|
||||
fs.writeFileSync(abs, data);
|
||||
fixOwnership(abs);
|
||||
}
|
||||
|
||||
export function deletePath(rel: string): void {
|
||||
const abs = resolveSafe(rel);
|
||||
if (abs === env.mcDataDir) throw new Error("Refusing to delete the data root");
|
||||
const st = fs.statSync(abs);
|
||||
if (st.isDirectory()) {
|
||||
fs.rmdirSync(abs); // only empty dirs — bulk deletion is deliberately unsupported
|
||||
} else {
|
||||
fs.unlinkSync(abs);
|
||||
}
|
||||
}
|
||||
|
||||
export function makeDir(rel: string): void {
|
||||
const abs = resolveSafe(rel);
|
||||
fs.mkdirSync(abs, { recursive: true });
|
||||
fixOwnership(abs);
|
||||
}
|
||||
|
||||
export function fileStream(rel: string): { stream: fs.ReadStream; size: number; name: string } {
|
||||
const abs = resolveSafe(rel);
|
||||
const st = fs.statSync(abs);
|
||||
if (!st.isFile()) throw new Error("Not a file");
|
||||
return { stream: fs.createReadStream(abs), size: st.size, name: path.basename(abs) };
|
||||
}
|
||||
|
||||
function fixOwnership(abs: string) {
|
||||
try {
|
||||
fs.chownSync(abs, MC_UID, MC_GID);
|
||||
} catch {
|
||||
// Not root (local dev) — fine.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import { streamContainerLogs, type LogStreamHandle } from "./docker";
|
||||
import { bus, pushLogLine } from "./bus";
|
||||
import { db, logActivity, getBoolSetting } from "./db";
|
||||
import { notify } from "./notify";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Tails the Minecraft container's console, feeds the live console stream,
|
||||
* and parses game events (joins, leaves, server ready, ...) into the
|
||||
* activity feed + notifications.
|
||||
*/
|
||||
|
||||
type State = {
|
||||
handle: LogStreamHandle | null;
|
||||
attachedAt: number;
|
||||
timer: NodeJS.Timeout | null;
|
||||
};
|
||||
|
||||
const g = globalThis as unknown as { __craftdeckLogwatch?: State };
|
||||
|
||||
// Strip "[12:34:56] [Server thread/INFO]: " style prefixes.
|
||||
function messageOf(line: string): string {
|
||||
const m = line.match(/^\[[^\]]*\]\s*\[[^\]]*\]:?\s*(.*)$/);
|
||||
return m ? m[1] : line;
|
||||
}
|
||||
|
||||
function parseEvent(line: string, backfill: boolean) {
|
||||
const msg = messageOf(line).replace(/§[0-9a-fk-or]/gi, "");
|
||||
const emit = (type: string, player?: string, detail?: string) => {
|
||||
if (backfill) return; // don't re-record history after a panel restart
|
||||
logActivity(type, player, detail);
|
||||
if (player) touchLastSeen(player, type);
|
||||
bus().emit("mc-event", { ts: Date.now(), type, player, detail });
|
||||
if (type === "join" && getBoolSetting("notify_joins", true)) {
|
||||
notify("info", `${player} joined the server`, "", { discord: getBoolSetting("discord_joins", true) });
|
||||
}
|
||||
if (type === "leave" && getBoolSetting("notify_joins", true)) {
|
||||
notify("info", `${player} left the server`, "", { discord: getBoolSetting("discord_joins", true) });
|
||||
}
|
||||
if (type === "server_online") {
|
||||
notify("success", "Server is online", detail ?? "");
|
||||
}
|
||||
};
|
||||
|
||||
let m;
|
||||
if ((m = msg.match(/^([A-Za-z0-9_]{3,16}) joined the game/))) {
|
||||
emit("join", m[1]);
|
||||
} else if ((m = msg.match(/^([A-Za-z0-9_]{3,16}) left the game/))) {
|
||||
emit("leave", m[1]);
|
||||
} else if ((m = msg.match(/^([A-Za-z0-9_]{3,16})\[\/([0-9a-fA-F.:]+):\d+\] logged in/))) {
|
||||
emit("connect", m[1], m[2]);
|
||||
} else if ((m = msg.match(/^Done \(([\d.]+)s\)/))) {
|
||||
emit("server_online", undefined, `Started in ${m[1]}s`);
|
||||
} else if (/^Stopping the server|^Stopping server/.test(msg)) {
|
||||
emit("server_stopping");
|
||||
} else if (/registered/i.test(msg) && !backfill) {
|
||||
// EasyAuth registration hint — the auth watcher reconciles immediately.
|
||||
bus().emit("mc-event", { ts: Date.now(), type: "register-hint" });
|
||||
}
|
||||
}
|
||||
|
||||
function touchLastSeen(player: string, type: string) {
|
||||
if (type !== "join" && type !== "leave" && type !== "connect") return;
|
||||
db()
|
||||
.prepare("UPDATE approved_players SET last_seen_at = ? WHERE username = ?")
|
||||
.run(Date.now(), player);
|
||||
}
|
||||
|
||||
function attach(state: State) {
|
||||
state.attachedAt = Date.now();
|
||||
state.handle = streamContainerLogs(
|
||||
env.mcContainer,
|
||||
{ tail: 200, follow: true },
|
||||
(line) => {
|
||||
pushLogLine(line);
|
||||
// Lines arriving in the first 2s are the tail backfill, not live events.
|
||||
parseEvent(line, Date.now() - state.attachedAt < 2000);
|
||||
},
|
||||
() => {
|
||||
state.handle = null; // container stopped or docker went away; retry loop reattaches
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
export function startLogWatcher() {
|
||||
if (g.__craftdeckLogwatch) return;
|
||||
const state: State = { handle: null, attachedAt: 0, timer: null };
|
||||
g.__craftdeckLogwatch = state;
|
||||
attach(state);
|
||||
state.timer = setInterval(() => {
|
||||
if (!state.handle) attach(state);
|
||||
}, 5000);
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { rcon, stripColors, RconError } from "./rcon";
|
||||
|
||||
/**
|
||||
* Game-level operations on top of RCON: player list, whitelist, EasyAuth
|
||||
* account management, and spark performance queries. All parsers are
|
||||
* intentionally tolerant of minor message-format changes between versions.
|
||||
*/
|
||||
|
||||
const NAME_RE = /^[A-Za-z0-9_]{3,16}$/;
|
||||
|
||||
export function isValidUsername(name: string): boolean {
|
||||
return NAME_RE.test(name);
|
||||
}
|
||||
|
||||
function assertValidUsername(name: string) {
|
||||
if (!isValidUsername(name)) throw new Error(`Invalid Minecraft username: ${name}`);
|
||||
}
|
||||
|
||||
// ── players ─────────────────────────────────────────────────────────────
|
||||
|
||||
export async function listOnlinePlayers(): Promise<{ online: number; max: number; names: string[] }> {
|
||||
const out = stripColors(await rcon().exec("list"));
|
||||
// "There are 2 of a max of 10 players online: Alice, Bob"
|
||||
const m = out.match(/There are (\d+) of a max of (\d+) players online:?\s*(.*)/i);
|
||||
if (!m) return { online: 0, max: 0, names: [] };
|
||||
const names = m[3]
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => NAME_RE.test(s));
|
||||
return { online: Number(m[1]), max: Number(m[2]), names };
|
||||
}
|
||||
|
||||
export async function kickPlayer(name: string, reason: string): Promise<string> {
|
||||
assertValidUsername(name);
|
||||
return stripColors(await rcon().exec(`kick ${name} ${reason}`));
|
||||
}
|
||||
|
||||
export async function say(message: string): Promise<void> {
|
||||
await rcon().exec(`say ${message.replace(/\n/g, " ")}`);
|
||||
}
|
||||
|
||||
// ── whitelist ───────────────────────────────────────────────────────────
|
||||
|
||||
export async function whitelistAdd(name: string): Promise<string> {
|
||||
assertValidUsername(name);
|
||||
return stripColors(await rcon().exec(`whitelist add ${name}`));
|
||||
}
|
||||
|
||||
export async function whitelistRemove(name: string): Promise<string> {
|
||||
assertValidUsername(name);
|
||||
return stripColors(await rcon().exec(`whitelist remove ${name}`));
|
||||
}
|
||||
|
||||
export async function whitelistList(): Promise<string[]> {
|
||||
const out = stripColors(await rcon().exec("whitelist list"));
|
||||
// "There are 3 whitelisted player(s): Alice, Bob, Carol"
|
||||
const colon = out.indexOf(":");
|
||||
if (colon < 0) return [];
|
||||
return out
|
||||
.slice(colon + 1)
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => NAME_RE.test(s));
|
||||
}
|
||||
|
||||
// ── EasyAuth ────────────────────────────────────────────────────────────
|
||||
|
||||
/** Registered account names according to EasyAuth. */
|
||||
export async function easyauthList(): Promise<string[]> {
|
||||
const out = stripColors(await rcon().exec("auth list"));
|
||||
// Output is a list of registered usernames; extract all name-like tokens
|
||||
// after a colon if present, otherwise from the whole string.
|
||||
const colon = out.indexOf(":");
|
||||
const source = colon >= 0 ? out.slice(colon + 1) : out;
|
||||
const names = source.match(/[A-Za-z0-9_]{3,16}/g) ?? [];
|
||||
// Filter obvious non-name words that may appear in the message.
|
||||
const noise = new Set(["registered", "players", "list", "accounts", "there", "are", "the"]);
|
||||
return [...new Set(names.filter((n) => !noise.has(n.toLowerCase())))];
|
||||
}
|
||||
|
||||
export async function easyauthRemove(name: string): Promise<string> {
|
||||
assertValidUsername(name);
|
||||
return stripColors(await rcon().exec(`auth remove ${name}`));
|
||||
}
|
||||
|
||||
export async function easyauthSetPassword(name: string, password: string): Promise<string> {
|
||||
assertValidUsername(name);
|
||||
if (!/^[\x21-\x7e]{1,64}$/.test(password)) throw new Error("Unsupported password characters");
|
||||
return stripColors(await rcon().exec(`auth update ${name} ${password}`));
|
||||
}
|
||||
|
||||
// ── spark (performance) ─────────────────────────────────────────────────
|
||||
|
||||
export type TpsSample = { tps: number | null; mspt: number | null };
|
||||
|
||||
export async function sparkTps(): Promise<TpsSample> {
|
||||
try {
|
||||
const out = stripColors(await rcon().exec("spark tps"));
|
||||
// "TPS from last 5s, 10s, 1m, 5m, 15m: 20.0, 20.0, 20.0, 20.0, 20.0"
|
||||
// "Tick durations (min/med/95%ile/max ms) from last 10s, 1m: 0.8/1.2/2.4/8.0; ..."
|
||||
const tpsMatch = out.match(/TPS[^:]*:\s*\*?([\d.]+)/i);
|
||||
const msptMatch = out.match(/durations[^:]*:\s*[\d.]+\/([\d.]+)/i);
|
||||
return {
|
||||
tps: tpsMatch ? Math.min(Number(tpsMatch[1]), 20) : null,
|
||||
mspt: msptMatch ? Number(msptMatch[1]) : null,
|
||||
};
|
||||
} catch (err) {
|
||||
if (err instanceof RconError) return { tps: null, mspt: null };
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/** Per-player ping in ms, via spark. */
|
||||
export async function sparkPings(): Promise<Record<string, number>> {
|
||||
try {
|
||||
const out = stripColors(await rcon().exec("spark ping"));
|
||||
// Lines like "Alice: 23ms" / "Average Ping: 25.3ms"
|
||||
const result: Record<string, number> = {};
|
||||
for (const m of out.matchAll(/([A-Za-z0-9_]{3,16})\s*[:-]\s*([\d.]+)\s*ms/g)) {
|
||||
if (m[1].toLowerCase() === "ping") continue;
|
||||
result[m[1]] = Math.round(Number(m[2]));
|
||||
}
|
||||
return result;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export async function sparkHealthReport(): Promise<string> {
|
||||
return stripColors(await rcon().exec("spark healthreport"));
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { execFile } from "node:child_process";
|
||||
import { readEnvFile, writeEnvFile } from "./envfile";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Mod management: the MODRINTH_PROJECTS list in .env is the source of truth
|
||||
* for "managed" mods (the itzg image auto-resolves compatible versions on
|
||||
* every server start). Manually uploaded jars in mods/ are also surfaced.
|
||||
*/
|
||||
|
||||
const MODRINTH_API = "https://api.modrinth.com/v2";
|
||||
const UA = "craftdeck-panel/1.0 (self-hosted server dashboard)";
|
||||
|
||||
export type ModSearchResult = {
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string;
|
||||
downloads: number;
|
||||
icon_url: string | null;
|
||||
server_side: string;
|
||||
client_side: string;
|
||||
};
|
||||
|
||||
export function currentMcVersion(): string {
|
||||
return readEnvFile()["MC_VERSION"] || "26.2";
|
||||
}
|
||||
|
||||
export async function searchMods(query: string): Promise<ModSearchResult[]> {
|
||||
const facets = JSON.stringify([
|
||||
["project_type:mod"],
|
||||
["categories:fabric"],
|
||||
[`versions:${currentMcVersion()}`],
|
||||
]);
|
||||
const url = `${MODRINTH_API}/search?query=${encodeURIComponent(query)}&facets=${encodeURIComponent(facets)}&limit=20`;
|
||||
const res = await fetch(url, { headers: { "User-Agent": UA }, signal: AbortSignal.timeout(10_000) });
|
||||
if (!res.ok) throw new Error(`Modrinth search failed (${res.status})`);
|
||||
const data = (await res.json()) as { hits: ModSearchResult[] };
|
||||
return data.hits;
|
||||
}
|
||||
|
||||
export function managedSlugs(): string[] {
|
||||
return (readEnvFile()["MODRINTH_PROJECTS"] ?? "")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
const PROTECTED_SLUGS = new Set(["fabric-api", "easyauth"]);
|
||||
|
||||
export function addManagedMod(slug: string): string[] {
|
||||
if (!/^[a-z0-9][a-z0-9\-_]{0,63}$/i.test(slug)) throw new Error("Invalid Modrinth slug");
|
||||
const slugs = managedSlugs();
|
||||
if (slugs.some((s) => s.toLowerCase() === slug.toLowerCase())) return slugs;
|
||||
const next = [...slugs, slug.toLowerCase()];
|
||||
writeEnvFile({ MODRINTH_PROJECTS: next.join(",") });
|
||||
return next;
|
||||
}
|
||||
|
||||
export function removeManagedMod(slug: string): string[] {
|
||||
if (PROTECTED_SLUGS.has(slug.toLowerCase())) {
|
||||
throw new Error(`${slug} is required by the panel (auth/loader) and can't be removed here`);
|
||||
}
|
||||
const next = managedSlugs().filter((s) => s.toLowerCase() !== slug.toLowerCase());
|
||||
writeEnvFile({ MODRINTH_PROJECTS: next.join(",") });
|
||||
return next;
|
||||
}
|
||||
|
||||
// ── installed jar files ─────────────────────────────────────────────────
|
||||
|
||||
export type InstalledMod = {
|
||||
file: string;
|
||||
name: string;
|
||||
id: string | null;
|
||||
version: string | null;
|
||||
disabled: boolean;
|
||||
size: number;
|
||||
mtime: number;
|
||||
};
|
||||
|
||||
function modsDir(): string {
|
||||
return path.join(env.mcDataDir, "mods");
|
||||
}
|
||||
|
||||
function readFabricModJson(jarPath: string): Promise<{ id?: string; name?: string; version?: string } | null> {
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
"unzip",
|
||||
["-p", jarPath, "fabric.mod.json"],
|
||||
{ timeout: 10_000, maxBuffer: 2 * 1024 * 1024 },
|
||||
(err, stdout) => {
|
||||
if (err) return resolve(null);
|
||||
try {
|
||||
resolve(JSON.parse(stdout.replace(/^/, "")));
|
||||
} catch {
|
||||
resolve(null);
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export async function listInstalledMods(): Promise<InstalledMod[]> {
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(modsDir());
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const jars = entries.filter((n) => n.endsWith(".jar") || n.endsWith(".jar.disabled"));
|
||||
const out: InstalledMod[] = [];
|
||||
for (const file of jars) {
|
||||
const full = path.join(modsDir(), file);
|
||||
const st = fs.statSync(full);
|
||||
const meta = await readFabricModJson(full);
|
||||
out.push({
|
||||
file,
|
||||
name: meta?.name ?? file.replace(/\.jar(\.disabled)?$/, ""),
|
||||
id: meta?.id ?? null,
|
||||
version: meta?.version ?? null,
|
||||
disabled: file.endsWith(".disabled"),
|
||||
size: st.size,
|
||||
mtime: st.mtimeMs,
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => a.name.localeCompare(b.name));
|
||||
}
|
||||
|
||||
function safeModFile(file: string): string {
|
||||
if (file !== path.basename(file) || !/\.jar(\.disabled)?$/.test(file)) {
|
||||
throw new Error("Invalid mod file name");
|
||||
}
|
||||
return path.join(modsDir(), file);
|
||||
}
|
||||
|
||||
export function toggleMod(file: string): void {
|
||||
const full = safeModFile(file);
|
||||
const target = full.endsWith(".disabled") ? full.slice(0, -".disabled".length) : `${full}.disabled`;
|
||||
fs.renameSync(full, target);
|
||||
}
|
||||
|
||||
export function deleteModFile(file: string): void {
|
||||
fs.unlinkSync(safeModFile(file));
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { db, getBoolSetting, getSetting } from "./db";
|
||||
import { bus } from "./bus";
|
||||
import { env } from "./env";
|
||||
|
||||
export type NotificationLevel = "info" | "success" | "warn" | "error";
|
||||
|
||||
/**
|
||||
* Panel notifications: stored for the notification center, fanned out live
|
||||
* over SSE, and optionally mirrored to a Discord webhook.
|
||||
*/
|
||||
export function notify(
|
||||
level: NotificationLevel,
|
||||
title: string,
|
||||
body = "",
|
||||
opts: { discord?: boolean } = {}
|
||||
) {
|
||||
const ts = Date.now();
|
||||
const res = db()
|
||||
.prepare("INSERT INTO notifications(ts, level, title, body) VALUES(?, ?, ?, ?)")
|
||||
.run(ts, level, title, body);
|
||||
bus().emit("notification", { id: Number(res.lastInsertRowid), ts, level, title, body, read: 0 });
|
||||
|
||||
if (opts.discord !== false) void sendDiscord(level, title, body);
|
||||
}
|
||||
|
||||
const LEVEL_COLORS: Record<NotificationLevel, number> = {
|
||||
info: 0x60a5fa,
|
||||
success: 0x4ade80,
|
||||
warn: 0xfbbf24,
|
||||
error: 0xf87171,
|
||||
};
|
||||
|
||||
async function sendDiscord(level: NotificationLevel, title: string, body: string) {
|
||||
if (!getBoolSetting("discord_enabled", true)) return;
|
||||
const url = getSetting("discord_webhook_url", env.discordWebhookUrl);
|
||||
if (!url) return;
|
||||
try {
|
||||
await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
username: "CraftDeck",
|
||||
embeds: [
|
||||
{
|
||||
title,
|
||||
description: body || undefined,
|
||||
color: LEVEL_COLORS[level],
|
||||
timestamp: new Date().toISOString(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch {
|
||||
// Discord being down must never break the panel.
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||
|
||||
/** scrypt password hashing — built into Node, no native deps. */
|
||||
|
||||
export function hashPassword(password: string): string {
|
||||
const salt = randomBytes(16);
|
||||
const hash = scryptSync(password, salt, 64);
|
||||
return `scrypt:${salt.toString("hex")}:${hash.toString("hex")}`;
|
||||
}
|
||||
|
||||
export function verifyPassword(password: string, stored: string): boolean {
|
||||
const [scheme, saltHex, hashHex] = stored.split(":");
|
||||
if (scheme !== "scrypt" || !saltHex || !hashHex) return false;
|
||||
const expected = Buffer.from(hashHex, "hex");
|
||||
const actual = scryptSync(password, Buffer.from(saltHex, "hex"), expected.length);
|
||||
return timingSafeEqual(actual, expected);
|
||||
}
|
||||
|
||||
export function generatePassword(length = 12): string {
|
||||
const alphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
||||
const bytes = randomBytes(length);
|
||||
let out = "";
|
||||
for (let i = 0; i < length; i++) out += alphabet[bytes[i] % alphabet.length];
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Player statistics from the world's stats/*.json files (written by the
|
||||
* server) joined with usercache.json for name↔UUID mapping. Works with
|
||||
* offline-mode UUIDs.
|
||||
*/
|
||||
|
||||
type UserCacheEntry = { name: string; uuid: string };
|
||||
|
||||
export type PlayerStats = {
|
||||
name: string;
|
||||
uuid: string;
|
||||
playTimeHours: number;
|
||||
deaths: number;
|
||||
mobKills: number;
|
||||
playerKills: number;
|
||||
distanceKm: number;
|
||||
jumps: number;
|
||||
damageDealt: number;
|
||||
blocksMined: number;
|
||||
lastModified: number;
|
||||
};
|
||||
|
||||
function worldDir(): string {
|
||||
return path.join(env.mcDataDir, "world");
|
||||
}
|
||||
|
||||
export function readUserCache(): UserCacheEntry[] {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(path.join(env.mcDataDir, "usercache.json"), "utf8"));
|
||||
if (Array.isArray(raw)) return raw as UserCacheEntry[];
|
||||
} catch {}
|
||||
return [];
|
||||
}
|
||||
|
||||
function num(obj: Record<string, number> | undefined, key: string): number {
|
||||
return obj?.[key] ?? 0;
|
||||
}
|
||||
|
||||
export function readAllPlayerStats(): PlayerStats[] {
|
||||
const statsDir = path.join(worldDir(), "stats");
|
||||
let files: string[];
|
||||
try {
|
||||
files = fs.readdirSync(statsDir).filter((f) => f.endsWith(".json"));
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const nameByUuid = new Map(readUserCache().map((e) => [e.uuid.toLowerCase(), e.name]));
|
||||
const out: PlayerStats[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const uuid = file.replace(/\.json$/, "");
|
||||
let data: { stats?: Record<string, Record<string, number>> };
|
||||
let mtime = 0;
|
||||
try {
|
||||
const full = path.join(statsDir, file);
|
||||
mtime = fs.statSync(full).mtimeMs;
|
||||
data = JSON.parse(fs.readFileSync(full, "utf8"));
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const s = data.stats ?? {};
|
||||
const custom = s["minecraft:custom"];
|
||||
// distance stats are in centimeters
|
||||
const distanceCm =
|
||||
num(custom, "minecraft:walk_one_cm") +
|
||||
num(custom, "minecraft:sprint_one_cm") +
|
||||
num(custom, "minecraft:swim_one_cm") +
|
||||
num(custom, "minecraft:fly_one_cm") +
|
||||
num(custom, "minecraft:boat_one_cm") +
|
||||
num(custom, "minecraft:horse_one_cm") +
|
||||
num(custom, "minecraft:minecart_one_cm");
|
||||
const blocksMined = Object.values(s["minecraft:mined"] ?? {}).reduce((a, b) => a + b, 0);
|
||||
|
||||
out.push({
|
||||
name: nameByUuid.get(uuid.toLowerCase()) ?? uuid.slice(0, 8),
|
||||
uuid,
|
||||
playTimeHours: Math.round((num(custom, "minecraft:play_time") / 20 / 3600) * 10) / 10,
|
||||
deaths: num(custom, "minecraft:deaths"),
|
||||
mobKills: num(custom, "minecraft:mob_kills"),
|
||||
playerKills: num(custom, "minecraft:player_kills"),
|
||||
distanceKm: Math.round(distanceCm / 100 / 1000),
|
||||
jumps: num(custom, "minecraft:jump"),
|
||||
damageDealt: Math.round(num(custom, "minecraft:damage_dealt") / 10),
|
||||
blocksMined,
|
||||
lastModified: mtime,
|
||||
});
|
||||
}
|
||||
return out.sort((a, b) => b.playTimeHours - a.playTimeHours);
|
||||
}
|
||||
|
||||
export type WorldInfo = {
|
||||
seed: string | null;
|
||||
sizeBytes: number | null;
|
||||
dimensions: string[];
|
||||
levelName: string | null;
|
||||
};
|
||||
|
||||
export function readWorldInfo(worldSizeBytes: number | null): WorldInfo {
|
||||
const dims: string[] = [];
|
||||
const wd = worldDir();
|
||||
if (fs.existsSync(wd)) dims.push("overworld");
|
||||
if (fs.existsSync(path.join(wd, "DIM-1"))) dims.push("nether");
|
||||
if (fs.existsSync(path.join(wd, "DIM1"))) dims.push("end");
|
||||
|
||||
let seed: string | null = null;
|
||||
let levelName: string | null = null;
|
||||
try {
|
||||
const props = fs.readFileSync(path.join(env.mcDataDir, "server.properties"), "utf8");
|
||||
seed = props.match(/^level-seed=(.*)$/m)?.[1] || null;
|
||||
levelName = props.match(/^level-name=(.*)$/m)?.[1] || null;
|
||||
} catch {}
|
||||
return { seed, sizeBytes: worldSizeBytes, dimensions: dims, levelName };
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
import net from "node:net";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Minimal Source-RCON client (the protocol Minecraft uses). One persistent
|
||||
* connection, commands serialized through a queue, automatic reconnect.
|
||||
* Long responses arrive as multiple packets with the same request id; we
|
||||
* collect until a short quiet period.
|
||||
*/
|
||||
|
||||
const TYPE_AUTH = 3;
|
||||
const TYPE_COMMAND = 2;
|
||||
|
||||
export class RconError extends Error {
|
||||
constructor(message: string, public kind: "offline" | "auth" | "timeout" = "offline") {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
|
||||
function encodePacket(id: number, type: number, body: string): Buffer {
|
||||
const bodyBuf = Buffer.from(body, "utf8");
|
||||
const buf = Buffer.alloc(14 + bodyBuf.length);
|
||||
buf.writeInt32LE(10 + bodyBuf.length, 0);
|
||||
buf.writeInt32LE(id, 4);
|
||||
buf.writeInt32LE(type, 8);
|
||||
bodyBuf.copy(buf, 12);
|
||||
return buf;
|
||||
}
|
||||
|
||||
type Pending = {
|
||||
id: number;
|
||||
chunks: string[];
|
||||
resolve: (out: string) => void;
|
||||
reject: (err: Error) => void;
|
||||
quietTimer?: NodeJS.Timeout;
|
||||
hardTimer: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
class RconClient {
|
||||
private socket: net.Socket | null = null;
|
||||
private buffer = Buffer.alloc(0);
|
||||
private nextId = 1;
|
||||
private authed = false;
|
||||
private authWaiter: { resolve: () => void; reject: (e: Error) => void } | null = null;
|
||||
private pending: Pending | null = null;
|
||||
private queue: Array<() => void> = [];
|
||||
|
||||
private connect(): Promise<void> {
|
||||
if (this.authed && this.socket && !this.socket.destroyed) return Promise.resolve();
|
||||
return new Promise((resolve, reject) => {
|
||||
this.teardown();
|
||||
const socket = net.createConnection({ host: env.rconHost, port: env.rconPort });
|
||||
this.socket = socket;
|
||||
const connectTimeout = setTimeout(() => {
|
||||
socket.destroy();
|
||||
reject(new RconError("RCON connect timeout"));
|
||||
}, 5000);
|
||||
socket.on("connect", () => {
|
||||
clearTimeout(connectTimeout);
|
||||
this.authWaiter = { resolve, reject };
|
||||
socket.write(encodePacket(0, TYPE_AUTH, env.rconPassword));
|
||||
});
|
||||
socket.on("data", (chunk: Buffer) => this.onData(chunk));
|
||||
socket.on("error", (err) => {
|
||||
clearTimeout(connectTimeout);
|
||||
const e = new RconError(`RCON unavailable: ${(err as Error).message}`);
|
||||
this.failAll(e);
|
||||
reject(e);
|
||||
});
|
||||
socket.on("close", () => {
|
||||
this.authed = false;
|
||||
this.failAll(new RconError("RCON connection closed"));
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private teardown() {
|
||||
this.socket?.destroy();
|
||||
this.socket = null;
|
||||
this.authed = false;
|
||||
this.buffer = Buffer.alloc(0);
|
||||
}
|
||||
|
||||
private failAll(err: Error) {
|
||||
this.authWaiter?.reject(err);
|
||||
this.authWaiter = null;
|
||||
if (this.pending) {
|
||||
clearTimeout(this.pending.hardTimer);
|
||||
if (this.pending.quietTimer) clearTimeout(this.pending.quietTimer);
|
||||
this.pending.reject(err);
|
||||
this.pending = null;
|
||||
}
|
||||
}
|
||||
|
||||
private onData(chunk: Buffer) {
|
||||
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||
while (this.buffer.length >= 4) {
|
||||
const len = this.buffer.readInt32LE(0);
|
||||
if (this.buffer.length < 4 + len) break;
|
||||
const id = this.buffer.readInt32LE(4);
|
||||
const body = this.buffer.subarray(12, 4 + len - 2).toString("utf8");
|
||||
this.buffer = this.buffer.subarray(4 + len);
|
||||
|
||||
if (this.authWaiter) {
|
||||
if (id === -1) {
|
||||
this.authWaiter.reject(new RconError("RCON auth failed — wrong password", "auth"));
|
||||
this.authWaiter = null;
|
||||
this.teardown();
|
||||
} else {
|
||||
this.authed = true;
|
||||
this.authWaiter.resolve();
|
||||
this.authWaiter = null;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const p = this.pending;
|
||||
if (p && id === p.id) {
|
||||
p.chunks.push(body);
|
||||
if (p.quietTimer) clearTimeout(p.quietTimer);
|
||||
p.quietTimer = setTimeout(() => this.finish(), 150);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private finish() {
|
||||
const p = this.pending;
|
||||
if (!p) return;
|
||||
clearTimeout(p.hardTimer);
|
||||
this.pending = null;
|
||||
p.resolve(p.chunks.join(""));
|
||||
this.drain();
|
||||
}
|
||||
|
||||
private drain() {
|
||||
const next = this.queue.shift();
|
||||
if (next) next();
|
||||
}
|
||||
|
||||
exec(command: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const run = async () => {
|
||||
try {
|
||||
await this.connect();
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
this.drain();
|
||||
return;
|
||||
}
|
||||
const id = this.nextId++;
|
||||
this.pending = {
|
||||
id,
|
||||
chunks: [],
|
||||
resolve,
|
||||
reject,
|
||||
hardTimer: setTimeout(() => {
|
||||
this.pending = null;
|
||||
reject(new RconError("RCON command timeout", "timeout"));
|
||||
this.drain();
|
||||
}, 10_000),
|
||||
};
|
||||
this.socket!.write(encodePacket(id, TYPE_COMMAND, command));
|
||||
};
|
||||
if (this.pending || this.queue.length > 0) this.queue.push(run);
|
||||
else void run();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const g = globalThis as unknown as { __craftdeckRcon?: RconClient };
|
||||
|
||||
export function rcon(): RconClient {
|
||||
if (!g.__craftdeckRcon) g.__craftdeckRcon = new RconClient();
|
||||
return g.__craftdeckRcon;
|
||||
}
|
||||
|
||||
/** Strip Minecraft § color/formatting codes from RCON output. */
|
||||
export function stripColors(s: string): string {
|
||||
return s.replace(/§[0-9a-fk-or]/gi, "");
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import fs from "node:fs";
|
||||
import { execFile } from "node:child_process";
|
||||
import { inspectContainer, containerStats } from "./docker";
|
||||
import { listOnlinePlayers, sparkTps, sparkPings } from "./mc";
|
||||
import { db } from "./db";
|
||||
import { bus } from "./bus";
|
||||
import { notify } from "./notify";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Periodic metrics collection into the panel DB (ring buffer with retention)
|
||||
* plus threshold alerts: server offline/crash, sustained low TPS, low disk.
|
||||
*/
|
||||
|
||||
type SamplerState = {
|
||||
timer: NodeJS.Timeout | null;
|
||||
tick: number;
|
||||
lastRunning: boolean | null;
|
||||
cachedTps: number | null;
|
||||
cachedMspt: number | null;
|
||||
lowTpsStreak: number;
|
||||
lastTpsAlert: number;
|
||||
lastDiskAlert: number;
|
||||
worldSizeBytes: number | null;
|
||||
};
|
||||
|
||||
const g = globalThis as unknown as { __craftdeckSampler?: SamplerState };
|
||||
|
||||
const INTERVAL_MS = 10_000;
|
||||
|
||||
async function sampleTick(state: SamplerState) {
|
||||
state.tick++;
|
||||
let running = false;
|
||||
try {
|
||||
const c = await inspectContainer(env.mcContainer);
|
||||
running = c.running;
|
||||
|
||||
// online/offline transitions → alerts
|
||||
if (state.lastRunning !== null && state.lastRunning !== running) {
|
||||
if (!running) {
|
||||
if ((c.exitCode ?? 0) !== 0) {
|
||||
notify("error", "Server crashed", `Container exited with code ${c.exitCode}`);
|
||||
} else {
|
||||
notify("warn", "Server stopped");
|
||||
}
|
||||
}
|
||||
}
|
||||
state.lastRunning = running;
|
||||
} catch {
|
||||
// docker unavailable — nothing to sample
|
||||
return;
|
||||
}
|
||||
|
||||
const ts = Date.now();
|
||||
let cpu: number | null = null;
|
||||
let memUsed: number | null = null;
|
||||
let memLimit: number | null = null;
|
||||
let netRx: number | null = null;
|
||||
let netTx: number | null = null;
|
||||
let players: number | null = null;
|
||||
|
||||
if (running) {
|
||||
try {
|
||||
const s = await containerStats(env.mcContainer);
|
||||
cpu = Math.round(s.cpuPercent * 10) / 10;
|
||||
memUsed = s.memUsed;
|
||||
memLimit = s.memLimit;
|
||||
netRx = s.netRx;
|
||||
netTx = s.netTx;
|
||||
} catch {}
|
||||
try {
|
||||
players = (await listOnlinePlayers()).online;
|
||||
} catch {}
|
||||
|
||||
// spark TPS every 3rd tick (30s)
|
||||
if (state.tick % 3 === 0) {
|
||||
const t = await sparkTps();
|
||||
state.cachedTps = t.tps;
|
||||
state.cachedMspt = t.mspt;
|
||||
if (t.tps !== null && t.tps < 15) {
|
||||
state.lowTpsStreak++;
|
||||
if (state.lowTpsStreak >= 3 && ts - state.lastTpsAlert > 15 * 60 * 1000) {
|
||||
state.lastTpsAlert = ts;
|
||||
notify("warn", "Server is lagging", `TPS dropped to ${t.tps.toFixed(1)} (target 20)`);
|
||||
}
|
||||
} else {
|
||||
state.lowTpsStreak = 0;
|
||||
}
|
||||
}
|
||||
|
||||
// per-player pings every 6th tick (60s)
|
||||
if (state.tick % 6 === 0) {
|
||||
const pings = await sparkPings();
|
||||
const insert = db().prepare("INSERT INTO ping_samples(ts, player, ping) VALUES(?, ?, ?)");
|
||||
for (const [player, ping] of Object.entries(pings)) insert.run(ts, player, ping);
|
||||
}
|
||||
} else {
|
||||
state.cachedTps = null;
|
||||
state.cachedMspt = null;
|
||||
}
|
||||
|
||||
// disk stats every tick (cheap statfs)
|
||||
let diskUsed: number | null = null;
|
||||
let diskFree: number | null = null;
|
||||
try {
|
||||
const st = fs.statfsSync(env.mcDataDir);
|
||||
diskFree = st.bavail * st.bsize;
|
||||
diskUsed = (st.blocks - st.bfree) * st.bsize;
|
||||
if (diskFree < 2 * 1024 ** 3 && ts - state.lastDiskAlert > 6 * 60 * 60 * 1000) {
|
||||
state.lastDiskAlert = ts;
|
||||
notify("error", "Low disk space", `Only ${(diskFree / 1024 ** 3).toFixed(1)} GB free`);
|
||||
}
|
||||
} catch {}
|
||||
|
||||
// world folder size every 60th tick (10 min) — du can be slow
|
||||
if (state.tick % 60 === 1) {
|
||||
execFile("du", ["-sk", env.mcDataDir], { timeout: 60_000 }, (err, stdout) => {
|
||||
if (!err) {
|
||||
const kb = parseInt(stdout.split("\t")[0], 10);
|
||||
if (!Number.isNaN(kb)) state.worldSizeBytes = kb * 1024;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
db()
|
||||
.prepare(
|
||||
`INSERT OR REPLACE INTO metrics(ts, cpu, mem_used, mem_limit, net_rx, net_tx, tps, mspt, players, disk_used, disk_free)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(ts, cpu, memUsed, memLimit, netRx, netTx, state.cachedTps, state.cachedMspt, players, diskUsed, diskFree);
|
||||
|
||||
bus().emit("metrics", {
|
||||
ts,
|
||||
running,
|
||||
cpu,
|
||||
memUsed,
|
||||
memLimit,
|
||||
tps: state.cachedTps,
|
||||
mspt: state.cachedMspt,
|
||||
players,
|
||||
diskFree,
|
||||
});
|
||||
|
||||
// retention pruning hourly
|
||||
if (state.tick % 360 === 2) {
|
||||
const week = ts - 7 * 24 * 60 * 60 * 1000;
|
||||
const month = ts - 30 * 24 * 60 * 60 * 1000;
|
||||
db().prepare("DELETE FROM metrics WHERE ts < ?").run(week);
|
||||
db().prepare("DELETE FROM ping_samples WHERE ts < ?").run(week);
|
||||
db().prepare("DELETE FROM activity WHERE ts < ?").run(month);
|
||||
db().prepare("DELETE FROM notifications WHERE ts < ?").run(month);
|
||||
}
|
||||
}
|
||||
|
||||
export function startSampler() {
|
||||
if (g.__craftdeckSampler) return;
|
||||
const state: SamplerState = {
|
||||
timer: null,
|
||||
tick: 0,
|
||||
lastRunning: null,
|
||||
cachedTps: null,
|
||||
cachedMspt: null,
|
||||
lowTpsStreak: 0,
|
||||
lastTpsAlert: 0,
|
||||
lastDiskAlert: 0,
|
||||
worldSizeBytes: null,
|
||||
};
|
||||
g.__craftdeckSampler = state;
|
||||
state.timer = setInterval(() => void sampleTick(state).catch(() => {}), INTERVAL_MS);
|
||||
void sampleTick(state).catch(() => {});
|
||||
}
|
||||
|
||||
export function getWorldSizeBytes(): number | null {
|
||||
return g.__craftdeckSampler?.worldSizeBytes ?? null;
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import cron, { type ScheduledTask } from "node-cron";
|
||||
import { db } from "./db";
|
||||
import { notify } from "./notify";
|
||||
import { powerContainer, execInContainer } from "./docker";
|
||||
import { rcon } from "./rcon";
|
||||
import { say } from "./mc";
|
||||
import { env } from "./env";
|
||||
|
||||
/**
|
||||
* Scheduled tasks (cron-based): restarts, backups, announcements, arbitrary
|
||||
* console commands. Definitions live in the panel DB; jobs are (re)loaded
|
||||
* whenever they change.
|
||||
*/
|
||||
|
||||
export 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 g = globalThis as unknown as { __craftdeckJobs?: Map<number, ScheduledTask> };
|
||||
|
||||
function jobs(): Map<number, ScheduledTask> {
|
||||
if (!g.__craftdeckJobs) g.__craftdeckJobs = new Map();
|
||||
return g.__craftdeckJobs;
|
||||
}
|
||||
|
||||
export function validateCron(expr: string): boolean {
|
||||
return cron.validate(expr);
|
||||
}
|
||||
|
||||
export async function runScheduleAction(action: Schedule["action"], payload: string | null): Promise<string> {
|
||||
switch (action) {
|
||||
case "restart": {
|
||||
try {
|
||||
await say("Server restarting in 30 seconds!");
|
||||
await new Promise((r) => setTimeout(r, 30_000));
|
||||
} catch {
|
||||
// server not reachable over RCON — restart anyway
|
||||
}
|
||||
await powerContainer(env.mcContainer, "restart");
|
||||
return "Restarted";
|
||||
}
|
||||
case "backup": {
|
||||
const res = await execInContainer(env.backupContainer, ["backup", "now"]);
|
||||
if (res.exitCode !== 0) throw new Error(res.output.slice(-500) || "backup failed");
|
||||
return "Backup completed";
|
||||
}
|
||||
case "command": {
|
||||
if (!payload) throw new Error("No command configured");
|
||||
return (await rcon().exec(payload)).slice(0, 500);
|
||||
}
|
||||
case "announce": {
|
||||
if (!payload) throw new Error("No message configured");
|
||||
await say(payload);
|
||||
return "Announced";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function execute(schedule: Schedule) {
|
||||
let result: string;
|
||||
try {
|
||||
result = await runScheduleAction(schedule.action, schedule.payload);
|
||||
if (schedule.action !== "announce") {
|
||||
notify("success", `Schedule "${schedule.name}" ran`, result, { discord: false });
|
||||
}
|
||||
} catch (err) {
|
||||
result = `ERROR: ${(err as Error).message}`;
|
||||
notify("error", `Schedule "${schedule.name}" failed`, (err as Error).message);
|
||||
}
|
||||
db()
|
||||
.prepare("UPDATE schedules SET last_run_at = ?, last_result = ? WHERE id = ?")
|
||||
.run(Date.now(), result.slice(0, 1000), schedule.id);
|
||||
}
|
||||
|
||||
export function reloadSchedules() {
|
||||
for (const task of jobs().values()) task.stop();
|
||||
jobs().clear();
|
||||
const rows = db().prepare("SELECT * FROM schedules WHERE enabled = 1").all() as unknown as Schedule[];
|
||||
for (const row of rows) {
|
||||
if (!cron.validate(row.cron)) continue;
|
||||
jobs().set(
|
||||
row.id,
|
||||
cron.schedule(row.cron, () => void execute(row))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function startScheduler() {
|
||||
reloadSchedules();
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Stateless signed-cookie sessions. Implemented with the Web Crypto API only,
|
||||
* so the exact same code runs in the Edge middleware and Node route handlers.
|
||||
* Token format: base64url(payload-json) + "." + base64url(hmac-sha256).
|
||||
*/
|
||||
|
||||
export const SESSION_COOKIE = "craftdeck_session";
|
||||
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
type SessionPayload = { u: string; iat: number; exp: number };
|
||||
|
||||
const enc = new TextEncoder();
|
||||
|
||||
function b64url(bytes: Uint8Array): string {
|
||||
let bin = "";
|
||||
for (const b of bytes) bin += String.fromCharCode(b);
|
||||
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
function b64urlDecode(s: string): Uint8Array {
|
||||
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
|
||||
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
||||
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
async function hmacKey(secret: string): Promise<CryptoKey> {
|
||||
return crypto.subtle.importKey(
|
||||
"raw",
|
||||
enc.encode(secret),
|
||||
{ name: "HMAC", hash: "SHA-256" },
|
||||
false,
|
||||
["sign", "verify"]
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSessionToken(secret: string, username: string): Promise<string> {
|
||||
const payload: SessionPayload = { u: username, iat: Date.now(), exp: Date.now() + SESSION_TTL_MS };
|
||||
const body = b64url(enc.encode(JSON.stringify(payload)));
|
||||
const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body));
|
||||
return `${body}.${b64url(new Uint8Array(sig))}`;
|
||||
}
|
||||
|
||||
export async function verifySessionToken(
|
||||
secret: string,
|
||||
token: string | undefined
|
||||
): Promise<SessionPayload | null> {
|
||||
if (!token) return null;
|
||||
const dot = token.lastIndexOf(".");
|
||||
if (dot < 1) return null;
|
||||
const body = token.slice(0, dot);
|
||||
const sig = token.slice(dot + 1);
|
||||
try {
|
||||
const ok = await crypto.subtle.verify(
|
||||
"HMAC",
|
||||
await hmacKey(secret),
|
||||
b64urlDecode(sig) as unknown as ArrayBuffer,
|
||||
enc.encode(body)
|
||||
);
|
||||
if (!ok) return null;
|
||||
const payload = JSON.parse(new TextDecoder().decode(b64urlDecode(body))) as SessionPayload;
|
||||
if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null;
|
||||
return payload;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionCookieAttributes(): string {
|
||||
// Secure flag intentionally omitted: the panel runs over plain HTTP on a
|
||||
// trusted LAN/VPN (see docs/networking.md). Add TLS + Secure if exposing it.
|
||||
return `Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`;
|
||||
}
|
||||
Reference in new issue
Block a user