Minecraft 26.2 Fabric server stack with CraftDeck management panel

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

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

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "dashboard",
"runtimeExecutable": "sh",
"runtimeArgs": ["-c", "cd dashboard && npm run dev"],
"port": 3000
}
]
}
+53
View File
@@ -0,0 +1,53 @@
# ──────────────────────────────────────────────────────────────────────────
# Minecraft server + dashboard configuration
# Copy to .env (scripts/setup.sh does this and generates the secrets).
# After changing values, apply with: docker compose up -d
# ──────────────────────────────────────────────────────────────────────────
# ── Minecraft ────────────────────────────────────────────────────────────
# Game version. Change and re-run `docker compose up -d mc` to upgrade.
MC_VERSION=26.2
# Loader: FABRIC (default), NEOFORGE, FORGE, VANILLA, PAPER, ...
SERVER_TYPE=FABRIC
# JVM heap for the server. Host has 10 GB → 6G heap / 7g container limit.
MC_MEMORY=6G
MC_CONTAINER_MEM_LIMIT=7g
MC_PORT=25565
MC_MOTD=A cozy server for friends
MC_DIFFICULTY=normal
MC_MAX_PLAYERS=10
MC_VIEW_DISTANCE=10
MC_SIMULATION_DISTANCE=8
# Leave empty for a random world seed
MC_SEED=
# Server-side mods (Modrinth project slugs, comma-separated).
# Auto-resolved to the newest compatible release on every container start.
MODRINTH_PROJECTS=fabric-api,easyauth,spark,lithium,ferrite-core
# To run a full modpack instead, set its Modrinth slug (e.g. adrenaline)
# and see docs/mods-and-modpacks.md before enabling.
MODRINTH_MODPACK=
# ── Secrets (generated by scripts/setup.sh — never commit .env) ──────────
RCON_PASSWORD=CHANGE_ME
PANEL_SESSION_SECRET=CHANGE_ME
# Initial dashboard admin password. Used only to seed the first login;
# you are forced to change it in the UI afterwards.
PANEL_ADMIN_PASSWORD=CHANGE_ME
# ── Dashboard ────────────────────────────────────────────────────────────
DASHBOARD_PORT=8080
# Minutes an approved player has to /register before approval expires
REGISTRATION_WINDOW_MINUTES=2
# Optional: Discord webhook for alerts (joins, downtime, backups, lag)
DISCORD_WEBHOOK_URL=
# ── Backups ──────────────────────────────────────────────────────────────
# Interval between automatic backups (e.g. 6h, 24h)
BACKUP_INTERVAL=24h
# Delete backups older than this many days
BACKUP_RETENTION_DAYS=7
# ── Misc ─────────────────────────────────────────────────────────────────
TZ=UTC
+14
View File
@@ -0,0 +1,14 @@
# Runtime data (world, backups, panel database) — never commit
data/
dashboard/dev-data/
# Secrets
.env
# Dashboard build artifacts
dashboard/node_modules/
dashboard/.next/
dashboard/next-env.d.ts
# OS noise
.DS_Store
+102
View File
@@ -0,0 +1,102 @@
# Minecraft 26.2 Fabric Server + CraftDeck Panel
A production-quality, self-hosted Minecraft **Java 26.2** server for friends:
- **Fabric** loader with invisible server-side performance mods — any vanilla client
(including TLauncher) can join, no client mods needed
- **Offline mode + gated authentication**: only usernames *you* approve can join, each
must register a password within a short window, and log in on every visit
- **CraftDeck**, a full web management panel: live console, players & stats, lag
diagnostics, performance graphs, mod manager with Modrinth search, backups & restore,
file browser/editor, config editor, scheduled tasks, Discord alerts
- **Automatic backups** with retention, **one-command deployment**, everything in Docker
| | |
|---|---|
| Minecraft | `26.2` (change one line to upgrade) |
| Loader | Fabric (NeoForge/Forge/vanilla supported via config) |
| Server mods | Fabric API, EasyAuth, spark, Lithium, FerriteCore — auto-updated |
| Panel | Next.js, port `8080`, LAN/VPN only |
| Host needs | Linux x86_64, Docker + Compose, ~10 GB RAM |
---
## Quick start
```bash
git clone <this-repo> minecraft && cd minecraft
./scripts/setup.sh # creates .env with generated secrets, prints your panel login
docker compose up -d # first start downloads the server + mods (a few minutes)
```
Then open **http://\<server-ip\>:8080**, log in (`admin` / password printed by setup.sh),
change the password, and you're live:
- Minecraft: `<server-ip>:25565`
- Dashboard: `http://<server-ip>:8080`
> No `setup.sh`? Copy `.env.example` to `.env` and fill in `RCON_PASSWORD`,
> `PANEL_SESSION_SECRET`, `PANEL_ADMIN_PASSWORD` yourself.
## Letting a friend join (the auth flow)
1. Panel → **Access & Auth** → type their Minecraft username → **Approve & open window**
2. Tell them to join `<server-ip>:25565` **right away** (TLauncher: any username works,
but it must match the approved name exactly)
3. In game they run `/register <password> <password>` before the countdown ends
(default 2 minutes — configurable in Settings)
4. Done. On every future visit they just `/login <password>`
If the window lapses, the name is auto-removed from the whitelist — re-approve when
they're ready. Full details: [docs/auth-flow.md](docs/auth-flow.md).
## Everyday operations
| Task | Where |
|---|---|
| Start / stop / restart | Overview page (or `docker compose restart mc`) |
| Watch console, run commands | Console page |
| See who's lagging (them vs you) | Performance & Lag page |
| Add/remove mods | Mods page (Modrinth search built in) |
| Back up / restore | Backups page |
| Change version, memory, MOTD… | Configuration page → Save → Apply |
| Auto-restarts & scheduled backups | Schedules page |
| Discord alerts | Settings page |
## Documentation
- [docs/auth-flow.md](docs/auth-flow.md) — how the approval/registration system works
- [docs/mods-and-modpacks.md](docs/mods-and-modpacks.md) — adding mods, switching to modpacks or other loaders
- [docs/updating.md](docs/updating.md) — upgrading Minecraft, mods, and the panel
- [docs/backups-and-restore.md](docs/backups-and-restore.md) — backup schedule, restores, disaster recovery
- [docs/networking.md](docs/networking.md) — ports, firewall, Tailscale, why the panel stays off the internet
- [docs/troubleshooting.md](docs/troubleshooting.md) — common problems and fixes
## Architecture
```
docker-compose.yml
├── mc itzg/minecraft-server (Fabric 26.2, offline mode, whitelist enforced,
│ RCON on the internal network only, mods resolved from Modrinth at start)
├── backup itzg/mc-backup (scheduled world tars + retention pruning)
└── dashboard ./dashboard (CraftDeck: Next.js + node:sqlite; talks to the
Docker socket, RCON, and the server files)
data/
├── mc/ world, configs, mods, logs (bind mount → survives everything)
├── backups/ world-*.tgz
└── dashboard/ panel database
```
The panel's registration-window enforcement, activity feed, metrics sampler, and
scheduler run inside the dashboard container; state lives in `data/dashboard/` and the
`.env` file is the single source of truth for server configuration.
## Security model (read this once)
- The Minecraft port (25565) is the only thing that should ever be internet-exposed.
- `online-mode=false` means Mojang doesn't verify usernames — the whitelist + EasyAuth
passwords are what keep strangers out. Keep registration windows short.
- The dashboard can start/stop containers and edit server files (it mounts the Docker
socket), so treat it like root on the host: **LAN or VPN only, never port-forward
8080.** See [docs/networking.md](docs/networking.md) for the Tailscale setup.
- RCON is not published outside the Docker network.
+4
View File
@@ -0,0 +1,4 @@
node_modules
.next
Dockerfile
.dockerignore
+56
View File
@@ -0,0 +1,56 @@
# syntax=docker/dockerfile:1
FROM node:26-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
FROM node:26-slim AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:26-slim AS runtime
ARG TARGETARCH=amd64
ARG DOCKER_CLI_VERSION=27.5.1
ARG COMPOSE_VERSION=v2.32.4
# unzip: mod jar metadata · tar/gzip: backup restore · curl: healthcheck + installs
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates curl unzip tar gzip \
&& rm -rf /var/lib/apt/lists/*
# Docker CLI + compose plugin so the panel can recreate containers after
# config changes (the socket is mounted by docker-compose.yml).
RUN set -eux; \
case "${TARGETARCH}" in \
arm64) arch=aarch64 ;; \
*) arch=x86_64 ;; \
esac; \
curl -fsSL "https://download.docker.com/linux/static/stable/${arch}/docker-${DOCKER_CLI_VERSION}.tgz" \
| tar -xz --strip-components=1 -C /usr/local/bin docker/docker; \
mkdir -p /usr/local/lib/docker/cli-plugins; \
curl -fsSL -o /usr/local/lib/docker/cli-plugins/docker-compose \
"https://github.com/docker/compose/releases/download/${COMPOSE_VERSION}/docker-compose-linux-${arch}"; \
chmod +x /usr/local/lib/docker/cli-plugins/docker-compose; \
docker --version; docker compose version
WORKDIR /app
ENV NODE_ENV=production \
PORT=8080 \
HOSTNAME=0.0.0.0 \
NEXT_TELEMETRY_DISABLED=1
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 8080
HEALTHCHECK --interval=30s --timeout=5s --start-period=20s \
CMD curl -fsS http://127.0.0.1:8080/api/health || exit 1
# Runs as root: required for the docker socket and to keep edited server
# files owned by the MC user (the panel chowns writes to uid/gid 1000).
CMD ["node", "server.js"]
+9
View File
@@ -0,0 +1,9 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
// The panel is self-hosted on a LAN; no image CDN available.
images: { unoptimized: true },
};
export default nextConfig;
+2038
View File
File diff suppressed because it is too large. Load diff
+31
View File
@@ -0,0 +1,31 @@
{
"name": "craftdeck",
"version": "1.0.0",
"private": true,
"description": "Web management dashboard for the Minecraft server stack",
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start"
},
"dependencies": {
"@codemirror/lang-json": "6.0.2",
"@codemirror/lang-yaml": "6.1.3",
"@codemirror/theme-one-dark": "6.1.3",
"@uiw/react-codemirror": "4.25.11",
"lucide-react": "1.24.0",
"next": "16.2.10",
"node-cron": "4.6.0",
"react": "19.2.7",
"react-dom": "19.2.7"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.2",
"@types/node": "26.1.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"postcss": "8.5.16",
"tailwindcss": "4.3.2",
"typescript": "5.9.3"
}
}
+5
View File
@@ -0,0 +1,5 @@
export default {
plugins: {
"@tailwindcss/postcss": {},
},
};
View File
Whitespace-only changes.
+289
View File
@@ -0,0 +1,289 @@
"use client";
import { useEffect, useState } from "react";
import { ShieldCheck, UserPlus, KeyRound, RefreshCw, Import, Copy } from "lucide-react";
import { api, useApi, useSSE, toast } from "@/lib/api";
import { Card, Button, Badge, Input, Modal, EmptyState } from "@/components/ui";
import { timeAgo, fmtCountdown, fmtDateTime } from "@/lib/format";
type ApprovedPlayer = {
id: number;
username: string;
status: "pending" | "registered" | "expired" | "revoked";
approved_at: number | null;
window_expires_at: number | null;
registered_at: number | null;
last_seen_at: number | null;
note: string | null;
};
type AccessData = {
approved: ApprovedPlayer[];
whitelist: string[] | null;
registered: string[] | null;
};
const STATUS_BADGE: Record<ApprovedPlayer["status"], { color: "good" | "warn" | "crit" | "muted"; label: string }> = {
pending: { color: "warn", label: "waiting to register" },
registered: { color: "good", label: "registered" },
expired: { color: "muted", label: "window expired" },
revoked: { color: "crit", label: "revoked" },
};
export default function AccessPage() {
const { data, error, refresh } = useApi<AccessData>("/api/approved", 10000);
const [newName, setNewName] = useState("");
const [busy, setBusy] = useState<string | null>(null);
const [tempPassword, setTempPassword] = useState<{ user: string; password: string } | null>(null);
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
const [, tick] = useState(0);
// live countdown for pending windows
useEffect(() => {
const t = setInterval(() => tick((x) => x + 1), 1000);
return () => clearInterval(t);
}, []);
useSSE("/api/stream/events", {
"mc-event": () => void refresh(),
});
const run = async (label: string, body: Record<string, unknown>, done?: (d: unknown) => void) => {
setBusy(label);
try {
const d = await api("/api/approved", { body });
done?.(d);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusy(null);
}
};
const approve = () => {
const name = newName.trim();
if (!name) return;
void run("approve", { op: "approve", username: name }, () => {
toast(`${name} approved — the registration window is open`, "success");
setNewName("");
});
};
const players = data?.approved ?? [];
const outsideWhitelist = (data?.whitelist ?? []).filter(
(w) => !players.some((p) => p.username.toLowerCase() === w.toLowerCase())
);
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold">Access &amp; Auth</h1>
<p className="mt-0.5 text-xs text-ink2">
Only approved usernames can join. New players must <code>/register</code> within the window, then{" "}
<code>/login</code> on every visit.
</p>
</div>
{/* approve box */}
<Card>
<div className="flex flex-wrap items-end gap-3">
<div className="flex-1">
<label className="mb-1 block text-xs font-medium text-ink2">Minecraft username to approve</label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && approve()}
placeholder="e.g. Steve_123"
className="w-full font-mono"
maxLength={16}
/>
</div>
<Button variant="primary" onClick={approve} busy={busy === "approve"}>
<UserPlus size={14} /> Approve &amp; open window
</Button>
</div>
<p className="mt-2 text-[11px] leading-relaxed text-muted">
Approving whitelists the name and starts the registration countdown. Tell your friend to join{" "}
<em>right away</em> and run <code>/register &lt;password&gt; &lt;password&gt;</code>. If the timer
expires first, the name is automatically removed from the whitelist.
</p>
</Card>
{error && (
<div className="rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm text-warn">{error}</div>
)}
{/* player table */}
<Card
title={`Approved players (${players.length})`}
actions={
<Button
size="sm"
onClick={() =>
void run("import", { op: "import-whitelist" }, (d) => {
const n = (d as { imported: number }).imported;
toast(n > 0 ? `Imported ${n} whitelist entr${n === 1 ? "y" : "ies"}` : "Nothing new to import", "info");
})
}
busy={busy === "import"}
title="Pull in players whitelisted outside the panel"
>
<Import size={13} /> Import whitelist
</Button>
}
pad={false}
>
{players.length === 0 ? (
<EmptyState
icon={<ShieldCheck size={28} />}
title="Nobody approved yet"
hint="Approve your friends' usernames above. Nobody else can join the server."
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Player</th>
<th className="px-4 py-2 font-medium">Status</th>
<th className="px-4 py-2 font-medium">Last seen</th>
<th className="px-4 py-2 font-medium">Registered</th>
<th className="px-4 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{players.map((p) => {
const badge = STATUS_BADGE[p.status];
return (
<tr key={p.id} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-mono font-medium">{p.username}</td>
<td className="px-4 py-2.5">
<Badge color={badge.color}>{badge.label}</Badge>
{p.status === "pending" && p.window_expires_at && (
<span className="ml-2 font-mono text-xs text-warn">
{fmtCountdown(p.window_expires_at)}
</span>
)}
{p.note && <span className="ml-2 text-[11px] text-muted">({p.note})</span>}
</td>
<td className="px-4 py-2.5 text-ink2">{timeAgo(p.last_seen_at)}</td>
<td className="px-4 py-2.5 text-ink2">{fmtDateTime(p.registered_at)}</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1.5">
{(p.status === "expired" || p.status === "revoked") && (
<Button
size="sm"
onClick={() =>
void run(`reopen-${p.username}`, { op: "reopen", username: p.username }, () =>
toast(`Window re-opened for ${p.username}`, "success")
)
}
busy={busy === `reopen-${p.username}`}
>
<RefreshCw size={12} /> Re-open window
</Button>
)}
{p.status === "registered" && (
<>
<Button
size="sm"
onClick={() =>
void run(
`reset-${p.username}`,
{ op: "reset-password", username: p.username },
(d) =>
setTempPassword({
user: p.username,
password: (d as { tempPassword: string }).tempPassword,
})
)
}
busy={busy === `reset-${p.username}`}
title="Set a temporary password and share it with them"
>
<KeyRound size={12} /> Reset password
</Button>
</>
)}
{p.status !== "revoked" && (
<Button size="sm" variant="danger" onClick={() => setConfirmRevoke(p.username)}>
Revoke
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
)}
</Card>
{/* whitelist drift warning */}
{outsideWhitelist.length > 0 && (
<Card title="On the whitelist but not managed here">
<div className="flex flex-wrap items-center gap-2">
{outsideWhitelist.map((w) => (
<Badge key={w} color="warn">
{w}
</Badge>
))}
<span className="text-xs text-muted">— use “Import whitelist” to manage them from the panel.</span>
</div>
</Card>
)}
{/* temp password modal */}
<Modal
open={tempPassword !== null}
onClose={() => setTempPassword(null)}
title={`Temporary password for ${tempPassword?.user}`}
>
<p className="text-sm text-ink2">
Share this privately. They log in with it and can change it in game via{" "}
<code>/account</code>.
</p>
<div className="mt-3 flex items-center gap-2 rounded-lg border border-border bg-surface2 px-3 py-2">
<code className="flex-1 font-mono text-lg tracking-wide text-accent">{tempPassword?.password}</code>
<Button
size="sm"
onClick={() => {
void navigator.clipboard.writeText(tempPassword?.password ?? "");
toast("Copied to clipboard", "success");
}}
>
<Copy size={12} /> Copy
</Button>
</div>
<p className="mt-3 text-[11px] text-muted">
This password is shown once and never stored by the panel.
</p>
</Modal>
{/* revoke confirm modal */}
<Modal open={confirmRevoke !== null} onClose={() => setConfirmRevoke(null)} title={`Revoke ${confirmRevoke}?`}>
<p className="text-sm text-ink2">
This removes them from the whitelist, deletes their password account, and kicks them if online. You
can re-approve them later.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setConfirmRevoke(null)}>Cancel</Button>
<Button
variant="danger"
busy={busy === `revoke-${confirmRevoke}`}
onClick={() => {
const name = confirmRevoke!;
setConfirmRevoke(null);
void run(`revoke-${name}`, { op: "revoke", username: name }, () =>
toast(`${name} revoked`, "warn")
);
}}
>
Revoke access
</Button>
</div>
</Modal>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
"use client";
import { useState } from "react";
import { Archive, Download, Trash2, History, PlayCircle } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Input, EmptyState, Modal } from "@/components/ui";
import { fmtBytes, fmtDateTime, timeAgo } from "@/lib/format";
type BackupsData = {
backups: Array<{ name: string; size: number; mtime: number }>;
totalSize: number;
interval: string;
retentionDays: string;
};
export default function BackupsPage() {
const { data, refresh } = useApi<BackupsData>("/api/backups", 30000);
const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
const [restoreTarget, setRestoreTarget] = useState<string | null>(null);
const [restoreConfirm, setRestoreConfirm] = useState("");
const [restoring, setRestoring] = useState(false);
const backupNow = async () => {
setCreating(true);
toast("Backup started — this can take a minute on a big world", "info");
try {
const res = await api<{ created: string }>("/api/backups", { method: "POST" });
toast(`Backup created: ${res.created}`, "success");
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setCreating(false);
}
};
const remove = async (name: string) => {
setDeleting(name);
try {
await api(`/api/backups/file?name=${encodeURIComponent(name)}`, { method: "DELETE" });
toast(`${name} deleted`, "warn");
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setDeleting(null);
}
};
const restore = async () => {
if (!restoreTarget) return;
setRestoring(true);
try {
await api("/api/backups/restore", { body: { name: restoreTarget, confirm: restoreConfirm } });
toast("Restore complete — the server is starting with the restored world", "success");
setRestoreTarget(null);
setRestoreConfirm("");
} catch (err) {
toast((err as Error).message, "error");
} finally {
setRestoring(false);
}
};
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Backups</h1>
<p className="mt-0.5 text-xs text-ink2">
Automatic every {data?.interval ?? "24h"}, kept {data?.retentionDays ?? "7"} days ·{" "}
{fmtBytes(data?.totalSize)} total — change in{" "}
<a href="/config" className="text-accent underline">
Configuration
</a>
</p>
</div>
<Button variant="primary" onClick={backupNow} busy={creating}>
<Archive size={14} /> Back up now
</Button>
</div>
<Card pad={false}>
{!data || data.backups.length === 0 ? (
<EmptyState
icon={<Archive size={28} />}
title="No backups yet"
hint='The backup sidecar creates the first one automatically shortly after the server starts, or press "Back up now".'
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Backup</th>
<th className="px-4 py-2 font-medium">Created</th>
<th className="px-4 py-2 font-medium">Size</th>
<th className="px-4 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{data.backups.map((b) => (
<tr key={b.name} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-mono text-xs font-medium">{b.name}</td>
<td className="px-4 py-2.5 text-ink2" title={fmtDateTime(b.mtime)}>
{timeAgo(b.mtime)}
</td>
<td className="px-4 py-2.5 text-ink2">{fmtBytes(b.size)}</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1.5">
<a href={`/api/backups/file?name=${encodeURIComponent(b.name)}`}>
<Button size="sm" title="Download">
<Download size={12} />
</Button>
</a>
<Button size="sm" variant="warn" onClick={() => setRestoreTarget(b.name)} title="Restore">
<History size={12} /> Restore
</Button>
<Button
size="sm"
variant="danger"
busy={deleting === b.name}
onClick={() => void remove(b.name)}
title="Delete"
>
<Trash2 size={12} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
<Modal
open={restoreTarget !== null}
onClose={() => {
setRestoreTarget(null);
setRestoreConfirm("");
}}
title="Restore backup"
>
<div className="space-y-3 text-sm text-ink2">
<p>
Restoring <code className="text-ink">{restoreTarget}</code> will:
</p>
<ol className="list-decimal space-y-1 pl-5 text-xs">
<li>Stop the server (players are disconnected)</li>
<li>
Overwrite the current world and settings with the backup —{" "}
<span className="text-warn">everything since the backup is lost</span>
</li>
<li>Start the server again</li>
</ol>
<p className="text-xs">
Type <code className="text-warn">restore</code> to confirm:
</p>
<Input
value={restoreConfirm}
onChange={(e) => setRestoreConfirm(e.target.value)}
placeholder="restore"
className="w-full font-mono"
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setRestoreTarget(null)}>Cancel</Button>
<Button variant="danger" disabled={restoreConfirm !== "restore"} busy={restoring} onClick={restore}>
<PlayCircle size={13} /> Restore this backup
</Button>
</div>
</Modal>
</div>
);
}
+161
View File
@@ -0,0 +1,161 @@
"use client";
import { useEffect, useState } from "react";
import { Rocket, AlertTriangle } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Input, Modal, Toggle } from "@/components/ui";
type ConfigValues = Record<string, string>;
const FIELDS: Array<{
key: string;
label: string;
hint?: string;
section: "server" | "gameplay" | "resources" | "backups";
placeholder?: string;
}> = [
{ key: "MC_VERSION", label: "Minecraft version", hint: "e.g. 26.2 — change, then Apply to upgrade", section: "server" },
{ key: "SERVER_TYPE", label: "Server type / loader", hint: "FABRIC (default), NEOFORGE, FORGE, VANILLA…", section: "server" },
{ key: "MC_MOTD", label: "MOTD (server list text)", section: "server" },
{ key: "MC_PORT", label: "Game port", section: "server" },
{ key: "MODRINTH_MODPACK", label: "Modrinth modpack slug", hint: "leave empty for plain Fabric — see docs before using", section: "server" },
{ key: "MC_DIFFICULTY", label: "Difficulty", hint: "peaceful / easy / normal / hard", section: "gameplay" },
{ key: "MC_MAX_PLAYERS", label: "Max players", section: "gameplay" },
{ key: "MC_VIEW_DISTANCE", label: "View distance", hint: "chunks — lower = less RAM/CPU", section: "gameplay" },
{ key: "MC_SIMULATION_DISTANCE", label: "Simulation distance", section: "gameplay" },
{ key: "MC_SEED", label: "World seed", hint: "only applies to newly generated worlds", section: "gameplay" },
{ key: "MC_MEMORY", label: "Java heap", hint: "e.g. 6G — leave ~1 GB headroom below the container limit", section: "resources" },
{ key: "MC_CONTAINER_MEM_LIMIT", label: "Container memory limit", hint: "e.g. 7g", section: "resources" },
{ key: "TZ", label: "Timezone", hint: "e.g. Asia/Kolkata — used by schedules and backups", section: "resources" },
{ key: "BACKUP_INTERVAL", label: "Backup interval", hint: "e.g. 6h, 24h", section: "backups" },
{ key: "BACKUP_RETENTION_DAYS", label: "Keep backups (days)", section: "backups" },
];
const SECTIONS = [
{ id: "server", title: "Server" },
{ id: "gameplay", title: "Gameplay" },
{ id: "resources", title: "Resources & time" },
{ id: "backups", title: "Backups" },
] as const;
export default function ConfigPage() {
const { data, refresh } = useApi<ConfigValues>("/api/config", 0);
const [values, setValues] = useState<ConfigValues>({});
const [saving, setSaving] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
const [applying, setApplying] = useState(false);
const [backupFirst, setBackupFirst] = useState(true);
const [dirty, setDirty] = useState(false);
useEffect(() => {
if (data) setValues(data);
}, [data]);
const set = (key: string, value: string) => {
setValues((v) => ({ ...v, [key]: value }));
setDirty(true);
};
const save = async () => {
setSaving(true);
try {
const updates: ConfigValues = {};
for (const f of FIELDS) updates[f.key] = values[f.key] ?? "";
await api("/api/config", { body: { updates } });
toast("Saved to .env — apply to make it live", "success");
setDirty(false);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setSaving(false);
}
};
const apply = async () => {
setApplying(true);
try {
await api("/api/config/apply", { body: { service: "mc", backupFirst } });
await api("/api/config/apply", { body: { service: "backup" } });
toast("Applied — containers recreated with the new configuration", "success");
setApplyOpen(false);
} catch (err) {
toast((err as Error).message, "error");
} finally {
setApplying(false);
}
};
const versionChanged = data && values["MC_VERSION"] !== data["MC_VERSION"];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Configuration</h1>
<p className="mt-0.5 text-xs text-ink2">
Values live in <code>.env</code>. Save writes the file; Apply recreates the server so they take
effect. Raw files (server.properties, EasyAuth) are editable in{" "}
<a href="/files" className="text-accent underline">
Files
</a>
.
</p>
</div>
<div className="flex gap-2">
<Button onClick={save} busy={saving} disabled={!dirty}>
Save
</Button>
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={dirty}>
<Rocket size={14} /> Apply (restarts server)
</Button>
</div>
</div>
{versionChanged && (
<div className="flex items-center gap-2 rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm text-warn">
<AlertTriangle size={15} />
Version change detected ({data?.["MC_VERSION"]} → {values["MC_VERSION"]}). Save, then Apply with the
safety backup enabled. Mods are automatically re-resolved for the new version.
</div>
)}
<div className="grid gap-4 lg:grid-cols-2">
{SECTIONS.map((section) => (
<Card key={section.id} title={section.title}>
<div className="space-y-3">
{FIELDS.filter((f) => f.section === section.id).map((f) => (
<div key={f.key}>
<label className="mb-1 block text-xs font-medium text-ink2">{f.label}</label>
<Input
value={values[f.key] ?? ""}
onChange={(e) => set(f.key, e.target.value)}
className="w-full font-mono"
placeholder={f.placeholder}
/>
{f.hint && <p className="mt-1 text-[10px] text-muted">{f.hint}</p>}
</div>
))}
</div>
</Card>
))}
</div>
<Modal open={applyOpen} onClose={() => setApplyOpen(false)} title="Apply configuration?">
<div className="space-y-3 text-sm text-ink2">
<p>
The server (and backup sidecar) containers are recreated with the saved <code>.env</code>. Players
online are disconnected while the server restarts.
</p>
<Toggle checked={backupFirst} onChange={setBackupFirst} label="Take a safety backup first (recommended)" />
</div>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setApplyOpen(false)}>Cancel</Button>
<Button variant="primary" busy={applying} onClick={apply}>
Apply now
</Button>
</div>
</Modal>
</div>
);
}
+177
View File
@@ -0,0 +1,177 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { SendHorizonal, ArrowDownToLine, Eraser } from "lucide-react";
import { api, useSSE, toast } from "@/lib/api";
import { Button, Input } from "@/components/ui";
const QUICK_COMMANDS = [
"list",
"whitelist list",
"auth list",
"spark tps",
"save-all",
"time set day",
"weather clear",
];
function colorFor(line: string): string {
if (/ERROR|FATAL|Exception|Crash/i.test(line)) return "text-crit";
if (/WARN/i.test(line)) return "text-warn";
if (/joined the game|registered|Done \(/.test(line)) return "text-good";
if (line.startsWith("> ")) return "text-accent";
return "text-ink2";
}
export default function ConsolePage() {
const [lines, setLines] = useState<string[]>([]);
const [command, setCommand] = useState("");
const [history, setHistory] = useState<string[]>([]);
const [histIdx, setHistIdx] = useState(-1);
const [filter, setFilter] = useState("");
const [follow, setFollow] = useState(true);
const [busy, setBusy] = useState(false);
const paneRef = useRef<HTMLDivElement>(null);
useSSE("/api/stream/logs", {
message: (line) => {
setLines((xs) => {
const next = [...xs, line as string];
return next.length > 1500 ? next.slice(-1200) : next;
});
},
});
useEffect(() => {
if (follow && paneRef.current) {
paneRef.current.scrollTop = paneRef.current.scrollHeight;
}
}, [lines, follow]);
const onScroll = () => {
const el = paneRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
setFollow(atBottom);
};
const send = async (cmd?: string) => {
const c = (cmd ?? command).trim();
if (!c) return;
setBusy(true);
try {
await api("/api/console", { body: { command: c } });
setHistory((h) => [c, ...h.filter((x) => x !== c)].slice(0, 50));
setHistIdx(-1);
setCommand("");
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusy(false);
}
};
const onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter") {
e.preventDefault();
void send();
} else if (e.key === "ArrowUp") {
e.preventDefault();
const next = Math.min(histIdx + 1, history.length - 1);
if (history[next]) {
setHistIdx(next);
setCommand(history[next]);
}
} else if (e.key === "ArrowDown") {
e.preventDefault();
const next = histIdx - 1;
setHistIdx(next);
setCommand(next >= 0 ? history[next] : "");
}
};
const visible = filter ? lines.filter((l) => l.toLowerCase().includes(filter.toLowerCase())) : lines;
return (
<div className="flex h-[calc(100vh-7.5rem)] flex-col gap-3">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Console</h1>
<p className="mt-0.5 text-xs text-ink2">Live server log with RCON command input</p>
</div>
<div className="flex items-center gap-2">
<Input
placeholder="Filter lines…"
value={filter}
onChange={(e) => setFilter(e.target.value)}
className="w-48"
/>
<Button size="sm" onClick={() => setLines([])}>
<Eraser size={13} /> Clear
</Button>
{!follow && (
<Button
size="sm"
variant="primary"
onClick={() => {
setFollow(true);
paneRef.current?.scrollTo({ top: paneRef.current.scrollHeight });
}}
>
<ArrowDownToLine size={13} /> Follow
</Button>
)}
</div>
</div>
<div
ref={paneRef}
onScroll={onScroll}
className="scroll-slim flex-1 overflow-y-auto rounded-xl border border-border bg-[#070a0e] p-3 font-mono text-[12px] leading-[1.55]"
>
{visible.length === 0 && (
<div className="py-10 text-center text-muted">
Waiting for log output… (is the server running?)
</div>
)}
{visible.map((line, i) => (
<div key={i} className={`whitespace-pre-wrap break-all ${colorFor(line)}`}>
{line}
</div>
))}
</div>
<div className="space-y-2">
<div className="flex gap-2">
<div className="relative flex-1">
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-accent">
&gt;
</span>
<Input
value={command}
onChange={(e) => setCommand(e.target.value)}
onKeyDown={onKeyDown}
placeholder="Type a server command… (↑ for history)"
className="w-full pl-7 font-mono"
spellCheck={false}
/>
</div>
<Button variant="primary" onClick={() => void send()} busy={busy}>
<SendHorizonal size={14} /> Send
</Button>
</div>
<div className="flex flex-wrap gap-1.5">
{QUICK_COMMANDS.map((c) => (
<button
key={c}
onClick={() => void send(c)}
className="rounded-md border border-border bg-surface2 px-2 py-0.5 font-mono text-[11px] text-ink2 hover:text-ink"
>
{c}
</button>
))}
</div>
</div>
</div>
);
}
+304
View File
@@ -0,0 +1,304 @@
"use client";
import { useCallback, useEffect, useRef, useState } from "react";
import {
Folder,
File as FileIcon,
ChevronRight,
Home,
Upload,
FolderPlus,
Trash2,
Download,
Save,
X,
} from "lucide-react";
import CodeMirror from "@uiw/react-codemirror";
import { json } from "@codemirror/lang-json";
import { yaml } from "@codemirror/lang-yaml";
import { oneDark } from "@codemirror/theme-one-dark";
import { api, toast } from "@/lib/api";
import { Card, Button, EmptyState, Input, Modal } from "@/components/ui";
import { fmtBytes, fmtDateTime } from "@/lib/format";
type Entry = { name: string; type: "file" | "dir"; size: number; mtime: number };
const TEXT_EXT = /\.(txt|json|json5|yml|yaml|toml|conf|cfg|properties|log|mcmeta|nbt\.txt|md|sh|env|ini|snbt)$/i;
function langFor(name: string) {
if (/\.json5?(\.|$)/i.test(name) || name.endsWith(".mcmeta")) return [json()];
if (/\.ya?ml$/i.test(name)) return [yaml()];
return [];
}
export default function FilesPage() {
const [path, setPath] = useState("");
const [entries, setEntries] = useState<Entry[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [editing, setEditing] = useState<{ path: string; content: string; dirty: boolean; truncated: boolean } | null>(null);
const [saving, setSaving] = useState(false);
const [mkdirOpen, setMkdirOpen] = useState(false);
const [mkdirName, setMkdirName] = useState("");
const [confirmDelete, setConfirmDelete] = useState<Entry | null>(null);
const uploadRef = useRef<HTMLInputElement>(null);
const load = useCallback(async (p: string) => {
setError(null);
try {
setEntries(await api<Entry[]>(`/api/files?dir=${encodeURIComponent(p)}`));
} catch (err) {
setEntries([]);
setError((err as Error).message);
}
}, []);
useEffect(() => {
void load(path);
}, [path, load]);
const open = async (entry: Entry) => {
const full = path ? `${path}/${entry.name}` : entry.name;
if (entry.type === "dir") {
setPath(full);
return;
}
if (!TEXT_EXT.test(entry.name) && entry.size > 512 * 1024) {
window.location.href = `/api/files/download?path=${encodeURIComponent(full)}`;
return;
}
try {
const d = await api<{ content: string; truncated: boolean }>(`/api/files?file=${encodeURIComponent(full)}`);
setEditing({ path: full, content: d.content, dirty: false, truncated: d.truncated });
} catch (err) {
toast((err as Error).message, "error");
}
};
const save = async () => {
if (!editing) return;
setSaving(true);
try {
await api("/api/files", { body: { op: "write", path: editing.path, content: editing.content } });
toast(`Saved ${editing.path}`, "success");
setEditing({ ...editing, dirty: false });
void load(path);
} catch (err) {
toast((err as Error).message, "error");
} finally {
setSaving(false);
}
};
const upload = async (file: File) => {
const form = new FormData();
form.append("dir", path);
form.append("file", file);
toast(`Uploading ${file.name}…`, "info");
const res = await fetch("/api/files/upload", { method: "POST", body: form });
const payload = await res.json();
if (!res.ok || payload.ok === false) {
toast(payload.error ?? "Upload failed", "error");
} else {
toast(`${file.name} uploaded`, "success");
void load(path);
}
};
const crumbs = path ? path.split("/") : [];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Files</h1>
<p className="mt-0.5 text-xs text-ink2">Server data directory — world, configs, mods, logs</p>
</div>
<div className="flex gap-2">
<input
ref={uploadRef}
type="file"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) void upload(f);
e.target.value = "";
}}
/>
<Button size="sm" onClick={() => uploadRef.current?.click()}>
<Upload size={13} /> Upload here
</Button>
<Button size="sm" onClick={() => setMkdirOpen(true)}>
<FolderPlus size={13} /> New folder
</Button>
</div>
</div>
{/* breadcrumbs */}
<div className="flex flex-wrap items-center gap-1 text-sm">
<button onClick={() => setPath("")} className="flex items-center gap-1 text-ink2 hover:text-ink">
<Home size={13} /> data
</button>
{crumbs.map((c, i) => (
<span key={i} className="flex items-center gap-1">
<ChevronRight size={12} className="text-muted" />
<button
onClick={() => setPath(crumbs.slice(0, i + 1).join("/"))}
className={i === crumbs.length - 1 ? "font-medium text-ink" : "text-ink2 hover:text-ink"}
>
{c}
</button>
</span>
))}
</div>
{error && <div className="rounded-xl border border-crit/40 bg-crit/10 px-4 py-3 text-sm text-crit">{error}</div>}
<Card pad={false}>
{entries === null ? (
<EmptyState title="Loading…" />
) : entries.length === 0 && !error ? (
<EmptyState icon={<Folder size={28} />} title="Empty folder" />
) : (
<table className="w-full text-sm">
<tbody>
{entries.map((e) => (
<tr key={e.name} className="border-b border-border/50 last:border-0 hover:bg-surface2">
<td className="cursor-pointer px-4 py-2" onClick={() => void open(e)}>
<span className="flex items-center gap-2.5">
{e.type === "dir" ? (
<Folder size={15} className="text-series1" />
) : (
<FileIcon size={15} className="text-muted" />
)}
<span className={e.type === "dir" ? "font-medium" : ""}>{e.name}</span>
</span>
</td>
<td className="w-28 px-4 py-2 text-xs text-muted">{e.type === "file" ? fmtBytes(e.size) : "—"}</td>
<td className="w-40 px-4 py-2 text-xs text-muted">{fmtDateTime(e.mtime)}</td>
<td className="w-24 px-4 py-2">
<div className="flex justify-end gap-1">
{e.type === "file" && (
<a
href={`/api/files/download?path=${encodeURIComponent(path ? `${path}/${e.name}` : e.name)}`}
className="rounded p-1 text-muted hover:text-ink"
title="Download"
>
<Download size={13} />
</a>
)}
<button
onClick={() => setConfirmDelete(e)}
className="rounded p-1 text-muted hover:text-crit"
title={e.type === "dir" ? "Delete (empty folders only)" : "Delete"}
>
<Trash2 size={13} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
{/* editor modal */}
{editing && (
<div className="fixed inset-0 z-50 flex flex-col bg-black/70 p-4 md:p-10">
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-surface">
<header className="flex items-center justify-between border-b border-border px-4 py-2.5">
<div className="flex items-center gap-2 font-mono text-sm">
{editing.path}
{editing.dirty && <span className="text-warn">●</span>}
{editing.truncated && <span className="text-[10px] text-warn">(truncated — file over 2 MB)</span>}
</div>
<div className="flex gap-2">
<Button size="sm" variant="primary" onClick={save} busy={saving} disabled={!editing.dirty || editing.truncated}>
<Save size={13} /> Save
</Button>
<Button size="sm" onClick={() => setEditing(null)}>
<X size={13} /> Close
</Button>
</div>
</header>
<div className="scroll-slim min-h-0 flex-1 overflow-auto">
<CodeMirror
value={editing.content}
onChange={(v) => setEditing((e) => (e ? { ...e, content: v, dirty: true } : e))}
extensions={langFor(editing.path)}
theme={oneDark}
height="100%"
style={{ fontSize: 12, height: "100%" }}
/>
</div>
</div>
</div>
)}
{/* new folder modal */}
<Modal open={mkdirOpen} onClose={() => setMkdirOpen(false)} title="New folder">
<Input
value={mkdirName}
onChange={(e) => setMkdirName(e.target.value)}
placeholder="folder name"
className="w-full"
onKeyDown={async (e) => {
if (e.key === "Enter" && mkdirName.trim()) {
await api("/api/files", {
body: { op: "mkdir", path: path ? `${path}/${mkdirName.trim()}` : mkdirName.trim() },
});
setMkdirOpen(false);
setMkdirName("");
void load(path);
}
}}
/>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setMkdirOpen(false)}>Cancel</Button>
<Button
variant="primary"
onClick={async () => {
if (!mkdirName.trim()) return;
await api("/api/files", {
body: { op: "mkdir", path: path ? `${path}/${mkdirName.trim()}` : mkdirName.trim() },
});
setMkdirOpen(false);
setMkdirName("");
void load(path);
}}
>
Create
</Button>
</div>
</Modal>
{/* delete confirm */}
<Modal open={confirmDelete !== null} onClose={() => setConfirmDelete(null)} title={`Delete ${confirmDelete?.name}?`}>
<p className="text-sm text-ink2">
{confirmDelete?.type === "dir"
? "Only empty folders can be deleted from the panel."
: "This permanently deletes the file from the server data directory."}
</p>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setConfirmDelete(null)}>Cancel</Button>
<Button
variant="danger"
onClick={async () => {
const e = confirmDelete!;
setConfirmDelete(null);
try {
await api("/api/files", { body: { op: "delete", path: path ? `${path}/${e.name}` : e.name } });
toast(`${e.name} deleted`, "warn");
void load(path);
} catch (err) {
toast((err as Error).message, "error");
}
}}
>
Delete
</Button>
</div>
</Modal>
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { Shell } from "@/components/shell";
export default function PanelLayout({ children }: { children: React.ReactNode }) {
return <Shell>{children}</Shell>;
}
+267
View File
@@ -0,0 +1,267 @@
"use client";
import { useState } from "react";
import { Package, Search, Trash2, Power, Download, RefreshCw, Lock } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Badge, Input, EmptyState, Modal } from "@/components/ui";
import { fmtBytes } from "@/lib/format";
type ModsData = {
installed: Array<{
file: string;
name: string;
id: string | null;
version: string | null;
disabled: boolean;
size: number;
}>;
managed: string[];
mcVersion: string;
};
type SearchHit = {
slug: string;
title: string;
description: string;
downloads: number;
icon_url: string | null;
server_side: string;
client_side: string;
};
const PROTECTED = new Set(["fabric-api", "easyauth"]);
export default function ModsPage() {
const { data, refresh } = useApi<ModsData>("/api/mods", 0);
const [query, setQuery] = useState("");
const [hits, setHits] = useState<SearchHit[] | null>(null);
const [searching, setSearching] = useState(false);
const [busy, setBusy] = useState<string | null>(null);
const [applying, setApplying] = useState(false);
const [needsApply, setNeedsApply] = useState(false);
const [confirmApply, setConfirmApply] = useState(false);
const search = async () => {
if (!query.trim()) return;
setSearching(true);
try {
setHits(await api<SearchHit[]>(`/api/mods/search?q=${encodeURIComponent(query.trim())}`));
} catch (err) {
toast((err as Error).message, "error");
} finally {
setSearching(false);
}
};
const manage = async (label: string, body: Record<string, unknown>, message: string) => {
setBusy(label);
try {
const res = await api<{ needsApply?: boolean; needsRestart?: boolean }>("/api/mods/manage", { body });
toast(message, "success");
if (res.needsApply || res.needsRestart) setNeedsApply(true);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusy(null);
}
};
const apply = async () => {
setConfirmApply(false);
setApplying(true);
try {
await api("/api/mods/apply", { method: "POST" });
toast("Server recreated — mods re-resolved for the current version", "success");
setNeedsApply(false);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setApplying(false);
}
};
const managedSet = new Set((data?.managed ?? []).map((s) => s.toLowerCase()));
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Mods</h1>
<p className="mt-0.5 text-xs text-ink2">
Managed mods auto-update to the newest build for Minecraft {data?.mcVersion ?? "…"} on every apply
</p>
</div>
<Button variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
<RefreshCw size={14} /> {needsApply ? "Apply changes (restarts server)" : "Update all & restart"}
</Button>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* Modrinth search */}
<Card title="Add from Modrinth">
<div className="flex gap-2">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && void search()}
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.2"}…`}
className="w-full"
/>
<Button onClick={() => void search()} busy={searching}>
<Search size={14} />
</Button>
</div>
<div className="scroll-slim mt-3 max-h-96 space-y-2 overflow-y-auto">
{hits === null && (
<p className="py-6 text-center text-xs text-muted">
Search Modrinth — results are pre-filtered to Fabric + your server version.
</p>
)}
{hits?.length === 0 && <p className="py-6 text-center text-xs text-muted">No compatible mods found.</p>}
{hits?.map((h) => {
const already = managedSet.has(h.slug.toLowerCase());
return (
<div key={h.slug} className="flex items-center gap-3 rounded-lg border border-border bg-surface2 p-2.5">
{h.icon_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={h.icon_url} alt="" className="h-9 w-9 rounded-md bg-surface3" />
) : (
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-surface3">
<Package size={16} className="text-muted" />
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-medium">{h.title}</span>
{h.client_side === "required" && (
<Badge color="warn">needs client install</Badge>
)}
</div>
<div className="truncate text-[11px] text-muted">{h.description}</div>
</div>
<Button
size="sm"
variant={already ? "ghost" : "primary"}
disabled={already}
busy={busy === `add-${h.slug}`}
onClick={() => void manage(`add-${h.slug}`, { op: "add", slug: h.slug }, `${h.title} added`)}
>
<Download size={12} /> {already ? "Added" : "Add"}
</Button>
</div>
);
})}
</div>
</Card>
{/* managed list */}
<Card title={`Managed mods (${data?.managed.length ?? 0})`}>
<p className="mb-3 text-[11px] leading-relaxed text-muted">
These Modrinth projects are pinned in <code>.env</code> (MODRINTH_PROJECTS). The server resolves the
newest compatible build on every start, so updating Minecraft also updates these automatically.
</p>
<ul className="space-y-1.5">
{(data?.managed ?? []).map((slug) => (
<li key={slug} className="flex items-center gap-2 rounded-lg border border-border bg-surface2 px-3 py-2">
<span className="font-mono text-sm">{slug}</span>
{PROTECTED.has(slug.toLowerCase()) && (
<Badge color="info">
<Lock size={9} /> required
</Badge>
)}
<span className="ml-auto">
{!PROTECTED.has(slug.toLowerCase()) && (
<Button
size="sm"
variant="danger"
busy={busy === `remove-${slug}`}
onClick={() => void manage(`remove-${slug}`, { op: "remove", slug }, `${slug} removed`)}
>
<Trash2 size={12} />
</Button>
)}
</span>
</li>
))}
</ul>
</Card>
</div>
{/* installed jars */}
<Card title="Installed jar files" pad={false}>
{!data || data.installed.length === 0 ? (
<EmptyState
icon={<Package size={28} />}
title="No mod jars found"
hint="Jars appear here once the server has started and downloaded its mods. You can also upload jars via the Files page (mods/ folder)."
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Mod</th>
<th className="px-4 py-2 font-medium">Version</th>
<th className="px-4 py-2 font-medium">File</th>
<th className="px-4 py-2 font-medium">Size</th>
<th className="px-4 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{data.installed.map((m) => (
<tr key={m.file} className={`border-b border-border/50 last:border-0 ${m.disabled ? "opacity-50" : ""}`}>
<td className="px-4 py-2.5 font-medium">
{m.name} {m.disabled && <Badge color="muted">disabled</Badge>}
</td>
<td className="px-4 py-2.5 font-mono text-xs text-ink2">{m.version ?? "—"}</td>
<td className="px-4 py-2.5 font-mono text-[11px] text-muted">{m.file}</td>
<td className="px-4 py-2.5 text-ink2">{fmtBytes(m.size)}</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1.5">
<Button
size="sm"
busy={busy === `toggle-${m.file}`}
onClick={() =>
void manage(
`toggle-${m.file}`,
{ op: "toggle", file: m.file },
m.disabled ? `${m.name} enabled` : `${m.name} disabled`
)
}
title={m.disabled ? "Enable" : "Disable (renames to .disabled)"}
>
<Power size={12} />
</Button>
<Button
size="sm"
variant="danger"
busy={busy === `delete-${m.file}`}
onClick={() => void manage(`delete-${m.file}`, { op: "delete", file: m.file }, `${m.file} deleted`)}
>
<Trash2 size={12} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
<Modal open={confirmApply} onClose={() => setConfirmApply(false)} title="Apply mod changes?">
<p className="text-sm leading-relaxed text-ink2">
This recreates the server container: online players are disconnected for a minute or two while the
server restarts and re-resolves every managed mod against Minecraft {data?.mcVersion}.
</p>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setConfirmApply(false)}>Cancel</Button>
<Button variant="primary" onClick={() => void apply()}>
Apply &amp; restart
</Button>
</div>
</Modal>
</div>
);
}
+264
View File
@@ -0,0 +1,264 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Play, Square, RotateCw, HardDrive, Globe2, Cuboid } from "lucide-react";
import { api, useApi, useSSE, toast } from "@/lib/api";
import { Card, StatTile, Button, Badge, EmptyState } from "@/components/ui";
import { Sparkline, UsageBar, type Point } from "@/components/charts";
import { fmtBytes, fmtDuration, timeAgo, fmtCountdown } from "@/lib/format";
type ServerData = {
dockerAvailable: boolean;
container: { exists: boolean; running: boolean; status: string; health?: string };
players: { online: number; max: number; names: string[] };
version: string | null;
serverType: string | null;
metrics: Record<string, number | null> | null;
pendingApprovals: number;
world: { seed: string | null; sizeBytes: number | null; dimensions: string[]; levelName: string | null };
uptimeMs: number | null;
};
type MetricRow = {
bucket: number;
cpu: number | null;
mem_used: number | null;
mem_limit: number | null;
tps: number | null;
players: number | null;
disk_used: number | null;
disk_free: number | null;
};
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
type ApprovedRow = { username: string; status: string; window_expires_at: number | null };
const ACTIVITY_LABELS: Record<string, { label: string; color: "good" | "warn" | "crit" | "info" | "muted" }> = {
join: { label: "joined", color: "good" },
leave: { label: "left", color: "muted" },
connect: { label: "connecting", color: "info" },
register: { label: "registered", color: "good" },
approve: { label: "approved", color: "info" },
expire: { label: "window expired", color: "warn" },
revoke: { label: "revoked", color: "crit" },
server_online: { label: "server online", color: "good" },
server_stopping: { label: "server stopping", color: "warn" },
backup: { label: "backup", color: "info" },
restore: { label: "restore", color: "warn" },
power: { label: "power", color: "muted" },
console: { label: "console", color: "muted" },
world_reset: { label: "world reset", color: "crit" },
};
export default function OverviewPage() {
const { data: server, refresh } = useApi<ServerData>("/api/server", 10000);
const { data: metricRows, refresh: refreshMetrics } = useApi<MetricRow[]>("/api/metrics?range=1h", 30000);
const { data: activity, refresh: refreshActivity } = useApi<ActivityRow[]>("/api/activity?limit=14", 20000);
const { data: approvedData, refresh: refreshApproved } = useApi<{ approved: ApprovedRow[] }>(
"/api/approved",
15000
);
const [busyAction, setBusyAction] = useState<string | null>(null);
const [, forceTick] = useState(0);
useSSE("/api/stream/events", {
"mc-event": () => {
void refreshActivity();
void refreshApproved();
},
metrics: () => forceTick((x) => x + 1),
});
const power = async (action: "start" | "stop" | "restart") => {
setBusyAction(action);
try {
await api("/api/server/power", { body: { action } });
toast(`Server ${action} requested`, "success");
setTimeout(() => void refresh(), 1500);
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusyAction(null);
}
};
const running = server?.container.running ?? false;
const m = server?.metrics;
const points = (key: keyof MetricRow): Point[] =>
(metricRows ?? []).map((r) => ({ ts: r.bucket, v: r[key] as number | null }));
const pending = (approvedData?.approved ?? []).filter((a) => a.status === "pending");
return (
<div className="space-y-4">
{/* header row */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Overview</h1>
<p className="mt-0.5 text-xs text-ink2">
{server?.serverType ?? "Fabric"} · Minecraft {server?.version ?? "…"}
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
</p>
</div>
<div className="flex gap-2">
<Button
variant="primary"
size="sm"
onClick={() => power("start")}
busy={busyAction === "start"}
disabled={running || !server?.dockerAvailable}
>
<Play size={13} /> Start
</Button>
<Button
size="sm"
onClick={() => power("restart")}
busy={busyAction === "restart"}
disabled={!running}
>
<RotateCw size={13} /> Restart
</Button>
<Button
variant="danger"
size="sm"
onClick={() => power("stop")}
busy={busyAction === "stop"}
disabled={!running}
>
<Square size={13} /> Stop
</Button>
</div>
</div>
{!server?.dockerAvailable && server && (
<div className="rounded-xl border border-crit/40 bg-crit/10 px-4 py-3 text-sm text-crit">
Docker is unreachable from the panel. If you're running the dashboard outside the compose stack,
server control and metrics are unavailable.
</div>
)}
{/* stat tiles */}
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
<StatTile
label="TPS"
value={m?.tps != null ? Number(m.tps).toFixed(1) : "—"}
sub="target 20.0"
accent={m?.tps != null ? (Number(m.tps) >= 18 ? "good" : Number(m.tps) >= 15 ? "warn" : "crit") : undefined}
>
<Sparkline points={points("tps")} yMax={20} color="var(--color-series2)" />
</StatTile>
<StatTile label="CPU" value={m?.cpu != null ? `${Number(m.cpu).toFixed(0)}%` : "—"} sub="of one core × cores">
<Sparkline points={points("cpu")} />
</StatTile>
<StatTile
label="Memory"
value={m?.mem_used != null ? fmtBytes(Number(m.mem_used)) : "—"}
sub={m?.mem_limit ? `limit ${fmtBytes(Number(m.mem_limit))}` : undefined}
>
<Sparkline points={points("mem_used")} yMax={m?.mem_limit ? Number(m.mem_limit) : undefined} />
</StatTile>
<StatTile
label="Players"
value={`${server?.players.online ?? 0}${server?.players.max ? ` / ${server.players.max}` : ""}`}
sub={server?.players.names.join(", ") || "nobody online"}
>
<Sparkline points={points("players")} color="var(--color-series3)" />
</StatTile>
</div>
<div className="grid gap-4 lg:grid-cols-2">
{/* activity feed */}
<Card title="Recent activity">
{!activity || activity.length === 0 ? (
<EmptyState title="No activity yet" hint="Joins, registrations and server events show up here." />
) : (
<ul className="space-y-2">
{activity.map((a) => {
const meta = ACTIVITY_LABELS[a.type] ?? { label: a.type, color: "muted" as const };
return (
<li key={a.id} className="flex items-center gap-2.5 text-sm">
<Badge color={meta.color}>{meta.label}</Badge>
<span className="truncate text-ink">
{a.player && <span className="font-medium">{a.player} </span>}
<span className="text-ink2">{a.detail}</span>
</span>
<span className="ml-auto shrink-0 text-[11px] text-muted">{timeAgo(a.ts)}</span>
</li>
);
})}
</ul>
)}
</Card>
<div className="space-y-4">
{/* pending registrations */}
{pending.length > 0 && (
<Card title="Waiting to register">
<ul className="space-y-2">
{pending.map((p) => (
<li key={p.username} className="flex items-center gap-2 text-sm">
<Badge color="warn">pending</Badge>
<span className="font-medium">{p.username}</span>
<span className="ml-auto font-mono text-xs text-warn">
{p.window_expires_at ? fmtCountdown(p.window_expires_at) : ""}
</span>
</li>
))}
</ul>
<p className="mt-3 text-[11px] text-muted">
They must join and <code>/register &lt;password&gt; &lt;password&gt;</code> before the timer runs
out. Manage in{" "}
<Link href="/access" className="text-accent underline">
Access &amp; Auth
</Link>
.
</p>
</Card>
)}
{/* world + disk */}
<Card title="World & storage">
<div className="space-y-3">
<div className="flex items-center gap-3 text-sm">
<Cuboid size={15} className="text-muted" />
<span className="text-ink2">World</span>
<span className="ml-auto font-medium">
{server?.world.levelName ?? "world"} · {fmtBytes(server?.world.sizeBytes)}
</span>
</div>
<div className="flex items-center gap-3 text-sm">
<Globe2 size={15} className="text-muted" />
<span className="text-ink2">Dimensions</span>
<span className="ml-auto font-medium">
{server?.world.dimensions.length ? server.world.dimensions.join(" · ") : "not generated yet"}
</span>
</div>
{server?.world.seed && (
<div className="flex items-center gap-3 text-sm">
<span className="text-ink2">Seed</span>
<span className="ml-auto font-mono text-xs">{server.world.seed}</span>
</div>
)}
<div className="pt-1">
{m?.disk_used != null && m?.disk_free != null ? (
<UsageBar
used={Number(m.disk_used)}
total={Number(m.disk_used) + Number(m.disk_free)}
label="Host disk"
format={fmtBytes}
/>
) : (
<div className="flex items-center gap-2 text-xs text-muted">
<HardDrive size={13} /> disk stats unavailable
</div>
)}
</div>
</div>
</Card>
</div>
</div>
</div>
);
}
@@ -0,0 +1,261 @@
"use client";
import { useMemo, useState } from "react";
import { Stethoscope, Wifi } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Badge, Tabs, EmptyState, Modal } from "@/components/ui";
import { TimeSeriesChart, Sparkline, type Point } from "@/components/charts";
import { fmtBytes } from "@/lib/format";
type MetricRow = {
bucket: number;
cpu: number | null;
mem_used: number | null;
mem_limit: number | null;
net_rx: number | null;
net_tx: number | null;
tps: number | null;
mspt: number | null;
players: number | null;
disk_used: number | null;
disk_free: number | null;
};
type LagData = {
tps: number | null;
mspt: number | null;
cpu: number | null;
serverHealthy: boolean;
verdict: "no-data" | "server-lagging" | "player-connections" | "all-good";
players: Array<{
name: string;
ping: number | null;
verdict: string;
history: Array<{ ts: number; ping: number }>;
}>;
};
const RANGES = [
{ id: "1h", label: "1 hour" },
{ id: "6h", label: "6 hours" },
{ id: "24h", label: "24 hours" },
{ id: "7d", label: "7 days" },
];
const VERDICTS: Record<LagData["verdict"], { badge: "good" | "warn" | "crit" | "muted"; title: string; hint: string }> = {
"all-good": {
badge: "good",
title: "Everything looks healthy",
hint: "The server is ticking at full speed and every player's connection is fine.",
},
"server-lagging": {
badge: "crit",
title: "The server itself is lagging",
hint: "Tick rate is below target — everyone experiences this equally. Check CPU/memory, heavy redstone/farms, or too many loaded chunks.",
},
"player-connections": {
badge: "warn",
title: "Server is fine — some connections are not",
hint: "The server is ticking normally. The players flagged below have high ping, so lag is on their network path, not your machine.",
},
"no-data": {
badge: "muted",
title: "No data yet",
hint: "Start the server and give the sampler a minute to collect TPS and ping data.",
},
};
export default function PerformancePage() {
const [range, setRange] = useState("1h");
const { data: rows } = useApi<MetricRow[]>(`/api/metrics?range=${range}`, 30000);
const { data: lag, refresh: refreshLag } = useApi<LagData>("/api/lag", 30000);
const [report, setReport] = useState<string | null>(null);
const [busyReport, setBusyReport] = useState(false);
const pts = useMemo(() => {
const make = (key: keyof MetricRow): Point[] => (rows ?? []).map((r) => ({ ts: r.bucket, v: r[key] as number | null }));
// network counters are cumulative; convert to per-second rates
const rates = (key: "net_rx" | "net_tx"): Point[] => {
const out: Point[] = [];
const data = rows ?? [];
for (let i = 1; i < data.length; i++) {
const dv = (data[i][key] ?? 0) - (data[i - 1][key] ?? 0);
const dt = (data[i].bucket - data[i - 1].bucket) / 1000;
out.push({ ts: data[i].bucket, v: dv >= 0 && dt > 0 ? dv / dt : null });
}
return out;
};
return { make, rates };
}, [rows]);
const runHealthReport = async () => {
setBusyReport(true);
try {
const d = await api<{ report: string }>("/api/lag/healthreport", { method: "POST" });
setReport(d.report);
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusyReport(false);
}
};
const verdict = VERDICTS[lag?.verdict ?? "no-data"];
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Performance &amp; Lag</h1>
<p className="mt-0.5 text-xs text-ink2">Server tick health, resource usage, and who's actually lagging</p>
</div>
<Tabs tabs={RANGES} active={range} onChange={setRange} />
</div>
{/* lag verdict */}
<Card
title="Lag diagnosis"
actions={
<Button size="sm" onClick={runHealthReport} busy={busyReport}>
<Stethoscope size={13} /> Full health report
</Button>
}
>
<div className="flex flex-wrap items-start gap-4">
<Badge color={verdict.badge}>{verdict.title}</Badge>
<p className="min-w-64 flex-1 text-xs leading-relaxed text-ink2">{verdict.hint}</p>
<div className="flex gap-4 text-center">
{[
["TPS", lag?.tps != null ? lag.tps.toFixed(1) : "—"],
["MSPT", lag?.mspt != null ? `${lag.mspt.toFixed(1)} ms` : "—"],
["CPU", lag?.cpu != null ? `${lag.cpu.toFixed(0)}%` : "—"],
].map(([label, value]) => (
<div key={label as string}>
<div className="text-[10px] uppercase tracking-wide text-muted">{label}</div>
<div className="text-lg font-semibold">{value}</div>
</div>
))}
</div>
</div>
{/* per-player connections */}
<div className="mt-4 border-t border-border pt-3">
<div className="mb-2 text-xs font-medium text-ink2">Player connections (last hour)</div>
{!lag || lag.players.length === 0 ? (
<div className="text-xs text-muted">Nobody online right now.</div>
) : (
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
{lag.players.map((p) => (
<div key={p.name} className="rounded-lg border border-border bg-surface2 p-3">
<div className="flex items-center gap-2">
<Wifi
size={13}
className={
p.verdict === "bad-connection"
? "text-crit"
: p.verdict === "weak-connection"
? "text-warn"
: "text-good"
}
/>
<span className="font-mono text-sm font-medium">{p.name}</span>
<span className="ml-auto text-sm font-semibold">
{p.ping !== null ? `${p.ping} ms` : "—"}
</span>
</div>
<div className="mt-2">
<Sparkline
points={p.history.map((h) => ({ ts: h.ts, v: h.ping }))}
color={
p.verdict === "bad-connection"
? "var(--color-crit)"
: p.verdict === "weak-connection"
? "var(--color-warn)"
: "var(--color-series2)"
}
height={28}
/>
</div>
<div className="mt-1 text-[10px] text-muted">
{p.verdict === "bad-connection"
? "Their connection is struggling — it's not the server."
: p.verdict === "weak-connection"
? "Somewhat high ping — their network, not the server."
: p.verdict === "good"
? "Healthy connection."
: "No ping data yet."}
</div>
</div>
))}
</div>
)}
</div>
</Card>
{/* charts — one measure per chart (never dual-axis) */}
{!rows || rows.length < 2 ? (
<Card>
<EmptyState
title="Collecting metrics…"
hint="The sampler stores a data point every 10 seconds while the panel runs."
/>
</Card>
) : (
<div className="grid gap-4 lg:grid-cols-2">
<Card title="Tick rate (TPS)">
<TimeSeriesChart
series={[{ name: "TPS", color: "var(--color-series2)", points: pts.make("tps") }]}
yMax={20}
height={190}
/>
</Card>
<Card title="Tick duration (MSPT)">
<TimeSeriesChart
series={[{ name: "MSPT", color: "var(--color-series3)", points: pts.make("mspt") }]}
unit=" ms"
height={190}
/>
</Card>
<Card title="CPU usage">
<TimeSeriesChart
series={[{ name: "CPU", color: "var(--color-series1)", points: pts.make("cpu") }]}
unit="%"
height={190}
/>
</Card>
<Card title="Memory">
<TimeSeriesChart
series={[{ name: "Used", color: "var(--color-series1)", points: pts.make("mem_used") }]}
height={190}
formatValue={(v) => fmtBytes(v)}
/>
</Card>
<Card title="Network throughput">
<TimeSeriesChart
series={[
{ name: "Down", color: "var(--color-series1)", points: pts.rates("net_rx") },
{ name: "Up", color: "var(--color-series2)", points: pts.rates("net_tx") },
]}
height={190}
formatValue={(v) => `${fmtBytes(v)}/s`}
/>
</Card>
<Card title="Players online">
<TimeSeriesChart
series={[{ name: "Players", color: "var(--color-series3)", points: pts.make("players") }]}
height={190}
formatValue={(v) => String(Math.round(v))}
/>
</Card>
</div>
)}
{/* spark health report modal */}
<Modal open={report !== null} onClose={() => setReport(null)} title="spark health report" wide>
<pre className="scroll-slim max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-lg bg-[#070a0e] p-3 font-mono text-[11px] leading-relaxed text-ink2">
{report}
</pre>
</Modal>
</div>
);
}
+201
View File
@@ -0,0 +1,201 @@
"use client";
import { useState } from "react";
import { Users, UserX, Wifi } from "lucide-react";
import { api, useApi, useSSE, toast } from "@/lib/api";
import { Card, Badge, Button, EmptyState, Modal } from "@/components/ui";
import { timeAgo, fmtDateTime } from "@/lib/format";
type PlayerRow = {
name: string;
uuid: string;
playTimeHours: number;
deaths: number;
mobKills: number;
playerKills: number;
distanceKm: number;
jumps: number;
damageDealt: number;
blocksMined: number;
isOnline: boolean;
ping: number | null;
authStatus: string | null;
lastSeenAt: number | null;
};
type PlayersData = { online: string[]; max: number; players: PlayerRow[] };
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
export default function PlayersPage() {
const { data, refresh } = useApi<PlayersData>("/api/players", 15000);
const { data: joins, refresh: refreshJoins } = useApi<ActivityRow[]>("/api/activity?limit=30", 20000);
const [detail, setDetail] = useState<PlayerRow | null>(null);
const [kicking, setKicking] = useState<string | null>(null);
useSSE("/api/stream/events", {
"mc-event": () => {
void refresh();
void refreshJoins();
},
});
const kick = async (name: string) => {
setKicking(name);
try {
await api("/api/console", { body: { command: `kick ${name} Kicked from the panel` } });
toast(`${name} kicked`, "warn");
setTimeout(() => void refresh(), 1000);
} catch (err) {
toast((err as Error).message, "error");
} finally {
setKicking(null);
}
};
const players = data?.players ?? [];
const online = players.filter((p) => p.isOnline);
const joinEvents = (joins ?? []).filter((j) => ["join", "leave", "connect"].includes(j.type));
const pingBadge = (ping: number | null) => {
if (ping === null) return null;
const color = ping > 250 ? "crit" : ping > 120 ? "warn" : "good";
return (
<Badge color={color}>
<Wifi size={10} /> {ping} ms
</Badge>
);
};
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold">Players</h1>
<p className="mt-0.5 text-xs text-ink2">
{online.length} online{data?.max ? ` of ${data.max} max` : ""} · {players.length} have played
</p>
</div>
<div className="grid gap-4 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<Card title="All players" pad={false}>
{players.length === 0 ? (
<EmptyState
icon={<Users size={28} />}
title="No player data yet"
hint="Stats appear after someone plays on the server."
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Player</th>
<th className="px-4 py-2 font-medium">Playtime</th>
<th className="px-4 py-2 font-medium">Deaths</th>
<th className="px-4 py-2 font-medium">Last seen</th>
<th className="px-4 py-2 text-right font-medium" />
</tr>
</thead>
<tbody>
{players.map((p) => (
<tr
key={p.uuid}
className="cursor-pointer border-b border-border/50 last:border-0 hover:bg-surface2"
onClick={() => setDetail(p)}
>
<td className="px-4 py-2.5">
<div className="flex items-center gap-2">
<span
className={`h-2 w-2 rounded-full ${p.isOnline ? "bg-good" : "bg-surface3"}`}
title={p.isOnline ? "online" : "offline"}
/>
<span className="font-mono font-medium">{p.name}</span>
{p.isOnline && pingBadge(p.ping)}
</div>
</td>
<td className="px-4 py-2.5 text-ink2">{p.playTimeHours} h</td>
<td className="px-4 py-2.5 text-ink2">{p.deaths}</td>
<td className="px-4 py-2.5 text-ink2">
{p.isOnline ? <span className="text-good">online now</span> : timeAgo(p.lastSeenAt)}
</td>
<td className="px-4 py-2.5 text-right">
{p.isOnline && (
<Button
size="sm"
variant="danger"
busy={kicking === p.name}
onClick={(e) => {
e.stopPropagation();
void kick(p.name);
}}
>
<UserX size={12} /> Kick
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
</div>
{/* join/leave feed */}
<Card title="Joins & leaves">
{joinEvents.length === 0 ? (
<EmptyState title="No connections yet" />
) : (
<ul className="space-y-2">
{joinEvents.map((e) => (
<li key={e.id} className="flex items-center gap-2 text-sm">
<Badge color={e.type === "join" ? "good" : e.type === "leave" ? "muted" : "info"}>
{e.type === "connect" ? "connecting" : e.type}
</Badge>
<span className="font-mono text-xs font-medium">{e.player}</span>
{e.type === "connect" && e.detail && (
<span className="font-mono text-[10px] text-muted">{e.detail}</span>
)}
<span className="ml-auto text-[10px] text-muted">{timeAgo(e.ts)}</span>
</li>
))}
</ul>
)}
</Card>
</div>
{/* stats drawer */}
<Modal open={detail !== null} onClose={() => setDetail(null)} title={detail?.name ?? ""} wide>
{detail && (
<div>
<div className="mb-3 flex items-center gap-2">
{detail.isOnline ? <Badge color="good">online</Badge> : <Badge color="muted">offline</Badge>}
{detail.authStatus && <Badge color="info">{detail.authStatus}</Badge>}
{detail.isOnline && detail.ping !== null && pingBadge(detail.ping)}
</div>
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
{[
["Playtime", `${detail.playTimeHours} h`],
["Deaths", detail.deaths],
["Mob kills", detail.mobKills],
["Player kills", detail.playerKills],
["Traveled", `${detail.distanceKm} km`],
["Blocks mined", detail.blocksMined.toLocaleString()],
["Jumps", detail.jumps.toLocaleString()],
["Damage dealt", detail.damageDealt.toLocaleString()],
].map(([label, value]) => (
<div key={label as string} className="rounded-lg border border-border bg-surface2 p-3">
<div className="text-[10px] uppercase tracking-wide text-muted">{label}</div>
<div className="mt-0.5 text-lg font-semibold">{value}</div>
</div>
))}
</div>
<div className="mt-3 space-y-1 text-[11px] text-muted">
<div>UUID: <code>{detail.uuid}</code></div>
<div>Last seen: {detail.isOnline ? "online now" : fmtDateTime(detail.lastSeenAt)}</div>
</div>
</div>
)}
</Modal>
</div>
);
}
@@ -0,0 +1,240 @@
"use client";
import { useState } from "react";
import { CalendarClock, Plus, Play, Trash2, Pencil } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Badge, Input, Select, Modal, EmptyState, Toggle } from "@/components/ui";
import { fmtDateTime } from "@/lib/format";
type Schedule = {
id: number;
name: string;
cron: string;
action: "restart" | "backup" | "command" | "announce";
payload: string | null;
enabled: number;
last_run_at: number | null;
last_result: string | null;
};
const ACTION_OPTIONS = [
{ value: "restart", label: "Restart server (30 s warning)" },
{ value: "backup", label: "Create backup" },
{ value: "announce", label: "In-game announcement" },
{ value: "command", label: "Console command" },
];
const PRESETS = [
{ label: "Every day at 5:00", cron: "0 5 * * *" },
{ label: "Every 6 hours", cron: "0 */6 * * *" },
{ label: "Every Sunday at 4:00", cron: "0 4 * * 0" },
{ label: "Every 30 minutes", cron: "*/30 * * * *" },
];
type Draft = { id?: number; name: string; cron: string; action: Schedule["action"]; payload: string };
const EMPTY_DRAFT: Draft = { name: "", cron: "0 5 * * *", action: "restart", payload: "" };
export default function SchedulesPage() {
const { data: schedules, refresh } = useApi<Schedule[]>("/api/schedules", 15000);
const [draft, setDraft] = useState<Draft | null>(null);
const [busy, setBusy] = useState<string | null>(null);
const run = async (label: string, body: Record<string, unknown>, message?: string) => {
setBusy(label);
try {
await api("/api/schedules", { body });
if (message) toast(message, "success");
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusy(null);
}
};
const saveDraft = async () => {
if (!draft) return;
const body =
draft.id === undefined
? { op: "create", name: draft.name, cron: draft.cron, action: draft.action, payload: draft.payload || undefined }
: { op: "update", id: draft.id, name: draft.name, cron: draft.cron, action: draft.action, payload: draft.payload };
setBusy("save");
try {
await api("/api/schedules", { body });
toast(draft.id === undefined ? "Schedule created" : "Schedule updated", "success");
setDraft(null);
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setBusy(null);
}
};
const needsPayload = draft?.action === "command" || draft?.action === "announce";
return (
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
<h1 className="text-xl font-bold">Schedules</h1>
<p className="mt-0.5 text-xs text-ink2">Automatic restarts, backups, announcements and commands (cron)</p>
</div>
<Button variant="primary" onClick={() => setDraft({ ...EMPTY_DRAFT })}>
<Plus size={14} /> New schedule
</Button>
</div>
<Card pad={false}>
{!schedules || schedules.length === 0 ? (
<EmptyState
icon={<CalendarClock size={28} />}
title="No schedules yet"
hint="A nightly restart (e.g. daily at 5:00) keeps modded servers fresh, and extra backups never hurt."
/>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
<th className="px-4 py-2 font-medium">Name</th>
<th className="px-4 py-2 font-medium">When (cron)</th>
<th className="px-4 py-2 font-medium">Action</th>
<th className="px-4 py-2 font-medium">Last run</th>
<th className="px-4 py-2 font-medium">Enabled</th>
<th className="px-4 py-2 text-right font-medium">Actions</th>
</tr>
</thead>
<tbody>
{schedules.map((s) => (
<tr key={s.id} className="border-b border-border/50 last:border-0">
<td className="px-4 py-2.5 font-medium">{s.name}</td>
<td className="px-4 py-2.5 font-mono text-xs text-ink2">{s.cron}</td>
<td className="px-4 py-2.5">
<Badge color="info">{s.action}</Badge>
{s.payload && (
<span className="ml-2 font-mono text-[11px] text-muted">
{s.payload.length > 40 ? s.payload.slice(0, 40) + "…" : s.payload}
</span>
)}
</td>
<td className="px-4 py-2.5 text-xs text-ink2">
{fmtDateTime(s.last_run_at)}
{s.last_result?.startsWith("ERROR") && (
<span className="ml-1 text-crit" title={s.last_result}>
failed
</span>
)}
</td>
<td className="px-4 py-2.5">
<Toggle
checked={s.enabled === 1}
onChange={() => void run(`toggle-${s.id}`, { op: "toggle", id: s.id })}
/>
</td>
<td className="px-4 py-2.5">
<div className="flex justify-end gap-1.5">
<Button
size="sm"
busy={busy === `run-${s.id}`}
onClick={() => void run(`run-${s.id}`, { op: "run", id: s.id }, `"${s.name}" executed`)}
title="Run now"
>
<Play size={12} />
</Button>
<Button
size="sm"
onClick={() =>
setDraft({ id: s.id, name: s.name, cron: s.cron, action: s.action, payload: s.payload ?? "" })
}
>
<Pencil size={12} />
</Button>
<Button
size="sm"
variant="danger"
busy={busy === `delete-${s.id}`}
onClick={() => void run(`delete-${s.id}`, { op: "delete", id: s.id }, `"${s.name}" deleted`)}
>
<Trash2 size={12} />
</Button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</Card>
<Modal open={draft !== null} onClose={() => setDraft(null)} title={draft?.id === undefined ? "New schedule" : "Edit schedule"}>
{draft && (
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Name</label>
<Input
value={draft.name}
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
placeholder="e.g. Nightly restart"
className="w-full"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Action</label>
<Select
value={draft.action}
onChange={(v) => setDraft({ ...draft, action: v as Schedule["action"] })}
options={ACTION_OPTIONS}
className="w-full"
/>
</div>
{needsPayload && (
<div>
<label className="mb-1 block text-xs font-medium text-ink2">
{draft.action === "announce" ? "Message" : "Command (without /)"}
</label>
<Input
value={draft.payload}
onChange={(e) => setDraft({ ...draft, payload: e.target.value })}
placeholder={draft.action === "announce" ? "Backup starting soon!" : "save-all"}
className="w-full font-mono"
/>
</div>
)}
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Cron expression</label>
<Input
value={draft.cron}
onChange={(e) => setDraft({ ...draft, cron: e.target.value })}
className="w-full font-mono"
/>
<div className="mt-1.5 flex flex-wrap gap-1.5">
{PRESETS.map((p) => (
<button
key={p.cron}
onClick={() => setDraft({ ...draft, cron: p.cron })}
className="rounded-md border border-border bg-surface2 px-2 py-0.5 text-[10px] text-ink2 hover:text-ink"
>
{p.label}
</button>
))}
</div>
<p className="mt-1 text-[10px] text-muted">minute hour day-of-month month day-of-week (server timezone)</p>
</div>
<div className="flex justify-end gap-2 pt-1">
<Button onClick={() => setDraft(null)}>Cancel</Button>
<Button
variant="primary"
busy={busy === "save"}
disabled={!draft.name.trim() || (needsPayload && !draft.payload.trim())}
onClick={saveDraft}
>
Save schedule
</Button>
</div>
</div>
)}
</Modal>
</div>
);
}
+255
View File
@@ -0,0 +1,255 @@
"use client";
import { useEffect, useState } from "react";
import { KeyRound, Webhook, Globe, AlertTriangle } from "lucide-react";
import { api, useApi, toast } from "@/lib/api";
import { Card, Button, Input, Toggle, Modal } from "@/components/ui";
import { fmtBytes } from "@/lib/format";
type Settings = {
registrationWindowMinutes: number;
notifyJoins: boolean;
discordEnabled: boolean;
discordJoins: boolean;
discordWebhookUrl: string;
};
type WorldInfo = { seed: string | null; sizeBytes: number | null; dimensions: string[]; levelName: string | null };
export default function SettingsPage() {
const { data, refresh } = useApi<Settings>("/api/settings", 0);
const { data: me, refresh: refreshMe } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me");
const { data: world } = useApi<WorldInfo>("/api/world", 0);
const [values, setValues] = useState<Settings | null>(null);
const [saving, setSaving] = useState(false);
const [currentPw, setCurrentPw] = useState("");
const [newPw, setNewPw] = useState("");
const [newPw2, setNewPw2] = useState("");
const [changing, setChanging] = useState(false);
const [resetOpen, setResetOpen] = useState(false);
const [resetConfirm, setResetConfirm] = useState("");
const [resetting, setResetting] = useState(false);
useEffect(() => {
if (data) setValues(data);
}, [data]);
const save = async () => {
if (!values) return;
setSaving(true);
try {
await api("/api/settings", { body: values });
toast("Settings saved", "success");
void refresh();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setSaving(false);
}
};
const changePassword = async () => {
if (newPw !== newPw2) {
toast("New passwords don't match", "error");
return;
}
setChanging(true);
try {
await api("/api/auth/password", { body: { current: currentPw, next: newPw } });
toast("Panel password changed", "success");
setCurrentPw("");
setNewPw("");
setNewPw2("");
void refreshMe();
} catch (err) {
toast((err as Error).message, "error");
} finally {
setChanging(false);
}
};
const resetWorld = async () => {
setResetting(true);
try {
const res = await api<{ archived: string }>("/api/world", {
body: { op: "reset", confirm: resetConfirm },
});
toast(`World reset — old world saved as ${res.archived}`, "warn");
setResetOpen(false);
setResetConfirm("");
} catch (err) {
toast((err as Error).message, "error");
} finally {
setResetting(false);
}
};
return (
<div className="space-y-4">
<div>
<h1 className="text-xl font-bold">Settings</h1>
<p className="mt-0.5 text-xs text-ink2">Panel account, authentication window, notifications, danger zone</p>
</div>
{me?.mustChange && (
<div className="flex items-center gap-2 rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm text-warn">
<AlertTriangle size={15} /> You're still on the initial admin password — change it below.
</div>
)}
<div className="grid gap-4 lg:grid-cols-2">
{/* password */}
<Card title="Panel password">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Current password</label>
<Input type="password" value={currentPw} onChange={(e) => setCurrentPw(e.target.value)} className="w-full" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="mb-1 block text-xs font-medium text-ink2">New password</label>
<Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} className="w-full" />
</div>
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Repeat new password</label>
<Input type="password" value={newPw2} onChange={(e) => setNewPw2(e.target.value)} className="w-full" />
</div>
</div>
<Button
variant="primary"
onClick={changePassword}
busy={changing}
disabled={!currentPw || newPw.length < 8 || newPw !== newPw2}
>
<KeyRound size={14} /> Change password
</Button>
<p className="text-[10px] text-muted">At least 8 characters.</p>
</div>
</Card>
{/* auth window */}
<Card title="Player registration window">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Window length (minutes)</label>
<Input
type="number"
min={0.5}
max={120}
step={0.5}
value={values?.registrationWindowMinutes ?? ""}
onChange={(e) =>
setValues((v) => (v ? { ...v, registrationWindowMinutes: Number(e.target.value) } : v))
}
className="w-32 font-mono"
/>
<p className="mt-1 text-[10px] leading-relaxed text-muted">
After you approve a username, the player has this long to join and <code>/register</code>. When
it lapses the name is automatically removed from the whitelist again.
</p>
</div>
<Toggle
checked={values?.notifyJoins ?? true}
onChange={(v) => setValues((s) => (s ? { ...s, notifyJoins: v } : s))}
label="Notify on player joins/leaves"
/>
</div>
</Card>
{/* discord */}
<Card title="Discord alerts">
<div className="space-y-3">
<div>
<label className="mb-1 block text-xs font-medium text-ink2">Webhook URL</label>
<Input
value={values?.discordWebhookUrl ?? ""}
onChange={(e) => setValues((v) => (v ? { ...v, discordWebhookUrl: e.target.value } : v))}
placeholder="https://discord.com/api/webhooks/…"
className="w-full font-mono text-xs"
/>
<p className="mt-1 text-[10px] text-muted">
Discord → Server settings → Integrations → Webhooks → New webhook → Copy URL
</p>
</div>
<Toggle
checked={values?.discordEnabled ?? true}
onChange={(v) => setValues((s) => (s ? { ...s, discordEnabled: v } : s))}
label="Send alerts to Discord"
/>
<Toggle
checked={values?.discordJoins ?? true}
onChange={(v) => setValues((s) => (s ? { ...s, discordJoins: v } : s))}
label="Include joins/leaves"
/>
<Button
size="sm"
onClick={async () => {
await save();
try {
await api("/api/settings/test-discord", { method: "POST" });
toast("Test sent — check your Discord channel", "success");
} catch (err) {
toast((err as Error).message, "error");
}
}}
>
<Webhook size={13} /> Save &amp; send test
</Button>
</div>
</Card>
{/* danger zone */}
<Card title="Danger zone">
<div className="space-y-3 text-sm">
<div className="flex items-center gap-3">
<Globe size={15} className="text-muted" />
<div className="flex-1">
<div className="font-medium">Reset world</div>
<div className="text-xs text-muted">
Current world: {world?.levelName ?? "world"} ({fmtBytes(world?.sizeBytes)}) — archived, then a
fresh world generates. A safety backup is taken first.
</div>
</div>
<Button variant="danger" size="sm" onClick={() => setResetOpen(true)}>
Reset…
</Button>
</div>
</div>
</Card>
</div>
<div className="flex justify-end">
<Button variant="primary" onClick={save} busy={saving}>
Save settings
</Button>
</div>
<Modal open={resetOpen} onClose={() => setResetOpen(false)} title="Reset the world?">
<div className="space-y-3 text-sm text-ink2">
<p>
The server stops, the current world folder is archived inside the data directory, and a brand-new
world generates on next start. A backup is taken first, but treat this as destructive.
</p>
<p className="text-xs">
Type <code className="text-crit">reset the world</code> to confirm:
</p>
<Input
value={resetConfirm}
onChange={(e) => setResetConfirm(e.target.value)}
placeholder="reset the world"
className="w-full font-mono"
/>
</div>
<div className="mt-4 flex justify-end gap-2">
<Button onClick={() => setResetOpen(false)}>Cancel</Button>
<Button variant="danger" disabled={resetConfirm !== "reset the world"} busy={resetting} onClick={resetWorld}>
Reset world
</Button>
</div>
</Modal>
</div>
);
}
+16
View File
@@ -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);
});
}
+73
View File
@@ -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");
}
});
}
+44
View File
@@ -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;
}
+17
View File
@@ -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 });
});
}
+24
View File
@@ -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) });
});
}
+32
View File
@@ -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 });
});
}
+19
View File
@@ -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);
}
}
+39
View File
@@ -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 });
});
}
+5
View File
@@ -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 });
});
}
+72
View File
@@ -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 });
});
}
+32
View File
@@ -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);
});
}
+16
View File
@@ -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");
}
});
}
+14
View File
@@ -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");
}
});
}
+46
View File
@@ -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,
})),
});
});
}
+73
View File
@@ -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 });
});
}
+59
View File
@@ -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,
});
});
}
+42
View File
@@ -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",
},
});
}
+41
View File
@@ -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 });
});
}
+55
View File
@@ -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;
}
+7
View File
@@ -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

+15
View File
@@ -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>
);
}
+76
View File
@@ -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>
);
}
+256
View File
@@ -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>
);
}
+264
View File
@@ -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>
);
}
+303
View File
@@ -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 };
+6
View File
@@ -0,0 +1,6 @@
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { boot } = await import("./server/boot");
boot();
}
}
+105
View File
@@ -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 } }));
}
+54
View File
@@ -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")}`;
}
+29
View File
@@ -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).*)"],
};
+49
View File
@@ -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");
}
}
+209
View File
@@ -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(() => {});
});
}
+98
View File
@@ -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}`);
}
}
+58
View File
@@ -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)");
}
}
+42
View File
@@ -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);
}
+32
View File
@@ -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"]);
}
+120
View File
@@ -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);
}
+296
View File
@@ -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 };
}
+35
View File
@@ -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", ""),
};
+72
View File
@@ -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;
+124
View File
@@ -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.
}
}
+93
View File
@@ -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);
}
+131
View File
@@ -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"));
}
+145
View File
@@ -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));
}
+57
View 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.
}
}
+25
View File
@@ -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;
}
+117
View File
@@ -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 };
}
+180
View File
@@ -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, "");
}
+175
View File
@@ -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;
}
+97
View File
@@ -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();
}
+72
View File
@@ -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)}`;
}
+41
View File
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": false,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+106
View File
@@ -0,0 +1,106 @@
name: minecraft
services:
# ── Minecraft server (Fabric via itzg/minecraft-server) ─────────────────
mc:
image: itzg/minecraft-server:latest
container_name: mc
restart: unless-stopped
stop_grace_period: 2m
tty: false
stdin_open: false
environment:
EULA: "TRUE"
TYPE: ${SERVER_TYPE:-FABRIC}
VERSION: ${MC_VERSION:-26.2}
MEMORY: ${MC_MEMORY:-6G}
USE_AIKAR_FLAGS: "true"
# Offline mode + whitelist as the approval gate (see docs/auth-flow.md)
ONLINE_MODE: "FALSE"
ENABLE_WHITELIST: "TRUE"
ENFORCE_WHITELIST: "TRUE"
MOTD: ${MC_MOTD:-A cozy server for friends}
DIFFICULTY: ${MC_DIFFICULTY:-normal}
MAX_PLAYERS: ${MC_MAX_PLAYERS:-10}
VIEW_DISTANCE: ${MC_VIEW_DISTANCE:-10}
SIMULATION_DISTANCE: ${MC_SIMULATION_DISTANCE:-8}
SEED: ${MC_SEED:-}
SPAWN_PROTECTION: "0"
# RCON is only reachable inside the compose network (never published)
ENABLE_RCON: "true"
RCON_PASSWORD: ${RCON_PASSWORD:?generate with scripts/setup.sh}
# Server-side mods, auto-resolved for the current MC version on every start
MODRINTH_PROJECTS: ${MODRINTH_PROJECTS:-fabric-api,easyauth,spark,lithium,ferrite-core}
MODRINTH_DOWNLOAD_DEPENDENCIES: required
# Modpack support (leave empty for lean Fabric; see docs/mods-and-modpacks.md)
MODRINTH_MODPACK: ${MODRINTH_MODPACK:-}
TZ: ${TZ:-UTC}
ports:
- "${MC_PORT:-25565}:25565"
volumes:
- ./data/mc:/data
mem_limit: ${MC_CONTAINER_MEM_LIMIT:-7g}
# ── Scheduled world backups (itzg/mc-backup) ─────────────────────────────
backup:
image: itzg/mc-backup:latest
container_name: mc-backup
restart: unless-stopped
environment:
BACKUP_INTERVAL: ${BACKUP_INTERVAL:-24h}
INITIAL_DELAY: 10m
PRUNE_BACKUPS_DAYS: ${BACKUP_RETENTION_DAYS:-7}
BACKUP_NAME: world
TAR_COMPRESS_METHOD: gzip
PAUSE_IF_NO_PLAYERS: "false"
RCON_HOST: mc
RCON_PORT: "25575"
RCON_PASSWORD: ${RCON_PASSWORD}
EXCLUDES: "*.tmp,cache,logs"
TZ: ${TZ:-UTC}
volumes:
- ./data/mc:/data:ro
- ./data/backups:/backups
depends_on:
mc:
condition: service_healthy
# ── Web management dashboard ─────────────────────────────────────────────
dashboard:
build: ./dashboard
image: minecraft-dashboard:local
container_name: mc-dashboard
restart: unless-stopped
environment:
PANEL_SESSION_SECRET: ${PANEL_SESSION_SECRET:?generate with scripts/setup.sh}
PANEL_ADMIN_PASSWORD: ${PANEL_ADMIN_PASSWORD:-}
RCON_HOST: mc
RCON_PORT: "25575"
RCON_PASSWORD: ${RCON_PASSWORD}
MC_CONTAINER: mc
BACKUP_CONTAINER: mc-backup
MC_DATA_DIR: /mc-data
BACKUPS_DIR: /backups
APP_DATA_DIR: /app-data
PROJECT_DIR: /project
REGISTRATION_WINDOW_MINUTES: ${REGISTRATION_WINDOW_MINUTES:-2}
DISCORD_WEBHOOK_URL: ${DISCORD_WEBHOOK_URL:-}
TZ: ${TZ:-UTC}
ports:
# LAN/VPN only — do not port-forward this on your router (docs/networking.md)
- "${DASHBOARD_PORT:-8080}:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
- ./data/mc:/mc-data
- ./data/backups:/backups
- ./data/dashboard:/app-data
- ./:/project
mem_limit: 512m
depends_on:
- mc
+64
View File
@@ -0,0 +1,64 @@
# Authentication flow
The server runs with `online-mode=false` so offline launchers (TLauncher, etc.) can
join. That disables Mojang's account verification, so this stack layers two gates on
top:
1. **Whitelist** (vanilla) — only approved usernames can connect at all
2. **EasyAuth** (Fabric mod) — every player must `/register` a password once, then
`/login` on every join. Unauthenticated players are frozen, invisible to the world,
and kicked after a timeout.
## The approval lifecycle
```
you approve a name ──► whitelisted + "pending" + countdown starts (default 2 min)
│
├─ player joins and /register-s in time ──► "registered" (permanent access)
│
└─ countdown lapses ──► whitelist entry removed ──► "expired"
(re-approve any time to restart the window)
```
The panel enforces this automatically: a background watcher reconciles the pending list
against EasyAuth's registered accounts every 5 seconds and revokes lapsed windows.
### Why the short window matters
While a name is whitelisted but unregistered, anyone who knows the name could join and
claim it. The window shrinks that exposure to a couple of minutes, and you control
exactly when it opens — approve while your friend is sitting in the launcher. If someone
does sneak in and register a name that isn't theirs, just **Revoke** it (removes the
account + whitelist entry) and approve again.
## In-game commands (players)
| Command | What it does |
|---|---|
| `/register <pass> <pass>` | Create your password (first join only) |
| `/login <pass>` | Log in (every join) |
| `/account` | Change password, manage sessions |
| `/logout` | Log out manually |
## Panel actions (Access & Auth page)
| Action | Effect |
|---|---|
| Approve | Whitelist + open registration window |
| Re-open window | Same, for an expired/revoked name |
| Reset password | Sets a temporary password (shown once) and kicks them to re-login |
| Revoke | Whitelist entry + EasyAuth account deleted, player kicked |
| Import whitelist | Adopt names whitelisted outside the panel |
## Storage
EasyAuth stores password hashes in its own database under
`data/mc/mods/EasyAuth/` (included in backups). The panel's approval state lives in
`data/dashboard/craftdeck.db`. The two are reconciled via RCON (`auth list`,
`auth remove`, `auth update`), so there's no fragile file coupling.
## Tuning
- **Window length**: Settings → "Player registration window" (0.5–120 minutes)
- **EasyAuth options** (kick timeout, session lifetime, password rules):
Files → `config/EasyAuth/` — edit and restart the server
+57
View File
@@ -0,0 +1,57 @@
# Backups & restore
## What runs automatically
The `backup` sidecar (itzg/mc-backup) tars the entire server data directory to
`data/backups/world-<timestamp>.tgz`:
- flushes the world to disk first (`save-all`), safe while players are online
- interval: `BACKUP_INTERVAL` in `.env` (default `24h`)
- retention: `BACKUP_RETENTION_DAYS` (default `7`) — older tars are pruned
- excludes caches and logs
Safety backups are also taken automatically before config applies (when enabled) and
world resets.
## Manual backup
- Panel: Backups page → **Back up now**
- CLI: `docker exec mc-backup backup now`
## Restoring
Panel: Backups page → **Restore** → type `restore`. The panel stops the server,
extracts the tar over the data directory, and starts the server again. Everything since
that backup is lost.
CLI fallback (panel down):
```bash
./scripts/restore.sh data/backups/world-20260710-050000.tgz
```
## Off-site copies (recommended)
`data/backups/` is just files — sync it anywhere:
```bash
# example: nightly rsync to another machine (add to the host's crontab)
0 6 * * * rsync -a --delete /path/to/minecraft/data/backups/ backup-host:/srv/mc-backups/
```
You can also download any backup from the panel (⬇ button) before risky experiments.
## Disaster recovery (new machine)
```bash
git clone <repo> minecraft && cd minecraft
./scripts/setup.sh # fresh secrets — friends keep their passwords,
# those live in the world data, not in .env
mkdir -p data/backups && cp <your-backup>.tgz data/backups/
docker compose up -d # let it initialize once
./scripts/restore.sh data/backups/<your-backup>.tgz
```
The backup contains the world, mods, configs, and the EasyAuth database, so player
registrations survive. Panel state (approval history, metrics) lives in
`data/dashboard/` — include it in your off-site sync if you want it back too.
+66
View File
@@ -0,0 +1,66 @@
# Mods & modpacks
## Why Fabric
- First loader to support new Minecraft versions (26.x drops land on Fabric within days)
- The best server-side performance ecosystem (Lithium, FerriteCore, spark)
- EasyAuth (the auth system) is Fabric-only
- Server-side-only mods are invisible to players — vanilla and TLauncher clients join
without installing anything
NeoForge/Forge are supported by the same stack (`SERVER_TYPE` in `.env`) but trail new
versions and would require replacing the auth mod, so Fabric is the default.
## The default mod set
| Mod | Job | Players need it? |
|---|---|---|
| fabric-api | required by most Fabric mods | no |
| easyauth | password authentication | no |
| spark | TPS/ping profiling (powers the Lag page) | no |
| lithium | game-logic optimization | no |
| ferrite-core | memory-usage reduction | no |
## Adding mods
**Panel way (recommended):** Mods page → search Modrinth → **Add** → **Apply changes**.
The search is pre-filtered to Fabric mods compatible with your exact server version.
Watch the **"needs client install"** badge — server-side mods are safe for everyone;
client-required mods mean every friend must install the same mod or they can't join.
**Manual way:** drop a `.jar` into `data/mc/mods/` (Files page → mods → Upload) and
restart the server.
## How auto-update works
Managed mods are pinned by *project slug*, not version, in `.env`:
```
MODRINTH_PROJECTS=fabric-api,easyauth,spark,lithium,ferrite-core
```
Every time the server container (re)starts, the itzg image resolves each slug to the
**newest release compatible with your Minecraft version** and downloads it. So:
- "Update all mods" = restart the server (Mods page → Update all & restart)
- Upgrading Minecraft automatically brings the matching mod builds with it
Manually uploaded jars are yours to update — the resolver doesn't touch them.
## Switching to a modpack later
1. Pick a **Modrinth** modpack (e.g. Adrenaline, Simply Optimized) and note its slug
2. Configuration page → set `MODRINTH_MODPACK` to the slug → Save → Apply
(take the safety backup — modpacks change world generation!)
3. Every player installs the same modpack client-side (Modrinth App → install pack →
play). Offline-launcher users need a launcher that supports Modrinth packs, or
manual mod installation.
To go back: clear `MODRINTH_MODPACK`, Apply. Keep `MODRINTH_PROJECTS` as your
server-side base either way. CurseForge packs also work (`CF_SLUG` env — needs a
CurseForge API key; see the itzg image docs), but Modrinth is smoother.
## Removing a mod
Mods page → managed list → 🗑 (or delete the jar) → Apply. `fabric-api` and `easyauth`
are locked — the auth system depends on them.
+56
View File
@@ -0,0 +1,56 @@
# Networking
## Ports
| Port | Service | Exposure |
|---|---|---|
| 25565/tcp | Minecraft | Internet (port-forward this on your router) |
| 8080/tcp | Dashboard | **LAN/VPN only — never port-forward** |
| 25575/tcp | RCON | Docker-internal only (not published at all) |
## Letting friends connect
1. Give the server machine a static LAN IP (router → DHCP reservation)
2. Router → port forwarding → external `25565` → `<server-lan-ip>:25565`
3. Friends connect to your public IP (`curl ifconfig.me`), or set up free dynamic DNS
(DuckDNS et al.) so they can use a stable name like `yourname.duckdns.org`
Whitelist + EasyAuth handle the "random people found my server" problem — that's what
they're for.
## Keeping the dashboard private
The dashboard is deliberately not hardened for the open internet (it controls Docker).
Reach it from outside your LAN with **Tailscale** (free, ~5 minutes):
```bash
# on the server
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
```
Install Tailscale on your phone/laptop, log into the same account, and the panel is at
`http://<server-tailscale-ip>:8080` from anywhere — encrypted, invisible to everyone
else. (WireGuard works just as well if you prefer to self-host the VPN.)
Optionally pin the port binding to specific interfaces in `.env`/compose, and firewall
the host:
```bash
sudo ufw allow 25565/tcp
sudo ufw allow from 192.168.0.0/16 to any port 8080 proto tcp # LAN only
sudo ufw enable
```
## If you later want the panel on a domain
Put Caddy (or Traefik/nginx) in front for TLS + an extra auth layer, and keep 8080
unpublished. This repo doesn't ship that by default — open an issue with your domain
setup and add a `caddy` service; the panel works fine behind a reverse proxy
(plain HTTP upstream, cookie auth).
## "Is it my internet or the server?"
Performance & Lag page. Server-side lag shows as low TPS / high MSPT (affects everyone
equally); player-side lag shows as high ping for that player only. The page gives a
plain-English verdict and per-player ping history so you can settle the argument.
+72
View File
@@ -0,0 +1,72 @@
# Troubleshooting
## Server won't start
**Check the Console page first** (or `docker logs mc --tail 100`).
| Symptom in logs | Fix |
|---|---|
| `EULA` complaints | `EULA=TRUE` is set by compose — make sure you didn't override it |
| A mod failed to resolve for your version | Remove that slug on the Mods page, Apply. (New MC version + mod hasn't updated yet.) |
| `OutOfMemoryError` | Lower `MC_VIEW_DISTANCE`, or raise `MC_MEMORY`/`MC_CONTAINER_MEM_LIMIT` if the host has headroom |
| Port already in use | Something else on 25565 — change `MC_PORT` in `.env` |
| Crash right after "Done" | A mod conflict — disable recently added jars (Mods page → power icon) |
## A friend can't join
Work down this list:
1. **"Not whitelisted"** → their username isn't approved (or the window expired and
auto-revoked them). Access & Auth → approve/re-open. Username must match **exactly**
(case matters in TLauncher).
2. **Joins but immediately kicked** → they didn't `/register` or `/login` in time —
EasyAuth kicks unauthenticated players after a timeout. Tell them to type faster or
raise the timeout in `config/EasyAuth/`.
3. **"Connection timed out"** → networking: is 25565 port-forwarded? Are they using
your *public* IP? Does `docker compose ps` show `mc` healthy?
4. **Wrong password loop** → Access & Auth → **Reset password**, share the temporary
one privately.
## Panel shows "Offline" but the server is up
The dashboard container can't reach the Docker socket or RCON:
```bash
docker compose ps # is mc-dashboard running?
docker logs mc-dashboard --tail 50
docker compose up -d --force-recreate dashboard
```
If you changed `RCON_PASSWORD` in `.env`, Apply/recreate **both** `mc` and `dashboard`
so they agree.
## Forgot the panel password
```bash
docker compose stop dashboard
rm data/dashboard/craftdeck.db # wipes panel users/settings/history — NOT the world
# set PANEL_ADMIN_PASSWORD in .env to a fresh value
docker compose up -d dashboard
```
Player approvals are re-importable afterwards (Access & Auth → Import whitelist).
## Lag
Performance & Lag page tells you *who* is lagging (server vs a player's connection).
If it's the server: check the CPU/memory charts, run the spark health report, lower
view/simulation distance, or add more RAM. If it's one player: it's their network —
nothing to fix server-side.
## Disk filling up
Backups page shows total backup size; lower `BACKUP_RETENTION_DAYS`. The panel also
raises a low-disk alert below 2 GB free.
## Nuke everything and start over (keeps nothing)
```bash
docker compose down
rm -rf data
./scripts/setup.sh && docker compose up -d
```
+39
View File
@@ -0,0 +1,39 @@
# Updating
## Upgrading Minecraft (e.g. 26.2 → 26.3)
1. **Configuration** page → change `Minecraft version` → **Save**
2. **Apply** with *"Take a safety backup first"* enabled
3. The server container is recreated: the new server jar, a matching Fabric loader, and
fresh builds of every managed mod are downloaded automatically
If a managed mod has no build for the new version yet, the server won't start — check
the Console page for which one, remove it from the Mods page (or wait a few days), and
Apply again. Worlds upgrade forward automatically; **there is no downgrade** — that's
what the safety backup is for.
Command-line equivalent:
```bash
sed -i 's/^MC_VERSION=.*/MC_VERSION=26.3/' .env
docker exec mc-backup backup now
docker compose up -d mc
```
## Updating mods only
Mods page → **Update all & restart**. (Any server restart re-resolves managed mods.)
## Updating the panel / images
```bash
git pull
docker compose pull # new itzg server + backup images
docker compose build dashboard
docker compose up -d
```
## Snapshots / release candidates
`MC_VERSION` accepts any version the launcher knows (`26.3-rc-1`, `26.3-snapshot-3`…).
Mods rarely support snapshots — expect to trim the mod list if you try them.
+25
View File
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Manual backup restore (fallback for when the dashboard is unavailable).
# The dashboard's Backups page does this same procedure with one click.
#
# Usage: ./scripts/restore.sh data/backups/world-20260710-120000.tgz
set -euo pipefail
cd "$(dirname "$0")/.."
archive="${1:-}"
[[ -n "$archive" && -f "$archive" ]] || {
echo "Usage: $0 <backup .tgz file>"
echo "Available backups:"
ls -1h data/backups/*.tgz 2>/dev/null || echo " (none)"
exit 1
}
echo "This will STOP the server and overwrite the current world with:"
echo " $archive"
read -r -p "Type 'restore' to continue: " confirm
[[ "$confirm" == "restore" ]] || { echo "Aborted."; exit 1; }
docker compose stop mc
tar -xzf "$archive" -C data/mc
docker compose start mc
echo "✔ Restore complete — server starting."
+35
View File
@@ -0,0 +1,35 @@
#!/usr/bin/env bash
# One-time setup: creates .env with generated secrets, prepares data dirs.
# Usage: ./scripts/setup.sh
set -euo pipefail
cd "$(dirname "$0")/.."
if [[ -f .env ]]; then
echo ".env already exists — leaving it untouched."
echo "Delete it first if you really want to regenerate secrets."
exit 1
fi
command -v openssl >/dev/null || { echo "openssl is required"; exit 1; }
rcon_pw="$(openssl rand -hex 24)"
session_secret="$(openssl rand -hex 32)"
admin_pw="$(openssl rand -base64 12 | tr -d '/+=' | cut -c1-16)"
sed -e "s|^RCON_PASSWORD=.*|RCON_PASSWORD=${rcon_pw}|" \
-e "s|^PANEL_SESSION_SECRET=.*|PANEL_SESSION_SECRET=${session_secret}|" \
-e "s|^PANEL_ADMIN_PASSWORD=.*|PANEL_ADMIN_PASSWORD=${admin_pw}|" \
.env.example > .env
mkdir -p data/mc data/backups data/dashboard
echo
echo " ✔ .env created with generated secrets"
echo " ✔ data directories prepared"
echo
echo " Dashboard login (you must change this password on first login):"
echo " username: admin"
echo " password: ${admin_pw}"
echo
echo " Next: docker compose up -d"
echo " Panel: http://<server-ip>:8080 Minecraft: <server-ip>:25565"