Minecraft 26.2 Fabric server stack with CraftDeck management panel
- docker-compose: itzg/minecraft-server (Fabric, offline mode, whitelist enforced, Modrinth-managed mods), itzg/mc-backup sidecar, custom dashboard - Gated auth for offline mode: approve username -> whitelisted with a short registration window -> EasyAuth /register + /login; auto-revoke on expiry - CraftDeck panel (Next.js 16, zero native deps): live console (SSE), players & stats, lag diagnostics (server TPS vs player ping via spark), performance graphs, Modrinth mod search/manager, backups & restore, sandboxed file browser/editor, .env config editor with compose apply, cron schedules, Discord alerts, scrypt-hashed panel login - scripts/setup.sh generates secrets; docs/ covers auth, mods, updating, backups, networking (Tailscale), troubleshooting Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
commit
279d137ce5
98 files changed
+9925
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": "0.0.1",
|
||||||
|
"configurations": [
|
||||||
|
{
|
||||||
|
"name": "dashboard",
|
||||||
|
"runtimeExecutable": "sh",
|
||||||
|
"runtimeArgs": ["-c", "cd dashboard && npm run dev"],
|
||||||
|
"port": 3000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
@@ -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"]
|
||||||
@@ -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;
|
||||||
Generated
+2038
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
Whitespace-only changes.
@@ -0,0 +1,289 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ShieldCheck, UserPlus, KeyRound, RefreshCw, Import, Copy } from "lucide-react";
|
||||||
|
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||||
|
import { Card, Button, Badge, Input, Modal, EmptyState } from "@/components/ui";
|
||||||
|
import { timeAgo, fmtCountdown, fmtDateTime } from "@/lib/format";
|
||||||
|
|
||||||
|
type ApprovedPlayer = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
status: "pending" | "registered" | "expired" | "revoked";
|
||||||
|
approved_at: number | null;
|
||||||
|
window_expires_at: number | null;
|
||||||
|
registered_at: number | null;
|
||||||
|
last_seen_at: number | null;
|
||||||
|
note: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AccessData = {
|
||||||
|
approved: ApprovedPlayer[];
|
||||||
|
whitelist: string[] | null;
|
||||||
|
registered: string[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BADGE: Record<ApprovedPlayer["status"], { color: "good" | "warn" | "crit" | "muted"; label: string }> = {
|
||||||
|
pending: { color: "warn", label: "waiting to register" },
|
||||||
|
registered: { color: "good", label: "registered" },
|
||||||
|
expired: { color: "muted", label: "window expired" },
|
||||||
|
revoked: { color: "crit", label: "revoked" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function AccessPage() {
|
||||||
|
const { data, error, refresh } = useApi<AccessData>("/api/approved", 10000);
|
||||||
|
const [newName, setNewName] = useState("");
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [tempPassword, setTempPassword] = useState<{ user: string; password: string } | null>(null);
|
||||||
|
const [confirmRevoke, setConfirmRevoke] = useState<string | null>(null);
|
||||||
|
const [, tick] = useState(0);
|
||||||
|
|
||||||
|
// live countdown for pending windows
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setInterval(() => tick((x) => x + 1), 1000);
|
||||||
|
return () => clearInterval(t);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useSSE("/api/stream/events", {
|
||||||
|
"mc-event": () => void refresh(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const run = async (label: string, body: Record<string, unknown>, done?: (d: unknown) => void) => {
|
||||||
|
setBusy(label);
|
||||||
|
try {
|
||||||
|
const d = await api("/api/approved", { body });
|
||||||
|
done?.(d);
|
||||||
|
void refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const approve = () => {
|
||||||
|
const name = newName.trim();
|
||||||
|
if (!name) return;
|
||||||
|
void run("approve", { op: "approve", username: name }, () => {
|
||||||
|
toast(`${name} approved — the registration window is open`, "success");
|
||||||
|
setNewName("");
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const players = data?.approved ?? [];
|
||||||
|
const outsideWhitelist = (data?.whitelist ?? []).filter(
|
||||||
|
(w) => !players.some((p) => p.username.toLowerCase() === w.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Access & Auth</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink2">
|
||||||
|
Only approved usernames can join. New players must <code>/register</code> within the window, then{" "}
|
||||||
|
<code>/login</code> on every visit.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* approve box */}
|
||||||
|
<Card>
|
||||||
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">Minecraft username to approve</label>
|
||||||
|
<Input
|
||||||
|
value={newName}
|
||||||
|
onChange={(e) => setNewName(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && approve()}
|
||||||
|
placeholder="e.g. Steve_123"
|
||||||
|
className="w-full font-mono"
|
||||||
|
maxLength={16}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={approve} busy={busy === "approve"}>
|
||||||
|
<UserPlus size={14} /> Approve & open window
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] leading-relaxed text-muted">
|
||||||
|
Approving whitelists the name and starts the registration countdown. Tell your friend to join{" "}
|
||||||
|
<em>right away</em> and run <code>/register <password> <password></code>. If the timer
|
||||||
|
expires first, the name is automatically removed from the whitelist.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm text-warn">{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* player table */}
|
||||||
|
<Card
|
||||||
|
title={`Approved players (${players.length})`}
|
||||||
|
actions={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
void run("import", { op: "import-whitelist" }, (d) => {
|
||||||
|
const n = (d as { imported: number }).imported;
|
||||||
|
toast(n > 0 ? `Imported ${n} whitelist entr${n === 1 ? "y" : "ies"}` : "Nothing new to import", "info");
|
||||||
|
})
|
||||||
|
}
|
||||||
|
busy={busy === "import"}
|
||||||
|
title="Pull in players whitelisted outside the panel"
|
||||||
|
>
|
||||||
|
<Import size={13} /> Import whitelist
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
pad={false}
|
||||||
|
>
|
||||||
|
{players.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<ShieldCheck size={28} />}
|
||||||
|
title="Nobody approved yet"
|
||||||
|
hint="Approve your friends' usernames above. Nobody else can join the server."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
|
||||||
|
<th className="px-4 py-2 font-medium">Player</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Status</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Last seen</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Registered</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{players.map((p) => {
|
||||||
|
const badge = STATUS_BADGE[p.status];
|
||||||
|
return (
|
||||||
|
<tr key={p.id} className="border-b border-border/50 last:border-0">
|
||||||
|
<td className="px-4 py-2.5 font-mono font-medium">{p.username}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Badge color={badge.color}>{badge.label}</Badge>
|
||||||
|
{p.status === "pending" && p.window_expires_at && (
|
||||||
|
<span className="ml-2 font-mono text-xs text-warn">
|
||||||
|
{fmtCountdown(p.window_expires_at)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{p.note && <span className="ml-2 text-[11px] text-muted">({p.note})</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink2">{timeAgo(p.last_seen_at)}</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink2">{fmtDateTime(p.registered_at)}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
{(p.status === "expired" || p.status === "revoked") && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
void run(`reopen-${p.username}`, { op: "reopen", username: p.username }, () =>
|
||||||
|
toast(`Window re-opened for ${p.username}`, "success")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
busy={busy === `reopen-${p.username}`}
|
||||||
|
>
|
||||||
|
<RefreshCw size={12} /> Re-open window
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
{p.status === "registered" && (
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
void run(
|
||||||
|
`reset-${p.username}`,
|
||||||
|
{ op: "reset-password", username: p.username },
|
||||||
|
(d) =>
|
||||||
|
setTempPassword({
|
||||||
|
user: p.username,
|
||||||
|
password: (d as { tempPassword: string }).tempPassword,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
|
busy={busy === `reset-${p.username}`}
|
||||||
|
title="Set a temporary password and share it with them"
|
||||||
|
>
|
||||||
|
<KeyRound size={12} /> Reset password
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{p.status !== "revoked" && (
|
||||||
|
<Button size="sm" variant="danger" onClick={() => setConfirmRevoke(p.username)}>
|
||||||
|
Revoke
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* whitelist drift warning */}
|
||||||
|
{outsideWhitelist.length > 0 && (
|
||||||
|
<Card title="On the whitelist but not managed here">
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
{outsideWhitelist.map((w) => (
|
||||||
|
<Badge key={w} color="warn">
|
||||||
|
{w}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
<span className="text-xs text-muted">— use “Import whitelist” to manage them from the panel.</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* temp password modal */}
|
||||||
|
<Modal
|
||||||
|
open={tempPassword !== null}
|
||||||
|
onClose={() => setTempPassword(null)}
|
||||||
|
title={`Temporary password for ${tempPassword?.user}`}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-ink2">
|
||||||
|
Share this privately. They log in with it and can change it in game via{" "}
|
||||||
|
<code>/account</code>.
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 flex items-center gap-2 rounded-lg border border-border bg-surface2 px-3 py-2">
|
||||||
|
<code className="flex-1 font-mono text-lg tracking-wide text-accent">{tempPassword?.password}</code>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
void navigator.clipboard.writeText(tempPassword?.password ?? "");
|
||||||
|
toast("Copied to clipboard", "success");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Copy size={12} /> Copy
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="mt-3 text-[11px] text-muted">
|
||||||
|
This password is shown once and never stored by the panel.
|
||||||
|
</p>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* revoke confirm modal */}
|
||||||
|
<Modal open={confirmRevoke !== null} onClose={() => setConfirmRevoke(null)} title={`Revoke ${confirmRevoke}?`}>
|
||||||
|
<p className="text-sm text-ink2">
|
||||||
|
This removes them from the whitelist, deletes their password account, and kicks them if online. You
|
||||||
|
can re-approve them later.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button onClick={() => setConfirmRevoke(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
busy={busy === `revoke-${confirmRevoke}`}
|
||||||
|
onClick={() => {
|
||||||
|
const name = confirmRevoke!;
|
||||||
|
setConfirmRevoke(null);
|
||||||
|
void run(`revoke-${name}`, { op: "revoke", username: name }, () =>
|
||||||
|
toast(`${name} revoked`, "warn")
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Revoke access
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Archive, Download, Trash2, History, PlayCircle } from "lucide-react";
|
||||||
|
import { api, useApi, toast } from "@/lib/api";
|
||||||
|
import { Card, Button, Input, EmptyState, Modal } from "@/components/ui";
|
||||||
|
import { fmtBytes, fmtDateTime, timeAgo } from "@/lib/format";
|
||||||
|
|
||||||
|
type BackupsData = {
|
||||||
|
backups: Array<{ name: string; size: number; mtime: number }>;
|
||||||
|
totalSize: number;
|
||||||
|
interval: string;
|
||||||
|
retentionDays: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function BackupsPage() {
|
||||||
|
const { data, refresh } = useApi<BackupsData>("/api/backups", 30000);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [deleting, setDeleting] = useState<string | null>(null);
|
||||||
|
const [restoreTarget, setRestoreTarget] = useState<string | null>(null);
|
||||||
|
const [restoreConfirm, setRestoreConfirm] = useState("");
|
||||||
|
const [restoring, setRestoring] = useState(false);
|
||||||
|
|
||||||
|
const backupNow = async () => {
|
||||||
|
setCreating(true);
|
||||||
|
toast("Backup started — this can take a minute on a big world", "info");
|
||||||
|
try {
|
||||||
|
const res = await api<{ created: string }>("/api/backups", { method: "POST" });
|
||||||
|
toast(`Backup created: ${res.created}`, "success");
|
||||||
|
void refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const remove = async (name: string) => {
|
||||||
|
setDeleting(name);
|
||||||
|
try {
|
||||||
|
await api(`/api/backups/file?name=${encodeURIComponent(name)}`, { method: "DELETE" });
|
||||||
|
toast(`${name} deleted`, "warn");
|
||||||
|
void refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setDeleting(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restore = async () => {
|
||||||
|
if (!restoreTarget) return;
|
||||||
|
setRestoring(true);
|
||||||
|
try {
|
||||||
|
await api("/api/backups/restore", { body: { name: restoreTarget, confirm: restoreConfirm } });
|
||||||
|
toast("Restore complete — the server is starting with the restored world", "success");
|
||||||
|
setRestoreTarget(null);
|
||||||
|
setRestoreConfirm("");
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setRestoring(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Backups</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink2">
|
||||||
|
Automatic every {data?.interval ?? "24h"}, kept {data?.retentionDays ?? "7"} days ·{" "}
|
||||||
|
{fmtBytes(data?.totalSize)} total — change in{" "}
|
||||||
|
<a href="/config" className="text-accent underline">
|
||||||
|
Configuration
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={backupNow} busy={creating}>
|
||||||
|
<Archive size={14} /> Back up now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card pad={false}>
|
||||||
|
{!data || data.backups.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Archive size={28} />}
|
||||||
|
title="No backups yet"
|
||||||
|
hint='The backup sidecar creates the first one automatically shortly after the server starts, or press "Back up now".'
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
|
||||||
|
<th className="px-4 py-2 font-medium">Backup</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Created</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Size</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.backups.map((b) => (
|
||||||
|
<tr key={b.name} className="border-b border-border/50 last:border-0">
|
||||||
|
<td className="px-4 py-2.5 font-mono text-xs font-medium">{b.name}</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink2" title={fmtDateTime(b.mtime)}>
|
||||||
|
{timeAgo(b.mtime)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink2">{fmtBytes(b.size)}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<a href={`/api/backups/file?name=${encodeURIComponent(b.name)}`}>
|
||||||
|
<Button size="sm" title="Download">
|
||||||
|
<Download size={12} />
|
||||||
|
</Button>
|
||||||
|
</a>
|
||||||
|
<Button size="sm" variant="warn" onClick={() => setRestoreTarget(b.name)} title="Restore">
|
||||||
|
<History size={12} /> Restore
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
busy={deleting === b.name}
|
||||||
|
onClick={() => void remove(b.name)}
|
||||||
|
title="Delete"
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
open={restoreTarget !== null}
|
||||||
|
onClose={() => {
|
||||||
|
setRestoreTarget(null);
|
||||||
|
setRestoreConfirm("");
|
||||||
|
}}
|
||||||
|
title="Restore backup"
|
||||||
|
>
|
||||||
|
<div className="space-y-3 text-sm text-ink2">
|
||||||
|
<p>
|
||||||
|
Restoring <code className="text-ink">{restoreTarget}</code> will:
|
||||||
|
</p>
|
||||||
|
<ol className="list-decimal space-y-1 pl-5 text-xs">
|
||||||
|
<li>Stop the server (players are disconnected)</li>
|
||||||
|
<li>
|
||||||
|
Overwrite the current world and settings with the backup —{" "}
|
||||||
|
<span className="text-warn">everything since the backup is lost</span>
|
||||||
|
</li>
|
||||||
|
<li>Start the server again</li>
|
||||||
|
</ol>
|
||||||
|
<p className="text-xs">
|
||||||
|
Type <code className="text-warn">restore</code> to confirm:
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
value={restoreConfirm}
|
||||||
|
onChange={(e) => setRestoreConfirm(e.target.value)}
|
||||||
|
placeholder="restore"
|
||||||
|
className="w-full font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button onClick={() => setRestoreTarget(null)}>Cancel</Button>
|
||||||
|
<Button variant="danger" disabled={restoreConfirm !== "restore"} busy={restoring} onClick={restore}>
|
||||||
|
<PlayCircle size={13} /> Restore this backup
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Rocket, AlertTriangle } from "lucide-react";
|
||||||
|
import { api, useApi, toast } from "@/lib/api";
|
||||||
|
import { Card, Button, Input, Modal, Toggle } from "@/components/ui";
|
||||||
|
|
||||||
|
type ConfigValues = Record<string, string>;
|
||||||
|
|
||||||
|
const FIELDS: Array<{
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
hint?: string;
|
||||||
|
section: "server" | "gameplay" | "resources" | "backups";
|
||||||
|
placeholder?: string;
|
||||||
|
}> = [
|
||||||
|
{ key: "MC_VERSION", label: "Minecraft version", hint: "e.g. 26.2 — change, then Apply to upgrade", section: "server" },
|
||||||
|
{ key: "SERVER_TYPE", label: "Server type / loader", hint: "FABRIC (default), NEOFORGE, FORGE, VANILLA…", section: "server" },
|
||||||
|
{ key: "MC_MOTD", label: "MOTD (server list text)", section: "server" },
|
||||||
|
{ key: "MC_PORT", label: "Game port", section: "server" },
|
||||||
|
{ key: "MODRINTH_MODPACK", label: "Modrinth modpack slug", hint: "leave empty for plain Fabric — see docs before using", section: "server" },
|
||||||
|
{ key: "MC_DIFFICULTY", label: "Difficulty", hint: "peaceful / easy / normal / hard", section: "gameplay" },
|
||||||
|
{ key: "MC_MAX_PLAYERS", label: "Max players", section: "gameplay" },
|
||||||
|
{ key: "MC_VIEW_DISTANCE", label: "View distance", hint: "chunks — lower = less RAM/CPU", section: "gameplay" },
|
||||||
|
{ key: "MC_SIMULATION_DISTANCE", label: "Simulation distance", section: "gameplay" },
|
||||||
|
{ key: "MC_SEED", label: "World seed", hint: "only applies to newly generated worlds", section: "gameplay" },
|
||||||
|
{ key: "MC_MEMORY", label: "Java heap", hint: "e.g. 6G — leave ~1 GB headroom below the container limit", section: "resources" },
|
||||||
|
{ key: "MC_CONTAINER_MEM_LIMIT", label: "Container memory limit", hint: "e.g. 7g", section: "resources" },
|
||||||
|
{ key: "TZ", label: "Timezone", hint: "e.g. Asia/Kolkata — used by schedules and backups", section: "resources" },
|
||||||
|
{ key: "BACKUP_INTERVAL", label: "Backup interval", hint: "e.g. 6h, 24h", section: "backups" },
|
||||||
|
{ key: "BACKUP_RETENTION_DAYS", label: "Keep backups (days)", section: "backups" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: "server", title: "Server" },
|
||||||
|
{ id: "gameplay", title: "Gameplay" },
|
||||||
|
{ id: "resources", title: "Resources & time" },
|
||||||
|
{ id: "backups", title: "Backups" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export default function ConfigPage() {
|
||||||
|
const { data, refresh } = useApi<ConfigValues>("/api/config", 0);
|
||||||
|
const [values, setValues] = useState<ConfigValues>({});
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [applyOpen, setApplyOpen] = useState(false);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [backupFirst, setBackupFirst] = useState(true);
|
||||||
|
const [dirty, setDirty] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) setValues(data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const set = (key: string, value: string) => {
|
||||||
|
setValues((v) => ({ ...v, [key]: value }));
|
||||||
|
setDirty(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const updates: ConfigValues = {};
|
||||||
|
for (const f of FIELDS) updates[f.key] = values[f.key] ?? "";
|
||||||
|
await api("/api/config", { body: { updates } });
|
||||||
|
toast("Saved to .env — apply to make it live", "success");
|
||||||
|
setDirty(false);
|
||||||
|
void refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
setApplying(true);
|
||||||
|
try {
|
||||||
|
await api("/api/config/apply", { body: { service: "mc", backupFirst } });
|
||||||
|
await api("/api/config/apply", { body: { service: "backup" } });
|
||||||
|
toast("Applied — containers recreated with the new configuration", "success");
|
||||||
|
setApplyOpen(false);
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const versionChanged = data && values["MC_VERSION"] !== data["MC_VERSION"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Configuration</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink2">
|
||||||
|
Values live in <code>.env</code>. Save writes the file; Apply recreates the server so they take
|
||||||
|
effect. Raw files (server.properties, EasyAuth) are editable in{" "}
|
||||||
|
<a href="/files" className="text-accent underline">
|
||||||
|
Files
|
||||||
|
</a>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button onClick={save} busy={saving} disabled={!dirty}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button variant="primary" onClick={() => setApplyOpen(true)} disabled={dirty}>
|
||||||
|
<Rocket size={14} /> Apply (restarts server)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{versionChanged && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm text-warn">
|
||||||
|
<AlertTriangle size={15} />
|
||||||
|
Version change detected ({data?.["MC_VERSION"]} → {values["MC_VERSION"]}). Save, then Apply with the
|
||||||
|
safety backup enabled. Mods are automatically re-resolved for the new version.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{SECTIONS.map((section) => (
|
||||||
|
<Card key={section.id} title={section.title}>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{FIELDS.filter((f) => f.section === section.id).map((f) => (
|
||||||
|
<div key={f.key}>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">{f.label}</label>
|
||||||
|
<Input
|
||||||
|
value={values[f.key] ?? ""}
|
||||||
|
onChange={(e) => set(f.key, e.target.value)}
|
||||||
|
className="w-full font-mono"
|
||||||
|
placeholder={f.placeholder}
|
||||||
|
/>
|
||||||
|
{f.hint && <p className="mt-1 text-[10px] text-muted">{f.hint}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal open={applyOpen} onClose={() => setApplyOpen(false)} title="Apply configuration?">
|
||||||
|
<div className="space-y-3 text-sm text-ink2">
|
||||||
|
<p>
|
||||||
|
The server (and backup sidecar) containers are recreated with the saved <code>.env</code>. Players
|
||||||
|
online are disconnected while the server restarts.
|
||||||
|
</p>
|
||||||
|
<Toggle checked={backupFirst} onChange={setBackupFirst} label="Take a safety backup first (recommended)" />
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button onClick={() => setApplyOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="primary" busy={applying} onClick={apply}>
|
||||||
|
Apply now
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { SendHorizonal, ArrowDownToLine, Eraser } from "lucide-react";
|
||||||
|
import { api, useSSE, toast } from "@/lib/api";
|
||||||
|
import { Button, Input } from "@/components/ui";
|
||||||
|
|
||||||
|
const QUICK_COMMANDS = [
|
||||||
|
"list",
|
||||||
|
"whitelist list",
|
||||||
|
"auth list",
|
||||||
|
"spark tps",
|
||||||
|
"save-all",
|
||||||
|
"time set day",
|
||||||
|
"weather clear",
|
||||||
|
];
|
||||||
|
|
||||||
|
function colorFor(line: string): string {
|
||||||
|
if (/ERROR|FATAL|Exception|Crash/i.test(line)) return "text-crit";
|
||||||
|
if (/WARN/i.test(line)) return "text-warn";
|
||||||
|
if (/joined the game|registered|Done \(/.test(line)) return "text-good";
|
||||||
|
if (line.startsWith("> ")) return "text-accent";
|
||||||
|
return "text-ink2";
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConsolePage() {
|
||||||
|
const [lines, setLines] = useState<string[]>([]);
|
||||||
|
const [command, setCommand] = useState("");
|
||||||
|
const [history, setHistory] = useState<string[]>([]);
|
||||||
|
const [histIdx, setHistIdx] = useState(-1);
|
||||||
|
const [filter, setFilter] = useState("");
|
||||||
|
const [follow, setFollow] = useState(true);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
const paneRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useSSE("/api/stream/logs", {
|
||||||
|
message: (line) => {
|
||||||
|
setLines((xs) => {
|
||||||
|
const next = [...xs, line as string];
|
||||||
|
return next.length > 1500 ? next.slice(-1200) : next;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (follow && paneRef.current) {
|
||||||
|
paneRef.current.scrollTop = paneRef.current.scrollHeight;
|
||||||
|
}
|
||||||
|
}, [lines, follow]);
|
||||||
|
|
||||||
|
const onScroll = () => {
|
||||||
|
const el = paneRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 60;
|
||||||
|
setFollow(atBottom);
|
||||||
|
};
|
||||||
|
|
||||||
|
const send = async (cmd?: string) => {
|
||||||
|
const c = (cmd ?? command).trim();
|
||||||
|
if (!c) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await api("/api/console", { body: { command: c } });
|
||||||
|
setHistory((h) => [c, ...h.filter((x) => x !== c)].slice(0, 50));
|
||||||
|
setHistIdx(-1);
|
||||||
|
setCommand("");
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||||
|
if (e.key === "Enter") {
|
||||||
|
e.preventDefault();
|
||||||
|
void send();
|
||||||
|
} else if (e.key === "ArrowUp") {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = Math.min(histIdx + 1, history.length - 1);
|
||||||
|
if (history[next]) {
|
||||||
|
setHistIdx(next);
|
||||||
|
setCommand(history[next]);
|
||||||
|
}
|
||||||
|
} else if (e.key === "ArrowDown") {
|
||||||
|
e.preventDefault();
|
||||||
|
const next = histIdx - 1;
|
||||||
|
setHistIdx(next);
|
||||||
|
setCommand(next >= 0 ? history[next] : "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const visible = filter ? lines.filter((l) => l.toLowerCase().includes(filter.toLowerCase())) : lines;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-[calc(100vh-7.5rem)] flex-col gap-3">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Console</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink2">Live server log with RCON command input</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
placeholder="Filter lines…"
|
||||||
|
value={filter}
|
||||||
|
onChange={(e) => setFilter(e.target.value)}
|
||||||
|
className="w-48"
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={() => setLines([])}>
|
||||||
|
<Eraser size={13} /> Clear
|
||||||
|
</Button>
|
||||||
|
{!follow && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
onClick={() => {
|
||||||
|
setFollow(true);
|
||||||
|
paneRef.current?.scrollTo({ top: paneRef.current.scrollHeight });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ArrowDownToLine size={13} /> Follow
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={paneRef}
|
||||||
|
onScroll={onScroll}
|
||||||
|
className="scroll-slim flex-1 overflow-y-auto rounded-xl border border-border bg-[#070a0e] p-3 font-mono text-[12px] leading-[1.55]"
|
||||||
|
>
|
||||||
|
{visible.length === 0 && (
|
||||||
|
<div className="py-10 text-center text-muted">
|
||||||
|
Waiting for log output… (is the server running?)
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visible.map((line, i) => (
|
||||||
|
<div key={i} className={`whitespace-pre-wrap break-all ${colorFor(line)}`}>
|
||||||
|
{line}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<span className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 font-mono text-sm text-accent">
|
||||||
|
>
|
||||||
|
</span>
|
||||||
|
<Input
|
||||||
|
value={command}
|
||||||
|
onChange={(e) => setCommand(e.target.value)}
|
||||||
|
onKeyDown={onKeyDown}
|
||||||
|
placeholder="Type a server command… (↑ for history)"
|
||||||
|
className="w-full pl-7 font-mono"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={() => void send()} busy={busy}>
|
||||||
|
<SendHorizonal size={14} /> Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{QUICK_COMMANDS.map((c) => (
|
||||||
|
<button
|
||||||
|
key={c}
|
||||||
|
onClick={() => void send(c)}
|
||||||
|
className="rounded-md border border-border bg-surface2 px-2 py-0.5 font-mono text-[11px] text-ink2 hover:text-ink"
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Folder,
|
||||||
|
File as FileIcon,
|
||||||
|
ChevronRight,
|
||||||
|
Home,
|
||||||
|
Upload,
|
||||||
|
FolderPlus,
|
||||||
|
Trash2,
|
||||||
|
Download,
|
||||||
|
Save,
|
||||||
|
X,
|
||||||
|
} from "lucide-react";
|
||||||
|
import CodeMirror from "@uiw/react-codemirror";
|
||||||
|
import { json } from "@codemirror/lang-json";
|
||||||
|
import { yaml } from "@codemirror/lang-yaml";
|
||||||
|
import { oneDark } from "@codemirror/theme-one-dark";
|
||||||
|
import { api, toast } from "@/lib/api";
|
||||||
|
import { Card, Button, EmptyState, Input, Modal } from "@/components/ui";
|
||||||
|
import { fmtBytes, fmtDateTime } from "@/lib/format";
|
||||||
|
|
||||||
|
type Entry = { name: string; type: "file" | "dir"; size: number; mtime: number };
|
||||||
|
|
||||||
|
const TEXT_EXT = /\.(txt|json|json5|yml|yaml|toml|conf|cfg|properties|log|mcmeta|nbt\.txt|md|sh|env|ini|snbt)$/i;
|
||||||
|
|
||||||
|
function langFor(name: string) {
|
||||||
|
if (/\.json5?(\.|$)/i.test(name) || name.endsWith(".mcmeta")) return [json()];
|
||||||
|
if (/\.ya?ml$/i.test(name)) return [yaml()];
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FilesPage() {
|
||||||
|
const [path, setPath] = useState("");
|
||||||
|
const [entries, setEntries] = useState<Entry[] | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [editing, setEditing] = useState<{ path: string; content: string; dirty: boolean; truncated: boolean } | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [mkdirOpen, setMkdirOpen] = useState(false);
|
||||||
|
const [mkdirName, setMkdirName] = useState("");
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<Entry | null>(null);
|
||||||
|
const uploadRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
const load = useCallback(async (p: string) => {
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
setEntries(await api<Entry[]>(`/api/files?dir=${encodeURIComponent(p)}`));
|
||||||
|
} catch (err) {
|
||||||
|
setEntries([]);
|
||||||
|
setError((err as Error).message);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load(path);
|
||||||
|
}, [path, load]);
|
||||||
|
|
||||||
|
const open = async (entry: Entry) => {
|
||||||
|
const full = path ? `${path}/${entry.name}` : entry.name;
|
||||||
|
if (entry.type === "dir") {
|
||||||
|
setPath(full);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!TEXT_EXT.test(entry.name) && entry.size > 512 * 1024) {
|
||||||
|
window.location.href = `/api/files/download?path=${encodeURIComponent(full)}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const d = await api<{ content: string; truncated: boolean }>(`/api/files?file=${encodeURIComponent(full)}`);
|
||||||
|
setEditing({ path: full, content: d.content, dirty: false, truncated: d.truncated });
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!editing) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api("/api/files", { body: { op: "write", path: editing.path, content: editing.content } });
|
||||||
|
toast(`Saved ${editing.path}`, "success");
|
||||||
|
setEditing({ ...editing, dirty: false });
|
||||||
|
void load(path);
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const upload = async (file: File) => {
|
||||||
|
const form = new FormData();
|
||||||
|
form.append("dir", path);
|
||||||
|
form.append("file", file);
|
||||||
|
toast(`Uploading ${file.name}…`, "info");
|
||||||
|
const res = await fetch("/api/files/upload", { method: "POST", body: form });
|
||||||
|
const payload = await res.json();
|
||||||
|
if (!res.ok || payload.ok === false) {
|
||||||
|
toast(payload.error ?? "Upload failed", "error");
|
||||||
|
} else {
|
||||||
|
toast(`${file.name} uploaded`, "success");
|
||||||
|
void load(path);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const crumbs = path ? path.split("/") : [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Files</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink2">Server data directory — world, configs, mods, logs</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input
|
||||||
|
ref={uploadRef}
|
||||||
|
type="file"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void upload(f);
|
||||||
|
e.target.value = "";
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button size="sm" onClick={() => uploadRef.current?.click()}>
|
||||||
|
<Upload size={13} /> Upload here
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setMkdirOpen(true)}>
|
||||||
|
<FolderPlus size={13} /> New folder
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* breadcrumbs */}
|
||||||
|
<div className="flex flex-wrap items-center gap-1 text-sm">
|
||||||
|
<button onClick={() => setPath("")} className="flex items-center gap-1 text-ink2 hover:text-ink">
|
||||||
|
<Home size={13} /> data
|
||||||
|
</button>
|
||||||
|
{crumbs.map((c, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1">
|
||||||
|
<ChevronRight size={12} className="text-muted" />
|
||||||
|
<button
|
||||||
|
onClick={() => setPath(crumbs.slice(0, i + 1).join("/"))}
|
||||||
|
className={i === crumbs.length - 1 ? "font-medium text-ink" : "text-ink2 hover:text-ink"}
|
||||||
|
>
|
||||||
|
{c}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <div className="rounded-xl border border-crit/40 bg-crit/10 px-4 py-3 text-sm text-crit">{error}</div>}
|
||||||
|
|
||||||
|
<Card pad={false}>
|
||||||
|
{entries === null ? (
|
||||||
|
<EmptyState title="Loading…" />
|
||||||
|
) : entries.length === 0 && !error ? (
|
||||||
|
<EmptyState icon={<Folder size={28} />} title="Empty folder" />
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody>
|
||||||
|
{entries.map((e) => (
|
||||||
|
<tr key={e.name} className="border-b border-border/50 last:border-0 hover:bg-surface2">
|
||||||
|
<td className="cursor-pointer px-4 py-2" onClick={() => void open(e)}>
|
||||||
|
<span className="flex items-center gap-2.5">
|
||||||
|
{e.type === "dir" ? (
|
||||||
|
<Folder size={15} className="text-series1" />
|
||||||
|
) : (
|
||||||
|
<FileIcon size={15} className="text-muted" />
|
||||||
|
)}
|
||||||
|
<span className={e.type === "dir" ? "font-medium" : ""}>{e.name}</span>
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="w-28 px-4 py-2 text-xs text-muted">{e.type === "file" ? fmtBytes(e.size) : "—"}</td>
|
||||||
|
<td className="w-40 px-4 py-2 text-xs text-muted">{fmtDateTime(e.mtime)}</td>
|
||||||
|
<td className="w-24 px-4 py-2">
|
||||||
|
<div className="flex justify-end gap-1">
|
||||||
|
{e.type === "file" && (
|
||||||
|
<a
|
||||||
|
href={`/api/files/download?path=${encodeURIComponent(path ? `${path}/${e.name}` : e.name)}`}
|
||||||
|
className="rounded p-1 text-muted hover:text-ink"
|
||||||
|
title="Download"
|
||||||
|
>
|
||||||
|
<Download size={13} />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={() => setConfirmDelete(e)}
|
||||||
|
className="rounded p-1 text-muted hover:text-crit"
|
||||||
|
title={e.type === "dir" ? "Delete (empty folders only)" : "Delete"}
|
||||||
|
>
|
||||||
|
<Trash2 size={13} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* editor modal */}
|
||||||
|
{editing && (
|
||||||
|
<div className="fixed inset-0 z-50 flex flex-col bg-black/70 p-4 md:p-10">
|
||||||
|
<div className="flex min-h-0 flex-1 flex-col overflow-hidden rounded-xl border border-border bg-surface">
|
||||||
|
<header className="flex items-center justify-between border-b border-border px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2 font-mono text-sm">
|
||||||
|
{editing.path}
|
||||||
|
{editing.dirty && <span className="text-warn">●</span>}
|
||||||
|
{editing.truncated && <span className="text-[10px] text-warn">(truncated — file over 2 MB)</span>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" variant="primary" onClick={save} busy={saving} disabled={!editing.dirty || editing.truncated}>
|
||||||
|
<Save size={13} /> Save
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" onClick={() => setEditing(null)}>
|
||||||
|
<X size={13} /> Close
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="scroll-slim min-h-0 flex-1 overflow-auto">
|
||||||
|
<CodeMirror
|
||||||
|
value={editing.content}
|
||||||
|
onChange={(v) => setEditing((e) => (e ? { ...e, content: v, dirty: true } : e))}
|
||||||
|
extensions={langFor(editing.path)}
|
||||||
|
theme={oneDark}
|
||||||
|
height="100%"
|
||||||
|
style={{ fontSize: 12, height: "100%" }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* new folder modal */}
|
||||||
|
<Modal open={mkdirOpen} onClose={() => setMkdirOpen(false)} title="New folder">
|
||||||
|
<Input
|
||||||
|
value={mkdirName}
|
||||||
|
onChange={(e) => setMkdirName(e.target.value)}
|
||||||
|
placeholder="folder name"
|
||||||
|
className="w-full"
|
||||||
|
onKeyDown={async (e) => {
|
||||||
|
if (e.key === "Enter" && mkdirName.trim()) {
|
||||||
|
await api("/api/files", {
|
||||||
|
body: { op: "mkdir", path: path ? `${path}/${mkdirName.trim()}` : mkdirName.trim() },
|
||||||
|
});
|
||||||
|
setMkdirOpen(false);
|
||||||
|
setMkdirName("");
|
||||||
|
void load(path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button onClick={() => setMkdirOpen(false)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!mkdirName.trim()) return;
|
||||||
|
await api("/api/files", {
|
||||||
|
body: { op: "mkdir", path: path ? `${path}/${mkdirName.trim()}` : mkdirName.trim() },
|
||||||
|
});
|
||||||
|
setMkdirOpen(false);
|
||||||
|
setMkdirName("");
|
||||||
|
void load(path);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Create
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* delete confirm */}
|
||||||
|
<Modal open={confirmDelete !== null} onClose={() => setConfirmDelete(null)} title={`Delete ${confirmDelete?.name}?`}>
|
||||||
|
<p className="text-sm text-ink2">
|
||||||
|
{confirmDelete?.type === "dir"
|
||||||
|
? "Only empty folders can be deleted from the panel."
|
||||||
|
: "This permanently deletes the file from the server data directory."}
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button onClick={() => setConfirmDelete(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
onClick={async () => {
|
||||||
|
const e = confirmDelete!;
|
||||||
|
setConfirmDelete(null);
|
||||||
|
try {
|
||||||
|
await api("/api/files", { body: { op: "delete", path: path ? `${path}/${e.name}` : e.name } });
|
||||||
|
toast(`${e.name} deleted`, "warn");
|
||||||
|
void load(path);
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { Shell } from "@/components/shell";
|
||||||
|
|
||||||
|
export default function PanelLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return <Shell>{children}</Shell>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Package, Search, Trash2, Power, Download, RefreshCw, Lock } from "lucide-react";
|
||||||
|
import { api, useApi, toast } from "@/lib/api";
|
||||||
|
import { Card, Button, Badge, Input, EmptyState, Modal } from "@/components/ui";
|
||||||
|
import { fmtBytes } from "@/lib/format";
|
||||||
|
|
||||||
|
type ModsData = {
|
||||||
|
installed: Array<{
|
||||||
|
file: string;
|
||||||
|
name: string;
|
||||||
|
id: string | null;
|
||||||
|
version: string | null;
|
||||||
|
disabled: boolean;
|
||||||
|
size: number;
|
||||||
|
}>;
|
||||||
|
managed: string[];
|
||||||
|
mcVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SearchHit = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
downloads: number;
|
||||||
|
icon_url: string | null;
|
||||||
|
server_side: string;
|
||||||
|
client_side: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PROTECTED = new Set(["fabric-api", "easyauth"]);
|
||||||
|
|
||||||
|
export default function ModsPage() {
|
||||||
|
const { data, refresh } = useApi<ModsData>("/api/mods", 0);
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [hits, setHits] = useState<SearchHit[] | null>(null);
|
||||||
|
const [searching, setSearching] = useState(false);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const [applying, setApplying] = useState(false);
|
||||||
|
const [needsApply, setNeedsApply] = useState(false);
|
||||||
|
const [confirmApply, setConfirmApply] = useState(false);
|
||||||
|
|
||||||
|
const search = async () => {
|
||||||
|
if (!query.trim()) return;
|
||||||
|
setSearching(true);
|
||||||
|
try {
|
||||||
|
setHits(await api<SearchHit[]>(`/api/mods/search?q=${encodeURIComponent(query.trim())}`));
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const manage = async (label: string, body: Record<string, unknown>, message: string) => {
|
||||||
|
setBusy(label);
|
||||||
|
try {
|
||||||
|
const res = await api<{ needsApply?: boolean; needsRestart?: boolean }>("/api/mods/manage", { body });
|
||||||
|
toast(message, "success");
|
||||||
|
if (res.needsApply || res.needsRestart) setNeedsApply(true);
|
||||||
|
void refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const apply = async () => {
|
||||||
|
setConfirmApply(false);
|
||||||
|
setApplying(true);
|
||||||
|
try {
|
||||||
|
await api("/api/mods/apply", { method: "POST" });
|
||||||
|
toast("Server recreated — mods re-resolved for the current version", "success");
|
||||||
|
setNeedsApply(false);
|
||||||
|
void refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setApplying(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const managedSet = new Set((data?.managed ?? []).map((s) => s.toLowerCase()));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Mods</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink2">
|
||||||
|
Managed mods auto-update to the newest build for Minecraft {data?.mcVersion ?? "…"} on every apply
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Button variant={needsApply ? "primary" : "ghost"} onClick={() => setConfirmApply(true)} busy={applying}>
|
||||||
|
<RefreshCw size={14} /> {needsApply ? "Apply changes (restarts server)" : "Update all & restart"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{/* Modrinth search */}
|
||||||
|
<Card title="Add from Modrinth">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Input
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && void search()}
|
||||||
|
placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.2"}…`}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
<Button onClick={() => void search()} busy={searching}>
|
||||||
|
<Search size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="scroll-slim mt-3 max-h-96 space-y-2 overflow-y-auto">
|
||||||
|
{hits === null && (
|
||||||
|
<p className="py-6 text-center text-xs text-muted">
|
||||||
|
Search Modrinth — results are pre-filtered to Fabric + your server version.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{hits?.length === 0 && <p className="py-6 text-center text-xs text-muted">No compatible mods found.</p>}
|
||||||
|
{hits?.map((h) => {
|
||||||
|
const already = managedSet.has(h.slug.toLowerCase());
|
||||||
|
return (
|
||||||
|
<div key={h.slug} className="flex items-center gap-3 rounded-lg border border-border bg-surface2 p-2.5">
|
||||||
|
{h.icon_url ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img src={h.icon_url} alt="" className="h-9 w-9 rounded-md bg-surface3" />
|
||||||
|
) : (
|
||||||
|
<div className="flex h-9 w-9 items-center justify-center rounded-md bg-surface3">
|
||||||
|
<Package size={16} className="text-muted" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="truncate text-sm font-medium">{h.title}</span>
|
||||||
|
{h.client_side === "required" && (
|
||||||
|
<Badge color="warn">needs client install</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-[11px] text-muted">{h.description}</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant={already ? "ghost" : "primary"}
|
||||||
|
disabled={already}
|
||||||
|
busy={busy === `add-${h.slug}`}
|
||||||
|
onClick={() => void manage(`add-${h.slug}`, { op: "add", slug: h.slug }, `${h.title} added`)}
|
||||||
|
>
|
||||||
|
<Download size={12} /> {already ? "Added" : "Add"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* managed list */}
|
||||||
|
<Card title={`Managed mods (${data?.managed.length ?? 0})`}>
|
||||||
|
<p className="mb-3 text-[11px] leading-relaxed text-muted">
|
||||||
|
These Modrinth projects are pinned in <code>.env</code> (MODRINTH_PROJECTS). The server resolves the
|
||||||
|
newest compatible build on every start, so updating Minecraft also updates these automatically.
|
||||||
|
</p>
|
||||||
|
<ul className="space-y-1.5">
|
||||||
|
{(data?.managed ?? []).map((slug) => (
|
||||||
|
<li key={slug} className="flex items-center gap-2 rounded-lg border border-border bg-surface2 px-3 py-2">
|
||||||
|
<span className="font-mono text-sm">{slug}</span>
|
||||||
|
{PROTECTED.has(slug.toLowerCase()) && (
|
||||||
|
<Badge color="info">
|
||||||
|
<Lock size={9} /> required
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto">
|
||||||
|
{!PROTECTED.has(slug.toLowerCase()) && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
busy={busy === `remove-${slug}`}
|
||||||
|
onClick={() => void manage(`remove-${slug}`, { op: "remove", slug }, `${slug} removed`)}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* installed jars */}
|
||||||
|
<Card title="Installed jar files" pad={false}>
|
||||||
|
{!data || data.installed.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Package size={28} />}
|
||||||
|
title="No mod jars found"
|
||||||
|
hint="Jars appear here once the server has started and downloaded its mods. You can also upload jars via the Files page (mods/ folder)."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
|
||||||
|
<th className="px-4 py-2 font-medium">Mod</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Version</th>
|
||||||
|
<th className="px-4 py-2 font-medium">File</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Size</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{data.installed.map((m) => (
|
||||||
|
<tr key={m.file} className={`border-b border-border/50 last:border-0 ${m.disabled ? "opacity-50" : ""}`}>
|
||||||
|
<td className="px-4 py-2.5 font-medium">
|
||||||
|
{m.name} {m.disabled && <Badge color="muted">disabled</Badge>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 font-mono text-xs text-ink2">{m.version ?? "—"}</td>
|
||||||
|
<td className="px-4 py-2.5 font-mono text-[11px] text-muted">{m.file}</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink2">{fmtBytes(m.size)}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
busy={busy === `toggle-${m.file}`}
|
||||||
|
onClick={() =>
|
||||||
|
void manage(
|
||||||
|
`toggle-${m.file}`,
|
||||||
|
{ op: "toggle", file: m.file },
|
||||||
|
m.disabled ? `${m.name} enabled` : `${m.name} disabled`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
title={m.disabled ? "Enable" : "Disable (renames to .disabled)"}
|
||||||
|
>
|
||||||
|
<Power size={12} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
busy={busy === `delete-${m.file}`}
|
||||||
|
onClick={() => void manage(`delete-${m.file}`, { op: "delete", file: m.file }, `${m.file} deleted`)}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal open={confirmApply} onClose={() => setConfirmApply(false)} title="Apply mod changes?">
|
||||||
|
<p className="text-sm leading-relaxed text-ink2">
|
||||||
|
This recreates the server container: online players are disconnected for a minute or two while the
|
||||||
|
server restarts and re-resolves every managed mod against Minecraft {data?.mcVersion}.
|
||||||
|
</p>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button onClick={() => setConfirmApply(false)}>Cancel</Button>
|
||||||
|
<Button variant="primary" onClick={() => void apply()}>
|
||||||
|
Apply & restart
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { Play, Square, RotateCw, HardDrive, Globe2, Cuboid } from "lucide-react";
|
||||||
|
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||||
|
import { Card, StatTile, Button, Badge, EmptyState } from "@/components/ui";
|
||||||
|
import { Sparkline, UsageBar, type Point } from "@/components/charts";
|
||||||
|
import { fmtBytes, fmtDuration, timeAgo, fmtCountdown } from "@/lib/format";
|
||||||
|
|
||||||
|
type ServerData = {
|
||||||
|
dockerAvailable: boolean;
|
||||||
|
container: { exists: boolean; running: boolean; status: string; health?: string };
|
||||||
|
players: { online: number; max: number; names: string[] };
|
||||||
|
version: string | null;
|
||||||
|
serverType: string | null;
|
||||||
|
metrics: Record<string, number | null> | null;
|
||||||
|
pendingApprovals: number;
|
||||||
|
world: { seed: string | null; sizeBytes: number | null; dimensions: string[]; levelName: string | null };
|
||||||
|
uptimeMs: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MetricRow = {
|
||||||
|
bucket: number;
|
||||||
|
cpu: number | null;
|
||||||
|
mem_used: number | null;
|
||||||
|
mem_limit: number | null;
|
||||||
|
tps: number | null;
|
||||||
|
players: number | null;
|
||||||
|
disk_used: number | null;
|
||||||
|
disk_free: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
|
||||||
|
|
||||||
|
type ApprovedRow = { username: string; status: string; window_expires_at: number | null };
|
||||||
|
|
||||||
|
const ACTIVITY_LABELS: Record<string, { label: string; color: "good" | "warn" | "crit" | "info" | "muted" }> = {
|
||||||
|
join: { label: "joined", color: "good" },
|
||||||
|
leave: { label: "left", color: "muted" },
|
||||||
|
connect: { label: "connecting", color: "info" },
|
||||||
|
register: { label: "registered", color: "good" },
|
||||||
|
approve: { label: "approved", color: "info" },
|
||||||
|
expire: { label: "window expired", color: "warn" },
|
||||||
|
revoke: { label: "revoked", color: "crit" },
|
||||||
|
server_online: { label: "server online", color: "good" },
|
||||||
|
server_stopping: { label: "server stopping", color: "warn" },
|
||||||
|
backup: { label: "backup", color: "info" },
|
||||||
|
restore: { label: "restore", color: "warn" },
|
||||||
|
power: { label: "power", color: "muted" },
|
||||||
|
console: { label: "console", color: "muted" },
|
||||||
|
world_reset: { label: "world reset", color: "crit" },
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OverviewPage() {
|
||||||
|
const { data: server, refresh } = useApi<ServerData>("/api/server", 10000);
|
||||||
|
const { data: metricRows, refresh: refreshMetrics } = useApi<MetricRow[]>("/api/metrics?range=1h", 30000);
|
||||||
|
const { data: activity, refresh: refreshActivity } = useApi<ActivityRow[]>("/api/activity?limit=14", 20000);
|
||||||
|
const { data: approvedData, refresh: refreshApproved } = useApi<{ approved: ApprovedRow[] }>(
|
||||||
|
"/api/approved",
|
||||||
|
15000
|
||||||
|
);
|
||||||
|
const [busyAction, setBusyAction] = useState<string | null>(null);
|
||||||
|
const [, forceTick] = useState(0);
|
||||||
|
|
||||||
|
useSSE("/api/stream/events", {
|
||||||
|
"mc-event": () => {
|
||||||
|
void refreshActivity();
|
||||||
|
void refreshApproved();
|
||||||
|
},
|
||||||
|
metrics: () => forceTick((x) => x + 1),
|
||||||
|
});
|
||||||
|
|
||||||
|
const power = async (action: "start" | "stop" | "restart") => {
|
||||||
|
setBusyAction(action);
|
||||||
|
try {
|
||||||
|
await api("/api/server/power", { body: { action } });
|
||||||
|
toast(`Server ${action} requested`, "success");
|
||||||
|
setTimeout(() => void refresh(), 1500);
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusyAction(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const running = server?.container.running ?? false;
|
||||||
|
const m = server?.metrics;
|
||||||
|
const points = (key: keyof MetricRow): Point[] =>
|
||||||
|
(metricRows ?? []).map((r) => ({ ts: r.bucket, v: r[key] as number | null }));
|
||||||
|
|
||||||
|
const pending = (approvedData?.approved ?? []).filter((a) => a.status === "pending");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* header row */}
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Overview</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink2">
|
||||||
|
{server?.serverType ?? "Fabric"} · Minecraft {server?.version ?? "…"}
|
||||||
|
{running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}</>}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => power("start")}
|
||||||
|
busy={busyAction === "start"}
|
||||||
|
disabled={running || !server?.dockerAvailable}
|
||||||
|
>
|
||||||
|
<Play size={13} /> Start
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => power("restart")}
|
||||||
|
busy={busyAction === "restart"}
|
||||||
|
disabled={!running}
|
||||||
|
>
|
||||||
|
<RotateCw size={13} /> Restart
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="danger"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => power("stop")}
|
||||||
|
busy={busyAction === "stop"}
|
||||||
|
disabled={!running}
|
||||||
|
>
|
||||||
|
<Square size={13} /> Stop
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!server?.dockerAvailable && server && (
|
||||||
|
<div className="rounded-xl border border-crit/40 bg-crit/10 px-4 py-3 text-sm text-crit">
|
||||||
|
Docker is unreachable from the panel. If you're running the dashboard outside the compose stack,
|
||||||
|
server control and metrics are unavailable.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* stat tiles */}
|
||||||
|
<div className="grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||||
|
<StatTile
|
||||||
|
label="TPS"
|
||||||
|
value={m?.tps != null ? Number(m.tps).toFixed(1) : "—"}
|
||||||
|
sub="target 20.0"
|
||||||
|
accent={m?.tps != null ? (Number(m.tps) >= 18 ? "good" : Number(m.tps) >= 15 ? "warn" : "crit") : undefined}
|
||||||
|
>
|
||||||
|
<Sparkline points={points("tps")} yMax={20} color="var(--color-series2)" />
|
||||||
|
</StatTile>
|
||||||
|
<StatTile label="CPU" value={m?.cpu != null ? `${Number(m.cpu).toFixed(0)}%` : "—"} sub="of one core × cores">
|
||||||
|
<Sparkline points={points("cpu")} />
|
||||||
|
</StatTile>
|
||||||
|
<StatTile
|
||||||
|
label="Memory"
|
||||||
|
value={m?.mem_used != null ? fmtBytes(Number(m.mem_used)) : "—"}
|
||||||
|
sub={m?.mem_limit ? `limit ${fmtBytes(Number(m.mem_limit))}` : undefined}
|
||||||
|
>
|
||||||
|
<Sparkline points={points("mem_used")} yMax={m?.mem_limit ? Number(m.mem_limit) : undefined} />
|
||||||
|
</StatTile>
|
||||||
|
<StatTile
|
||||||
|
label="Players"
|
||||||
|
value={`${server?.players.online ?? 0}${server?.players.max ? ` / ${server.players.max}` : ""}`}
|
||||||
|
sub={server?.players.names.join(", ") || "nobody online"}
|
||||||
|
>
|
||||||
|
<Sparkline points={points("players")} color="var(--color-series3)" />
|
||||||
|
</StatTile>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{/* activity feed */}
|
||||||
|
<Card title="Recent activity">
|
||||||
|
{!activity || activity.length === 0 ? (
|
||||||
|
<EmptyState title="No activity yet" hint="Joins, registrations and server events show up here." />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{activity.map((a) => {
|
||||||
|
const meta = ACTIVITY_LABELS[a.type] ?? { label: a.type, color: "muted" as const };
|
||||||
|
return (
|
||||||
|
<li key={a.id} className="flex items-center gap-2.5 text-sm">
|
||||||
|
<Badge color={meta.color}>{meta.label}</Badge>
|
||||||
|
<span className="truncate text-ink">
|
||||||
|
{a.player && <span className="font-medium">{a.player} </span>}
|
||||||
|
<span className="text-ink2">{a.detail}</span>
|
||||||
|
</span>
|
||||||
|
<span className="ml-auto shrink-0 text-[11px] text-muted">{timeAgo(a.ts)}</span>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* pending registrations */}
|
||||||
|
{pending.length > 0 && (
|
||||||
|
<Card title="Waiting to register">
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{pending.map((p) => (
|
||||||
|
<li key={p.username} className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge color="warn">pending</Badge>
|
||||||
|
<span className="font-medium">{p.username}</span>
|
||||||
|
<span className="ml-auto font-mono text-xs text-warn">
|
||||||
|
{p.window_expires_at ? fmtCountdown(p.window_expires_at) : ""}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="mt-3 text-[11px] text-muted">
|
||||||
|
They must join and <code>/register <password> <password></code> before the timer runs
|
||||||
|
out. Manage in{" "}
|
||||||
|
<Link href="/access" className="text-accent underline">
|
||||||
|
Access & Auth
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* world + disk */}
|
||||||
|
<Card title="World & storage">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Cuboid size={15} className="text-muted" />
|
||||||
|
<span className="text-ink2">World</span>
|
||||||
|
<span className="ml-auto font-medium">
|
||||||
|
{server?.world.levelName ?? "world"} · {fmtBytes(server?.world.sizeBytes)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<Globe2 size={15} className="text-muted" />
|
||||||
|
<span className="text-ink2">Dimensions</span>
|
||||||
|
<span className="ml-auto font-medium">
|
||||||
|
{server?.world.dimensions.length ? server.world.dimensions.join(" · ") : "not generated yet"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{server?.world.seed && (
|
||||||
|
<div className="flex items-center gap-3 text-sm">
|
||||||
|
<span className="text-ink2">Seed</span>
|
||||||
|
<span className="ml-auto font-mono text-xs">{server.world.seed}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="pt-1">
|
||||||
|
{m?.disk_used != null && m?.disk_free != null ? (
|
||||||
|
<UsageBar
|
||||||
|
used={Number(m.disk_used)}
|
||||||
|
total={Number(m.disk_used) + Number(m.disk_free)}
|
||||||
|
label="Host disk"
|
||||||
|
format={fmtBytes}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center gap-2 text-xs text-muted">
|
||||||
|
<HardDrive size={13} /> disk stats unavailable
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,261 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import { Stethoscope, Wifi } from "lucide-react";
|
||||||
|
import { api, useApi, toast } from "@/lib/api";
|
||||||
|
import { Card, Button, Badge, Tabs, EmptyState, Modal } from "@/components/ui";
|
||||||
|
import { TimeSeriesChart, Sparkline, type Point } from "@/components/charts";
|
||||||
|
import { fmtBytes } from "@/lib/format";
|
||||||
|
|
||||||
|
type MetricRow = {
|
||||||
|
bucket: number;
|
||||||
|
cpu: number | null;
|
||||||
|
mem_used: number | null;
|
||||||
|
mem_limit: number | null;
|
||||||
|
net_rx: number | null;
|
||||||
|
net_tx: number | null;
|
||||||
|
tps: number | null;
|
||||||
|
mspt: number | null;
|
||||||
|
players: number | null;
|
||||||
|
disk_used: number | null;
|
||||||
|
disk_free: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type LagData = {
|
||||||
|
tps: number | null;
|
||||||
|
mspt: number | null;
|
||||||
|
cpu: number | null;
|
||||||
|
serverHealthy: boolean;
|
||||||
|
verdict: "no-data" | "server-lagging" | "player-connections" | "all-good";
|
||||||
|
players: Array<{
|
||||||
|
name: string;
|
||||||
|
ping: number | null;
|
||||||
|
verdict: string;
|
||||||
|
history: Array<{ ts: number; ping: number }>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const RANGES = [
|
||||||
|
{ id: "1h", label: "1 hour" },
|
||||||
|
{ id: "6h", label: "6 hours" },
|
||||||
|
{ id: "24h", label: "24 hours" },
|
||||||
|
{ id: "7d", label: "7 days" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const VERDICTS: Record<LagData["verdict"], { badge: "good" | "warn" | "crit" | "muted"; title: string; hint: string }> = {
|
||||||
|
"all-good": {
|
||||||
|
badge: "good",
|
||||||
|
title: "Everything looks healthy",
|
||||||
|
hint: "The server is ticking at full speed and every player's connection is fine.",
|
||||||
|
},
|
||||||
|
"server-lagging": {
|
||||||
|
badge: "crit",
|
||||||
|
title: "The server itself is lagging",
|
||||||
|
hint: "Tick rate is below target — everyone experiences this equally. Check CPU/memory, heavy redstone/farms, or too many loaded chunks.",
|
||||||
|
},
|
||||||
|
"player-connections": {
|
||||||
|
badge: "warn",
|
||||||
|
title: "Server is fine — some connections are not",
|
||||||
|
hint: "The server is ticking normally. The players flagged below have high ping, so lag is on their network path, not your machine.",
|
||||||
|
},
|
||||||
|
"no-data": {
|
||||||
|
badge: "muted",
|
||||||
|
title: "No data yet",
|
||||||
|
hint: "Start the server and give the sampler a minute to collect TPS and ping data.",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PerformancePage() {
|
||||||
|
const [range, setRange] = useState("1h");
|
||||||
|
const { data: rows } = useApi<MetricRow[]>(`/api/metrics?range=${range}`, 30000);
|
||||||
|
const { data: lag, refresh: refreshLag } = useApi<LagData>("/api/lag", 30000);
|
||||||
|
const [report, setReport] = useState<string | null>(null);
|
||||||
|
const [busyReport, setBusyReport] = useState(false);
|
||||||
|
|
||||||
|
const pts = useMemo(() => {
|
||||||
|
const make = (key: keyof MetricRow): Point[] => (rows ?? []).map((r) => ({ ts: r.bucket, v: r[key] as number | null }));
|
||||||
|
// network counters are cumulative; convert to per-second rates
|
||||||
|
const rates = (key: "net_rx" | "net_tx"): Point[] => {
|
||||||
|
const out: Point[] = [];
|
||||||
|
const data = rows ?? [];
|
||||||
|
for (let i = 1; i < data.length; i++) {
|
||||||
|
const dv = (data[i][key] ?? 0) - (data[i - 1][key] ?? 0);
|
||||||
|
const dt = (data[i].bucket - data[i - 1].bucket) / 1000;
|
||||||
|
out.push({ ts: data[i].bucket, v: dv >= 0 && dt > 0 ? dv / dt : null });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
};
|
||||||
|
return { make, rates };
|
||||||
|
}, [rows]);
|
||||||
|
|
||||||
|
const runHealthReport = async () => {
|
||||||
|
setBusyReport(true);
|
||||||
|
try {
|
||||||
|
const d = await api<{ report: string }>("/api/lag/healthreport", { method: "POST" });
|
||||||
|
setReport(d.report);
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusyReport(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const verdict = VERDICTS[lag?.verdict ?? "no-data"];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Performance & Lag</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink2">Server tick health, resource usage, and who's actually lagging</p>
|
||||||
|
</div>
|
||||||
|
<Tabs tabs={RANGES} active={range} onChange={setRange} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* lag verdict */}
|
||||||
|
<Card
|
||||||
|
title="Lag diagnosis"
|
||||||
|
actions={
|
||||||
|
<Button size="sm" onClick={runHealthReport} busy={busyReport}>
|
||||||
|
<Stethoscope size={13} /> Full health report
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div className="flex flex-wrap items-start gap-4">
|
||||||
|
<Badge color={verdict.badge}>{verdict.title}</Badge>
|
||||||
|
<p className="min-w-64 flex-1 text-xs leading-relaxed text-ink2">{verdict.hint}</p>
|
||||||
|
<div className="flex gap-4 text-center">
|
||||||
|
{[
|
||||||
|
["TPS", lag?.tps != null ? lag.tps.toFixed(1) : "—"],
|
||||||
|
["MSPT", lag?.mspt != null ? `${lag.mspt.toFixed(1)} ms` : "—"],
|
||||||
|
["CPU", lag?.cpu != null ? `${lag.cpu.toFixed(0)}%` : "—"],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label as string}>
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted">{label}</div>
|
||||||
|
<div className="text-lg font-semibold">{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* per-player connections */}
|
||||||
|
<div className="mt-4 border-t border-border pt-3">
|
||||||
|
<div className="mb-2 text-xs font-medium text-ink2">Player connections (last hour)</div>
|
||||||
|
{!lag || lag.players.length === 0 ? (
|
||||||
|
<div className="text-xs text-muted">Nobody online right now.</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{lag.players.map((p) => (
|
||||||
|
<div key={p.name} className="rounded-lg border border-border bg-surface2 p-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wifi
|
||||||
|
size={13}
|
||||||
|
className={
|
||||||
|
p.verdict === "bad-connection"
|
||||||
|
? "text-crit"
|
||||||
|
: p.verdict === "weak-connection"
|
||||||
|
? "text-warn"
|
||||||
|
: "text-good"
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<span className="font-mono text-sm font-medium">{p.name}</span>
|
||||||
|
<span className="ml-auto text-sm font-semibold">
|
||||||
|
{p.ping !== null ? `${p.ping} ms` : "—"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<Sparkline
|
||||||
|
points={p.history.map((h) => ({ ts: h.ts, v: h.ping }))}
|
||||||
|
color={
|
||||||
|
p.verdict === "bad-connection"
|
||||||
|
? "var(--color-crit)"
|
||||||
|
: p.verdict === "weak-connection"
|
||||||
|
? "var(--color-warn)"
|
||||||
|
: "var(--color-series2)"
|
||||||
|
}
|
||||||
|
height={28}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[10px] text-muted">
|
||||||
|
{p.verdict === "bad-connection"
|
||||||
|
? "Their connection is struggling — it's not the server."
|
||||||
|
: p.verdict === "weak-connection"
|
||||||
|
? "Somewhat high ping — their network, not the server."
|
||||||
|
: p.verdict === "good"
|
||||||
|
? "Healthy connection."
|
||||||
|
: "No ping data yet."}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* charts — one measure per chart (never dual-axis) */}
|
||||||
|
{!rows || rows.length < 2 ? (
|
||||||
|
<Card>
|
||||||
|
<EmptyState
|
||||||
|
title="Collecting metrics…"
|
||||||
|
hint="The sampler stores a data point every 10 seconds while the panel runs."
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
<Card title="Tick rate (TPS)">
|
||||||
|
<TimeSeriesChart
|
||||||
|
series={[{ name: "TPS", color: "var(--color-series2)", points: pts.make("tps") }]}
|
||||||
|
yMax={20}
|
||||||
|
height={190}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card title="Tick duration (MSPT)">
|
||||||
|
<TimeSeriesChart
|
||||||
|
series={[{ name: "MSPT", color: "var(--color-series3)", points: pts.make("mspt") }]}
|
||||||
|
unit=" ms"
|
||||||
|
height={190}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card title="CPU usage">
|
||||||
|
<TimeSeriesChart
|
||||||
|
series={[{ name: "CPU", color: "var(--color-series1)", points: pts.make("cpu") }]}
|
||||||
|
unit="%"
|
||||||
|
height={190}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card title="Memory">
|
||||||
|
<TimeSeriesChart
|
||||||
|
series={[{ name: "Used", color: "var(--color-series1)", points: pts.make("mem_used") }]}
|
||||||
|
height={190}
|
||||||
|
formatValue={(v) => fmtBytes(v)}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card title="Network throughput">
|
||||||
|
<TimeSeriesChart
|
||||||
|
series={[
|
||||||
|
{ name: "Down", color: "var(--color-series1)", points: pts.rates("net_rx") },
|
||||||
|
{ name: "Up", color: "var(--color-series2)", points: pts.rates("net_tx") },
|
||||||
|
]}
|
||||||
|
height={190}
|
||||||
|
formatValue={(v) => `${fmtBytes(v)}/s`}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
<Card title="Players online">
|
||||||
|
<TimeSeriesChart
|
||||||
|
series={[{ name: "Players", color: "var(--color-series3)", points: pts.make("players") }]}
|
||||||
|
height={190}
|
||||||
|
formatValue={(v) => String(Math.round(v))}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* spark health report modal */}
|
||||||
|
<Modal open={report !== null} onClose={() => setReport(null)} title="spark health report" wide>
|
||||||
|
<pre className="scroll-slim max-h-[60vh] overflow-auto whitespace-pre-wrap rounded-lg bg-[#070a0e] p-3 font-mono text-[11px] leading-relaxed text-ink2">
|
||||||
|
{report}
|
||||||
|
</pre>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { Users, UserX, Wifi } from "lucide-react";
|
||||||
|
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||||
|
import { Card, Badge, Button, EmptyState, Modal } from "@/components/ui";
|
||||||
|
import { timeAgo, fmtDateTime } from "@/lib/format";
|
||||||
|
|
||||||
|
type PlayerRow = {
|
||||||
|
name: string;
|
||||||
|
uuid: string;
|
||||||
|
playTimeHours: number;
|
||||||
|
deaths: number;
|
||||||
|
mobKills: number;
|
||||||
|
playerKills: number;
|
||||||
|
distanceKm: number;
|
||||||
|
jumps: number;
|
||||||
|
damageDealt: number;
|
||||||
|
blocksMined: number;
|
||||||
|
isOnline: boolean;
|
||||||
|
ping: number | null;
|
||||||
|
authStatus: string | null;
|
||||||
|
lastSeenAt: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PlayersData = { online: string[]; max: number; players: PlayerRow[] };
|
||||||
|
type ActivityRow = { id: number; ts: number; type: string; player: string | null; detail: string | null };
|
||||||
|
|
||||||
|
export default function PlayersPage() {
|
||||||
|
const { data, refresh } = useApi<PlayersData>("/api/players", 15000);
|
||||||
|
const { data: joins, refresh: refreshJoins } = useApi<ActivityRow[]>("/api/activity?limit=30", 20000);
|
||||||
|
const [detail, setDetail] = useState<PlayerRow | null>(null);
|
||||||
|
const [kicking, setKicking] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useSSE("/api/stream/events", {
|
||||||
|
"mc-event": () => {
|
||||||
|
void refresh();
|
||||||
|
void refreshJoins();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const kick = async (name: string) => {
|
||||||
|
setKicking(name);
|
||||||
|
try {
|
||||||
|
await api("/api/console", { body: { command: `kick ${name} Kicked from the panel` } });
|
||||||
|
toast(`${name} kicked`, "warn");
|
||||||
|
setTimeout(() => void refresh(), 1000);
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setKicking(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const players = data?.players ?? [];
|
||||||
|
const online = players.filter((p) => p.isOnline);
|
||||||
|
const joinEvents = (joins ?? []).filter((j) => ["join", "leave", "connect"].includes(j.type));
|
||||||
|
|
||||||
|
const pingBadge = (ping: number | null) => {
|
||||||
|
if (ping === null) return null;
|
||||||
|
const color = ping > 250 ? "crit" : ping > 120 ? "warn" : "good";
|
||||||
|
return (
|
||||||
|
<Badge color={color}>
|
||||||
|
<Wifi size={10} /> {ping} ms
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Players</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink2">
|
||||||
|
{online.length} online{data?.max ? ` of ${data.max} max` : ""} · {players.length} have played
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-3">
|
||||||
|
<div className="space-y-4 lg:col-span-2">
|
||||||
|
<Card title="All players" pad={false}>
|
||||||
|
{players.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Users size={28} />}
|
||||||
|
title="No player data yet"
|
||||||
|
hint="Stats appear after someone plays on the server."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
|
||||||
|
<th className="px-4 py-2 font-medium">Player</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Playtime</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Deaths</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Last seen</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{players.map((p) => (
|
||||||
|
<tr
|
||||||
|
key={p.uuid}
|
||||||
|
className="cursor-pointer border-b border-border/50 last:border-0 hover:bg-surface2"
|
||||||
|
onClick={() => setDetail(p)}
|
||||||
|
>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className={`h-2 w-2 rounded-full ${p.isOnline ? "bg-good" : "bg-surface3"}`}
|
||||||
|
title={p.isOnline ? "online" : "offline"}
|
||||||
|
/>
|
||||||
|
<span className="font-mono font-medium">{p.name}</span>
|
||||||
|
{p.isOnline && pingBadge(p.ping)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink2">{p.playTimeHours} h</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink2">{p.deaths}</td>
|
||||||
|
<td className="px-4 py-2.5 text-ink2">
|
||||||
|
{p.isOnline ? <span className="text-good">online now</span> : timeAgo(p.lastSeenAt)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-right">
|
||||||
|
{p.isOnline && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
busy={kicking === p.name}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
void kick(p.name);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<UserX size={12} /> Kick
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* join/leave feed */}
|
||||||
|
<Card title="Joins & leaves">
|
||||||
|
{joinEvents.length === 0 ? (
|
||||||
|
<EmptyState title="No connections yet" />
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-2">
|
||||||
|
{joinEvents.map((e) => (
|
||||||
|
<li key={e.id} className="flex items-center gap-2 text-sm">
|
||||||
|
<Badge color={e.type === "join" ? "good" : e.type === "leave" ? "muted" : "info"}>
|
||||||
|
{e.type === "connect" ? "connecting" : e.type}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-mono text-xs font-medium">{e.player}</span>
|
||||||
|
{e.type === "connect" && e.detail && (
|
||||||
|
<span className="font-mono text-[10px] text-muted">{e.detail}</span>
|
||||||
|
)}
|
||||||
|
<span className="ml-auto text-[10px] text-muted">{timeAgo(e.ts)}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* stats drawer */}
|
||||||
|
<Modal open={detail !== null} onClose={() => setDetail(null)} title={detail?.name ?? ""} wide>
|
||||||
|
{detail && (
|
||||||
|
<div>
|
||||||
|
<div className="mb-3 flex items-center gap-2">
|
||||||
|
{detail.isOnline ? <Badge color="good">online</Badge> : <Badge color="muted">offline</Badge>}
|
||||||
|
{detail.authStatus && <Badge color="info">{detail.authStatus}</Badge>}
|
||||||
|
{detail.isOnline && detail.ping !== null && pingBadge(detail.ping)}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||||
|
{[
|
||||||
|
["Playtime", `${detail.playTimeHours} h`],
|
||||||
|
["Deaths", detail.deaths],
|
||||||
|
["Mob kills", detail.mobKills],
|
||||||
|
["Player kills", detail.playerKills],
|
||||||
|
["Traveled", `${detail.distanceKm} km`],
|
||||||
|
["Blocks mined", detail.blocksMined.toLocaleString()],
|
||||||
|
["Jumps", detail.jumps.toLocaleString()],
|
||||||
|
["Damage dealt", detail.damageDealt.toLocaleString()],
|
||||||
|
].map(([label, value]) => (
|
||||||
|
<div key={label as string} className="rounded-lg border border-border bg-surface2 p-3">
|
||||||
|
<div className="text-[10px] uppercase tracking-wide text-muted">{label}</div>
|
||||||
|
<div className="mt-0.5 text-lg font-semibold">{value}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 space-y-1 text-[11px] text-muted">
|
||||||
|
<div>UUID: <code>{detail.uuid}</code></div>
|
||||||
|
<div>Last seen: {detail.isOnline ? "online now" : fmtDateTime(detail.lastSeenAt)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,240 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { CalendarClock, Plus, Play, Trash2, Pencil } from "lucide-react";
|
||||||
|
import { api, useApi, toast } from "@/lib/api";
|
||||||
|
import { Card, Button, Badge, Input, Select, Modal, EmptyState, Toggle } from "@/components/ui";
|
||||||
|
import { fmtDateTime } from "@/lib/format";
|
||||||
|
|
||||||
|
type Schedule = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
cron: string;
|
||||||
|
action: "restart" | "backup" | "command" | "announce";
|
||||||
|
payload: string | null;
|
||||||
|
enabled: number;
|
||||||
|
last_run_at: number | null;
|
||||||
|
last_result: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const ACTION_OPTIONS = [
|
||||||
|
{ value: "restart", label: "Restart server (30 s warning)" },
|
||||||
|
{ value: "backup", label: "Create backup" },
|
||||||
|
{ value: "announce", label: "In-game announcement" },
|
||||||
|
{ value: "command", label: "Console command" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const PRESETS = [
|
||||||
|
{ label: "Every day at 5:00", cron: "0 5 * * *" },
|
||||||
|
{ label: "Every 6 hours", cron: "0 */6 * * *" },
|
||||||
|
{ label: "Every Sunday at 4:00", cron: "0 4 * * 0" },
|
||||||
|
{ label: "Every 30 minutes", cron: "*/30 * * * *" },
|
||||||
|
];
|
||||||
|
|
||||||
|
type Draft = { id?: number; name: string; cron: string; action: Schedule["action"]; payload: string };
|
||||||
|
|
||||||
|
const EMPTY_DRAFT: Draft = { name: "", cron: "0 5 * * *", action: "restart", payload: "" };
|
||||||
|
|
||||||
|
export default function SchedulesPage() {
|
||||||
|
const { data: schedules, refresh } = useApi<Schedule[]>("/api/schedules", 15000);
|
||||||
|
const [draft, setDraft] = useState<Draft | null>(null);
|
||||||
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const run = async (label: string, body: Record<string, unknown>, message?: string) => {
|
||||||
|
setBusy(label);
|
||||||
|
try {
|
||||||
|
await api("/api/schedules", { body });
|
||||||
|
if (message) toast(message, "success");
|
||||||
|
void refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveDraft = async () => {
|
||||||
|
if (!draft) return;
|
||||||
|
const body =
|
||||||
|
draft.id === undefined
|
||||||
|
? { op: "create", name: draft.name, cron: draft.cron, action: draft.action, payload: draft.payload || undefined }
|
||||||
|
: { op: "update", id: draft.id, name: draft.name, cron: draft.cron, action: draft.action, payload: draft.payload };
|
||||||
|
setBusy("save");
|
||||||
|
try {
|
||||||
|
await api("/api/schedules", { body });
|
||||||
|
toast(draft.id === undefined ? "Schedule created" : "Schedule updated", "success");
|
||||||
|
setDraft(null);
|
||||||
|
void refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setBusy(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const needsPayload = draft?.action === "command" || draft?.action === "announce";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Schedules</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink2">Automatic restarts, backups, announcements and commands (cron)</p>
|
||||||
|
</div>
|
||||||
|
<Button variant="primary" onClick={() => setDraft({ ...EMPTY_DRAFT })}>
|
||||||
|
<Plus size={14} /> New schedule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card pad={false}>
|
||||||
|
{!schedules || schedules.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<CalendarClock size={28} />}
|
||||||
|
title="No schedules yet"
|
||||||
|
hint="A nightly restart (e.g. daily at 5:00) keeps modded servers fresh, and extra backups never hurt."
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-border text-left text-[11px] uppercase tracking-wide text-muted">
|
||||||
|
<th className="px-4 py-2 font-medium">Name</th>
|
||||||
|
<th className="px-4 py-2 font-medium">When (cron)</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Action</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Last run</th>
|
||||||
|
<th className="px-4 py-2 font-medium">Enabled</th>
|
||||||
|
<th className="px-4 py-2 text-right font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{schedules.map((s) => (
|
||||||
|
<tr key={s.id} className="border-b border-border/50 last:border-0">
|
||||||
|
<td className="px-4 py-2.5 font-medium">{s.name}</td>
|
||||||
|
<td className="px-4 py-2.5 font-mono text-xs text-ink2">{s.cron}</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Badge color="info">{s.action}</Badge>
|
||||||
|
{s.payload && (
|
||||||
|
<span className="ml-2 font-mono text-[11px] text-muted">
|
||||||
|
{s.payload.length > 40 ? s.payload.slice(0, 40) + "…" : s.payload}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5 text-xs text-ink2">
|
||||||
|
{fmtDateTime(s.last_run_at)}
|
||||||
|
{s.last_result?.startsWith("ERROR") && (
|
||||||
|
<span className="ml-1 text-crit" title={s.last_result}>
|
||||||
|
failed
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<Toggle
|
||||||
|
checked={s.enabled === 1}
|
||||||
|
onChange={() => void run(`toggle-${s.id}`, { op: "toggle", id: s.id })}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2.5">
|
||||||
|
<div className="flex justify-end gap-1.5">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
busy={busy === `run-${s.id}`}
|
||||||
|
onClick={() => void run(`run-${s.id}`, { op: "run", id: s.id }, `"${s.name}" executed`)}
|
||||||
|
title="Run now"
|
||||||
|
>
|
||||||
|
<Play size={12} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() =>
|
||||||
|
setDraft({ id: s.id, name: s.name, cron: s.cron, action: s.action, payload: s.payload ?? "" })
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Pencil size={12} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
busy={busy === `delete-${s.id}`}
|
||||||
|
onClick={() => void run(`delete-${s.id}`, { op: "delete", id: s.id }, `"${s.name}" deleted`)}
|
||||||
|
>
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal open={draft !== null} onClose={() => setDraft(null)} title={draft?.id === undefined ? "New schedule" : "Edit schedule"}>
|
||||||
|
{draft && (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">Name</label>
|
||||||
|
<Input
|
||||||
|
value={draft.name}
|
||||||
|
onChange={(e) => setDraft({ ...draft, name: e.target.value })}
|
||||||
|
placeholder="e.g. Nightly restart"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">Action</label>
|
||||||
|
<Select
|
||||||
|
value={draft.action}
|
||||||
|
onChange={(v) => setDraft({ ...draft, action: v as Schedule["action"] })}
|
||||||
|
options={ACTION_OPTIONS}
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{needsPayload && (
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">
|
||||||
|
{draft.action === "announce" ? "Message" : "Command (without /)"}
|
||||||
|
</label>
|
||||||
|
<Input
|
||||||
|
value={draft.payload}
|
||||||
|
onChange={(e) => setDraft({ ...draft, payload: e.target.value })}
|
||||||
|
placeholder={draft.action === "announce" ? "Backup starting soon!" : "save-all"}
|
||||||
|
className="w-full font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">Cron expression</label>
|
||||||
|
<Input
|
||||||
|
value={draft.cron}
|
||||||
|
onChange={(e) => setDraft({ ...draft, cron: e.target.value })}
|
||||||
|
className="w-full font-mono"
|
||||||
|
/>
|
||||||
|
<div className="mt-1.5 flex flex-wrap gap-1.5">
|
||||||
|
{PRESETS.map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.cron}
|
||||||
|
onClick={() => setDraft({ ...draft, cron: p.cron })}
|
||||||
|
className="rounded-md border border-border bg-surface2 px-2 py-0.5 text-[10px] text-ink2 hover:text-ink"
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-[10px] text-muted">minute hour day-of-month month day-of-week (server timezone)</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-1">
|
||||||
|
<Button onClick={() => setDraft(null)}>Cancel</Button>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
busy={busy === "save"}
|
||||||
|
disabled={!draft.name.trim() || (needsPayload && !draft.payload.trim())}
|
||||||
|
onClick={saveDraft}
|
||||||
|
>
|
||||||
|
Save schedule
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,255 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { KeyRound, Webhook, Globe, AlertTriangle } from "lucide-react";
|
||||||
|
import { api, useApi, toast } from "@/lib/api";
|
||||||
|
import { Card, Button, Input, Toggle, Modal } from "@/components/ui";
|
||||||
|
import { fmtBytes } from "@/lib/format";
|
||||||
|
|
||||||
|
type Settings = {
|
||||||
|
registrationWindowMinutes: number;
|
||||||
|
notifyJoins: boolean;
|
||||||
|
discordEnabled: boolean;
|
||||||
|
discordJoins: boolean;
|
||||||
|
discordWebhookUrl: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type WorldInfo = { seed: string | null; sizeBytes: number | null; dimensions: string[]; levelName: string | null };
|
||||||
|
|
||||||
|
export default function SettingsPage() {
|
||||||
|
const { data, refresh } = useApi<Settings>("/api/settings", 0);
|
||||||
|
const { data: me, refresh: refreshMe } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me");
|
||||||
|
const { data: world } = useApi<WorldInfo>("/api/world", 0);
|
||||||
|
|
||||||
|
const [values, setValues] = useState<Settings | null>(null);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
|
||||||
|
const [currentPw, setCurrentPw] = useState("");
|
||||||
|
const [newPw, setNewPw] = useState("");
|
||||||
|
const [newPw2, setNewPw2] = useState("");
|
||||||
|
const [changing, setChanging] = useState(false);
|
||||||
|
|
||||||
|
const [resetOpen, setResetOpen] = useState(false);
|
||||||
|
const [resetConfirm, setResetConfirm] = useState("");
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (data) setValues(data);
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
const save = async () => {
|
||||||
|
if (!values) return;
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
await api("/api/settings", { body: values });
|
||||||
|
toast("Settings saved", "success");
|
||||||
|
void refresh();
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const changePassword = async () => {
|
||||||
|
if (newPw !== newPw2) {
|
||||||
|
toast("New passwords don't match", "error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setChanging(true);
|
||||||
|
try {
|
||||||
|
await api("/api/auth/password", { body: { current: currentPw, next: newPw } });
|
||||||
|
toast("Panel password changed", "success");
|
||||||
|
setCurrentPw("");
|
||||||
|
setNewPw("");
|
||||||
|
setNewPw2("");
|
||||||
|
void refreshMe();
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setChanging(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const resetWorld = async () => {
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
const res = await api<{ archived: string }>("/api/world", {
|
||||||
|
body: { op: "reset", confirm: resetConfirm },
|
||||||
|
});
|
||||||
|
toast(`World reset — old world saved as ${res.archived}`, "warn");
|
||||||
|
setResetOpen(false);
|
||||||
|
setResetConfirm("");
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-bold">Settings</h1>
|
||||||
|
<p className="mt-0.5 text-xs text-ink2">Panel account, authentication window, notifications, danger zone</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{me?.mustChange && (
|
||||||
|
<div className="flex items-center gap-2 rounded-xl border border-warn/40 bg-warn/10 px-4 py-3 text-sm text-warn">
|
||||||
|
<AlertTriangle size={15} /> You're still on the initial admin password — change it below.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid gap-4 lg:grid-cols-2">
|
||||||
|
{/* password */}
|
||||||
|
<Card title="Panel password">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">Current password</label>
|
||||||
|
<Input type="password" value={currentPw} onChange={(e) => setCurrentPw(e.target.value)} className="w-full" />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">New password</label>
|
||||||
|
<Input type="password" value={newPw} onChange={(e) => setNewPw(e.target.value)} className="w-full" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">Repeat new password</label>
|
||||||
|
<Input type="password" value={newPw2} onChange={(e) => setNewPw2(e.target.value)} className="w-full" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
onClick={changePassword}
|
||||||
|
busy={changing}
|
||||||
|
disabled={!currentPw || newPw.length < 8 || newPw !== newPw2}
|
||||||
|
>
|
||||||
|
<KeyRound size={14} /> Change password
|
||||||
|
</Button>
|
||||||
|
<p className="text-[10px] text-muted">At least 8 characters.</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* auth window */}
|
||||||
|
<Card title="Player registration window">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">Window length (minutes)</label>
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0.5}
|
||||||
|
max={120}
|
||||||
|
step={0.5}
|
||||||
|
value={values?.registrationWindowMinutes ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues((v) => (v ? { ...v, registrationWindowMinutes: Number(e.target.value) } : v))
|
||||||
|
}
|
||||||
|
className="w-32 font-mono"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] leading-relaxed text-muted">
|
||||||
|
After you approve a username, the player has this long to join and <code>/register</code>. When
|
||||||
|
it lapses the name is automatically removed from the whitelist again.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={values?.notifyJoins ?? true}
|
||||||
|
onChange={(v) => setValues((s) => (s ? { ...s, notifyJoins: v } : s))}
|
||||||
|
label="Notify on player joins/leaves"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* discord */}
|
||||||
|
<Card title="Discord alerts">
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">Webhook URL</label>
|
||||||
|
<Input
|
||||||
|
value={values?.discordWebhookUrl ?? ""}
|
||||||
|
onChange={(e) => setValues((v) => (v ? { ...v, discordWebhookUrl: e.target.value } : v))}
|
||||||
|
placeholder="https://discord.com/api/webhooks/…"
|
||||||
|
className="w-full font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<p className="mt-1 text-[10px] text-muted">
|
||||||
|
Discord → Server settings → Integrations → Webhooks → New webhook → Copy URL
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Toggle
|
||||||
|
checked={values?.discordEnabled ?? true}
|
||||||
|
onChange={(v) => setValues((s) => (s ? { ...s, discordEnabled: v } : s))}
|
||||||
|
label="Send alerts to Discord"
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
checked={values?.discordJoins ?? true}
|
||||||
|
onChange={(v) => setValues((s) => (s ? { ...s, discordJoins: v } : s))}
|
||||||
|
label="Include joins/leaves"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={async () => {
|
||||||
|
await save();
|
||||||
|
try {
|
||||||
|
await api("/api/settings/test-discord", { method: "POST" });
|
||||||
|
toast("Test sent — check your Discord channel", "success");
|
||||||
|
} catch (err) {
|
||||||
|
toast((err as Error).message, "error");
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Webhook size={13} /> Save & send test
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* danger zone */}
|
||||||
|
<Card title="Danger zone">
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Globe size={15} className="text-muted" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">Reset world</div>
|
||||||
|
<div className="text-xs text-muted">
|
||||||
|
Current world: {world?.levelName ?? "world"} ({fmtBytes(world?.sizeBytes)}) — archived, then a
|
||||||
|
fresh world generates. A safety backup is taken first.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="danger" size="sm" onClick={() => setResetOpen(true)}>
|
||||||
|
Reset…
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button variant="primary" onClick={save} busy={saving}>
|
||||||
|
Save settings
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Modal open={resetOpen} onClose={() => setResetOpen(false)} title="Reset the world?">
|
||||||
|
<div className="space-y-3 text-sm text-ink2">
|
||||||
|
<p>
|
||||||
|
The server stops, the current world folder is archived inside the data directory, and a brand-new
|
||||||
|
world generates on next start. A backup is taken first, but treat this as destructive.
|
||||||
|
</p>
|
||||||
|
<p className="text-xs">
|
||||||
|
Type <code className="text-crit">reset the world</code> to confirm:
|
||||||
|
</p>
|
||||||
|
<Input
|
||||||
|
value={resetConfirm}
|
||||||
|
onChange={(e) => setResetConfirm(e.target.value)}
|
||||||
|
placeholder="reset the world"
|
||||||
|
className="w-full font-mono"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4 flex justify-end gap-2">
|
||||||
|
<Button onClick={() => setResetOpen(false)}>Cancel</Button>
|
||||||
|
<Button variant="danger" disabled={resetConfirm !== "reset the world"} busy={resetting} onClick={resetWorld}>
|
||||||
|
Reset world
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 500);
|
||||||
|
const type = req.nextUrl.searchParams.get("type");
|
||||||
|
const rows = type
|
||||||
|
? db().prepare("SELECT * FROM activity WHERE type = ? ORDER BY ts DESC LIMIT ?").all(type, limit)
|
||||||
|
: db().prepare("SELECT * FROM activity ORDER BY ts DESC LIMIT ?").all(limit);
|
||||||
|
return ok(rows);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import {
|
||||||
|
listApproved,
|
||||||
|
approvePlayer,
|
||||||
|
revokePlayer,
|
||||||
|
reopenWindow,
|
||||||
|
resetPassword,
|
||||||
|
forceReregister,
|
||||||
|
importFromWhitelist,
|
||||||
|
} from "@/server/authflow";
|
||||||
|
import { whitelistList, easyauthList } from "@/server/mc";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return handle(async () => {
|
||||||
|
let whitelist: string[] | null = null;
|
||||||
|
let registered: string[] | null = null;
|
||||||
|
try {
|
||||||
|
whitelist = await whitelistList();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
registered = await easyauthList();
|
||||||
|
} catch {}
|
||||||
|
return ok({ approved: listApproved(), whitelist, registered });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
op: "approve" | "revoke" | "reopen" | "reset-password" | "force-reregister" | "import-whitelist";
|
||||||
|
username?: string;
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const body = await readJson<Body>(req);
|
||||||
|
switch (body.op) {
|
||||||
|
case "approve": {
|
||||||
|
if (!body.username) return fail("Username required");
|
||||||
|
const player = await approvePlayer(body.username.trim(), body.note);
|
||||||
|
return ok(player);
|
||||||
|
}
|
||||||
|
case "revoke": {
|
||||||
|
if (!body.username) return fail("Username required");
|
||||||
|
await revokePlayer(body.username);
|
||||||
|
return ok({ revoked: true });
|
||||||
|
}
|
||||||
|
case "reopen": {
|
||||||
|
if (!body.username) return fail("Username required");
|
||||||
|
await reopenWindow(body.username);
|
||||||
|
return ok({ reopened: true });
|
||||||
|
}
|
||||||
|
case "reset-password": {
|
||||||
|
if (!body.username) return fail("Username required");
|
||||||
|
const temp = await resetPassword(body.username);
|
||||||
|
return ok({ tempPassword: temp });
|
||||||
|
}
|
||||||
|
case "force-reregister": {
|
||||||
|
if (!body.username) return fail("Username required");
|
||||||
|
await forceReregister(body.username);
|
||||||
|
return ok({ pending: true });
|
||||||
|
}
|
||||||
|
case "import-whitelist": {
|
||||||
|
const added = await importFromWhitelist();
|
||||||
|
return ok({ imported: added });
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fail("Unknown operation");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import { verifyPassword } from "@/server/passwords";
|
||||||
|
import { createSessionToken, SESSION_COOKIE, sessionCookieAttributes } from "@/server/session";
|
||||||
|
import { env } from "@/server/env";
|
||||||
|
import { fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
/** Simple in-memory login rate limit: 10 attempts / 15 min per IP. */
|
||||||
|
const attempts = new Map<string, { count: number; resetAt: number }>();
|
||||||
|
|
||||||
|
function rateLimited(ip: string): boolean {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = attempts.get(ip);
|
||||||
|
if (!entry || entry.resetAt < now) {
|
||||||
|
attempts.set(ip, { count: 1, resetAt: now + 15 * 60 * 1000 });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
entry.count++;
|
||||||
|
return entry.count > 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const ip = req.headers.get("x-forwarded-for")?.split(",")[0].trim() || "local";
|
||||||
|
if (rateLimited(ip)) return fail("Too many attempts — try again in 15 minutes", 429);
|
||||||
|
|
||||||
|
const { username, password } = await readJson<{ username?: string; password?: string }>(req);
|
||||||
|
if (!username || !password) return fail("Username and password required");
|
||||||
|
|
||||||
|
const user = db()
|
||||||
|
.prepare("SELECT * FROM panel_users WHERE username = ?")
|
||||||
|
.get(username.toLowerCase()) as { username: string; pass_hash: string; must_change: number } | undefined;
|
||||||
|
|
||||||
|
if (!user || !verifyPassword(password, user.pass_hash)) {
|
||||||
|
return fail("Wrong username or password", 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
attempts.delete(ip);
|
||||||
|
const token = await createSessionToken(env.sessionSecret, user.username);
|
||||||
|
const res = NextResponse.json({ ok: true, data: { mustChange: user.must_change === 1 } });
|
||||||
|
res.headers.set("Set-Cookie", `${SESSION_COOKIE}=${token}; ${sessionCookieAttributes()}`);
|
||||||
|
return res;
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { SESSION_COOKIE } from "@/server/session";
|
||||||
|
|
||||||
|
export function POST() {
|
||||||
|
const res = NextResponse.json({ ok: true });
|
||||||
|
res.headers.set("Set-Cookie", `${SESSION_COOKIE}=; Path=/; HttpOnly; Max-Age=0`);
|
||||||
|
return res;
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import { verifySessionToken, SESSION_COOKIE } from "@/server/session";
|
||||||
|
import { env } from "@/server/env";
|
||||||
|
import { ok, fail, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const session = await verifySessionToken(env.sessionSecret, req.cookies.get(SESSION_COOKIE)?.value);
|
||||||
|
if (!session) return fail("Unauthorized", 401);
|
||||||
|
const user = db()
|
||||||
|
.prepare("SELECT username, must_change FROM panel_users WHERE username = ?")
|
||||||
|
.get(session.u) as { username: string; must_change: number } | undefined;
|
||||||
|
if (!user) return fail("Unauthorized", 401);
|
||||||
|
return ok({ username: user.username, mustChange: user.must_change === 1 });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import { hashPassword, verifyPassword } from "@/server/passwords";
|
||||||
|
import { verifySessionToken, SESSION_COOKIE } from "@/server/session";
|
||||||
|
import { env } from "@/server/env";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const session = await verifySessionToken(env.sessionSecret, req.cookies.get(SESSION_COOKIE)?.value);
|
||||||
|
if (!session) return fail("Unauthorized", 401);
|
||||||
|
|
||||||
|
const { current, next } = await readJson<{ current?: string; next?: string }>(req);
|
||||||
|
if (!current || !next) return fail("Current and new password required");
|
||||||
|
if (next.length < 8) return fail("New password must be at least 8 characters");
|
||||||
|
|
||||||
|
const user = db()
|
||||||
|
.prepare("SELECT pass_hash FROM panel_users WHERE username = ?")
|
||||||
|
.get(session.u) as { pass_hash: string } | undefined;
|
||||||
|
if (!user || !verifyPassword(current, user.pass_hash)) {
|
||||||
|
return fail("Current password is wrong", 401);
|
||||||
|
}
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE panel_users SET pass_hash = ?, must_change = 0 WHERE username = ?")
|
||||||
|
.run(hashPassword(next), session.u);
|
||||||
|
return ok({ changed: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { getBackupStream, deleteBackup } from "@/server/backups";
|
||||||
|
import { ok, fail, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const name = req.nextUrl.searchParams.get("name");
|
||||||
|
if (!name) return fail("Missing backup name");
|
||||||
|
try {
|
||||||
|
const { stream, size } = getBackupStream(name);
|
||||||
|
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/gzip",
|
||||||
|
"Content-Length": String(size),
|
||||||
|
"Content-Disposition": `attachment; filename="${name}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return fail((err as Error).message, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const name = req.nextUrl.searchParams.get("name");
|
||||||
|
if (!name) return fail("Missing backup name");
|
||||||
|
deleteBackup(name);
|
||||||
|
return ok({ deleted: name });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { restoreBackup } from "@/server/backups";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const { name, confirm } = await readJson<{ name?: string; confirm?: string }>(req);
|
||||||
|
if (!name) return fail("Missing backup name");
|
||||||
|
if (confirm !== "restore") return fail('Type "restore" to confirm');
|
||||||
|
await restoreBackup(name);
|
||||||
|
return ok({ restored: name });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { listBackups, triggerBackup, backupsDirSize } from "@/server/backups";
|
||||||
|
import { readEnvFile } from "@/server/envfile";
|
||||||
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return handle(async () => {
|
||||||
|
const envValues = readEnvFile();
|
||||||
|
return ok({
|
||||||
|
backups: listBackups(),
|
||||||
|
totalSize: backupsDirSize(),
|
||||||
|
interval: envValues["BACKUP_INTERVAL"] ?? "24h",
|
||||||
|
retentionDays: envValues["BACKUP_RETENTION_DAYS"] ?? "7",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
return handle(async () => {
|
||||||
|
const name = await triggerBackup();
|
||||||
|
return ok({ created: name });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { applyMcConfig, applyBackupConfig } from "@/server/compose";
|
||||||
|
import { safetyBackup } from "@/server/backups";
|
||||||
|
import { notify } from "@/server/notify";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const { service, backupFirst } = await readJson<{ service?: string; backupFirst?: boolean }>(req);
|
||||||
|
if (backupFirst) await safetyBackup("config apply");
|
||||||
|
const res = service === "backup" ? await applyBackupConfig() : await applyMcConfig();
|
||||||
|
if (!res.ok) {
|
||||||
|
notify("error", "Config apply failed", res.output.slice(-800));
|
||||||
|
return fail(`compose failed: ${res.output.slice(-400)}`, 500);
|
||||||
|
}
|
||||||
|
notify("success", "Configuration applied", "Container recreated with the new settings.", { discord: false });
|
||||||
|
return ok({ output: res.output.slice(-1000) });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { readEnvFile, writeEnvFile, EDITABLE_ENV_KEYS } from "@/server/envfile";
|
||||||
|
import { logActivity } from "@/server/db";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return handle(async () => {
|
||||||
|
const all = readEnvFile();
|
||||||
|
const values: Record<string, string> = {};
|
||||||
|
for (const key of EDITABLE_ENV_KEYS) values[key] = all[key] ?? "";
|
||||||
|
return ok(values);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const { updates } = await readJson<{ updates?: Record<string, string> }>(req);
|
||||||
|
if (!updates || typeof updates !== "object") return fail("Missing updates");
|
||||||
|
const allowed = new Set<string>(EDITABLE_ENV_KEYS);
|
||||||
|
const filtered: Record<string, string> = {};
|
||||||
|
for (const [key, value] of Object.entries(updates)) {
|
||||||
|
if (!allowed.has(key)) return fail(`Key not editable: ${key}`);
|
||||||
|
if (/[\r\n]/.test(value)) return fail(`Invalid value for ${key}`);
|
||||||
|
filtered[key] = value;
|
||||||
|
}
|
||||||
|
writeEnvFile(filtered);
|
||||||
|
logActivity("config", null, Object.keys(filtered).join(", "));
|
||||||
|
return ok({ updated: Object.keys(filtered), needsApply: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { rcon, stripColors } from "@/server/rcon";
|
||||||
|
import { logActivity } from "@/server/db";
|
||||||
|
import { pushLogLine } from "@/server/bus";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const { command } = await readJson<{ command?: string }>(req);
|
||||||
|
if (!command?.trim()) return fail("Empty command");
|
||||||
|
const cmd = command.trim().replace(/^\//, "");
|
||||||
|
const output = stripColors(await rcon().exec(cmd));
|
||||||
|
logActivity("console", null, cmd);
|
||||||
|
// Echo the exchange into the live console stream so it reads like a terminal.
|
||||||
|
pushLogLine(`> ${cmd}`);
|
||||||
|
if (output.trim()) for (const line of output.split("\n")) pushLogLine(line);
|
||||||
|
return ok({ output });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { Readable } from "node:stream";
|
||||||
|
import { fileStream } from "@/server/files";
|
||||||
|
import { fail } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const p = req.nextUrl.searchParams.get("path");
|
||||||
|
if (!p) return fail("Missing path");
|
||||||
|
try {
|
||||||
|
const { stream, size, name } = fileStream(p);
|
||||||
|
return new Response(Readable.toWeb(stream) as ReadableStream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/octet-stream",
|
||||||
|
"Content-Length": String(size),
|
||||||
|
"Content-Disposition": `attachment; filename="${name.replace(/"/g, "")}"`,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return fail((err as Error).message, 404);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { listDir, readTextFile, writeTextFile, deletePath, makeDir } from "@/server/files";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const dir = req.nextUrl.searchParams.get("dir");
|
||||||
|
const file = req.nextUrl.searchParams.get("file");
|
||||||
|
if (file !== null) return ok(readTextFile(file));
|
||||||
|
return ok(listDir(dir ?? ""));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body =
|
||||||
|
| { op: "write"; path: string; content: string }
|
||||||
|
| { op: "mkdir"; path: string }
|
||||||
|
| { op: "delete"; path: string };
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const body = await readJson<Body>(req);
|
||||||
|
if (!body.path && body.path !== "") return fail("Missing path");
|
||||||
|
switch (body.op) {
|
||||||
|
case "write":
|
||||||
|
writeTextFile(body.path, body.content ?? "");
|
||||||
|
return ok({ written: body.path });
|
||||||
|
case "mkdir":
|
||||||
|
makeDir(body.path);
|
||||||
|
return ok({ created: body.path });
|
||||||
|
case "delete":
|
||||||
|
deletePath(body.path);
|
||||||
|
return ok({ deleted: body.path });
|
||||||
|
default:
|
||||||
|
return fail("Unknown operation");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import path from "node:path";
|
||||||
|
import { writeBinaryFile } from "@/server/files";
|
||||||
|
import { ok, fail, handle } from "@/server/api";
|
||||||
|
|
||||||
|
const MAX_UPLOAD = 256 * 1024 * 1024; // mod jars and datapacks, not world dumps
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const form = await req.formData();
|
||||||
|
const dir = (form.get("dir") as string) ?? "";
|
||||||
|
const file = form.get("file");
|
||||||
|
if (!(file instanceof File)) return fail("Missing file");
|
||||||
|
if (file.size > MAX_UPLOAD) return fail("File too large (256 MB max)");
|
||||||
|
const name = path.basename(file.name);
|
||||||
|
if (!name || name.startsWith(".")) return fail("Invalid file name");
|
||||||
|
const buf = Buffer.from(await file.arrayBuffer());
|
||||||
|
writeBinaryFile(path.join(dir, name), buf);
|
||||||
|
return ok({ uploaded: name, size: file.size });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
export function GET() {
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { sparkHealthReport } from "@/server/mc";
|
||||||
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
return handle(async () => {
|
||||||
|
const report = await sparkHealthReport();
|
||||||
|
return ok({ report });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { db } from "@/server/db";
|
||||||
|
import { sparkPings, listOnlinePlayers } from "@/server/mc";
|
||||||
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lag diagnostics: separates "the server is lagging" (low TPS / high MSPT /
|
||||||
|
* high CPU) from "a player's connection is lagging" (high ping) and renders
|
||||||
|
* a verdict the admin can read at a glance.
|
||||||
|
*/
|
||||||
|
export async function GET() {
|
||||||
|
return handle(async () => {
|
||||||
|
const recent = db()
|
||||||
|
.prepare("SELECT tps, mspt, cpu FROM metrics WHERE ts > ? ORDER BY ts DESC LIMIT 18")
|
||||||
|
.all(Date.now() - 3 * 60 * 1000) as Array<{ tps: number | null; mspt: number | null; cpu: number | null }>;
|
||||||
|
|
||||||
|
const avg = (xs: number[]) => (xs.length ? xs.reduce((a, b) => a + b, 0) / xs.length : null);
|
||||||
|
const tps = avg(recent.map((r) => r.tps).filter((v): v is number => v !== null));
|
||||||
|
const mspt = avg(recent.map((r) => r.mspt).filter((v): v is number => v !== null));
|
||||||
|
const cpu = avg(recent.map((r) => r.cpu).filter((v): v is number => v !== null));
|
||||||
|
|
||||||
|
let livePings: Record<string, number> = {};
|
||||||
|
let online: string[] = [];
|
||||||
|
try {
|
||||||
|
[livePings, online] = await Promise.all([
|
||||||
|
sparkPings(),
|
||||||
|
listOnlinePlayers().then((l) => l.names),
|
||||||
|
]);
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// ping history per player (last hour) for sparklines
|
||||||
|
const history = db()
|
||||||
|
.prepare("SELECT player, ts, ping FROM ping_samples WHERE ts > ? ORDER BY ts ASC")
|
||||||
|
.all(Date.now() - 60 * 60 * 1000) as Array<{ player: string; ts: number; ping: number }>;
|
||||||
|
const historyByPlayer: Record<string, Array<{ ts: number; ping: number }>> = {};
|
||||||
|
for (const h of history) {
|
||||||
|
(historyByPlayer[h.player] ??= []).push({ ts: h.ts, ping: h.ping });
|
||||||
|
}
|
||||||
|
|
||||||
|
const serverHealthy = (tps === null || tps >= 18) && (mspt === null || mspt <= 40);
|
||||||
|
const players = online.map((name) => {
|
||||||
|
const ping = livePings[name] ?? null;
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
ping,
|
||||||
|
history: historyByPlayer[name] ?? [],
|
||||||
|
verdict:
|
||||||
|
ping === null
|
||||||
|
? "unknown"
|
||||||
|
: ping > 250
|
||||||
|
? "bad-connection"
|
||||||
|
: ping > 120
|
||||||
|
? "weak-connection"
|
||||||
|
: "good",
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
let verdict: string;
|
||||||
|
if (tps === null && mspt === null) {
|
||||||
|
verdict = "no-data";
|
||||||
|
} else if (!serverHealthy) {
|
||||||
|
verdict = "server-lagging";
|
||||||
|
} else if (players.some((p) => p.verdict === "bad-connection" || p.verdict === "weak-connection")) {
|
||||||
|
verdict = "player-connections";
|
||||||
|
} else {
|
||||||
|
verdict = "all-good";
|
||||||
|
}
|
||||||
|
|
||||||
|
return ok({ tps, mspt, cpu, serverHealthy, verdict, players });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const RANGES: Record<string, { ms: number; bucketMs: number }> = {
|
||||||
|
"1h": { ms: 60 * 60 * 1000, bucketMs: 10 * 1000 },
|
||||||
|
"6h": { ms: 6 * 60 * 60 * 1000, bucketMs: 60 * 1000 },
|
||||||
|
"24h": { ms: 24 * 60 * 60 * 1000, bucketMs: 5 * 60 * 1000 },
|
||||||
|
"7d": { ms: 7 * 24 * 60 * 60 * 1000, bucketMs: 30 * 60 * 1000 },
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const range = RANGES[req.nextUrl.searchParams.get("range") ?? "1h"] ?? RANGES["1h"];
|
||||||
|
const since = Date.now() - range.ms;
|
||||||
|
// Bucket-average so long ranges stay light on the wire.
|
||||||
|
const rows = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT (ts / ?) * ? AS bucket,
|
||||||
|
AVG(cpu) AS cpu, AVG(mem_used) AS mem_used, MAX(mem_limit) AS mem_limit,
|
||||||
|
MAX(net_rx) AS net_rx, MAX(net_tx) AS net_tx,
|
||||||
|
AVG(tps) AS tps, AVG(mspt) AS mspt, MAX(players) AS players,
|
||||||
|
AVG(disk_used) AS disk_used, AVG(disk_free) AS disk_free
|
||||||
|
FROM metrics WHERE ts > ?
|
||||||
|
GROUP BY bucket ORDER BY bucket ASC`
|
||||||
|
)
|
||||||
|
.all(range.bucketMs, range.bucketMs, since);
|
||||||
|
return ok(rows);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { applyMcConfig } from "@/server/compose";
|
||||||
|
import { notify } from "@/server/notify";
|
||||||
|
import { ok, fail, handle } from "@/server/api";
|
||||||
|
|
||||||
|
/** Recreate the mc container so the itzg image re-resolves MODRINTH_PROJECTS. */
|
||||||
|
export async function POST() {
|
||||||
|
return handle(async () => {
|
||||||
|
const res = await applyMcConfig();
|
||||||
|
if (!res.ok) {
|
||||||
|
notify("error", "Mod apply failed", res.output.slice(-800));
|
||||||
|
return fail(`compose failed: ${res.output.slice(-400)}`, 500);
|
||||||
|
}
|
||||||
|
notify("success", "Mod changes applied", "Server container recreated; mods re-resolved.", { discord: false });
|
||||||
|
return ok({ output: res.output.slice(-1000) });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { addManagedMod, removeManagedMod, toggleMod, deleteModFile } from "@/server/modrinth";
|
||||||
|
import { logActivity } from "@/server/db";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
type Body = {
|
||||||
|
op: "add" | "remove" | "toggle" | "delete";
|
||||||
|
slug?: string;
|
||||||
|
file?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const body = await readJson<Body>(req);
|
||||||
|
switch (body.op) {
|
||||||
|
case "add": {
|
||||||
|
if (!body.slug) return fail("Missing slug");
|
||||||
|
const managed = addManagedMod(body.slug);
|
||||||
|
logActivity("mod_add", null, body.slug);
|
||||||
|
return ok({ managed, needsApply: true });
|
||||||
|
}
|
||||||
|
case "remove": {
|
||||||
|
if (!body.slug) return fail("Missing slug");
|
||||||
|
const managed = removeManagedMod(body.slug);
|
||||||
|
logActivity("mod_remove", null, body.slug);
|
||||||
|
return ok({ managed, needsApply: true });
|
||||||
|
}
|
||||||
|
case "toggle": {
|
||||||
|
if (!body.file) return fail("Missing file");
|
||||||
|
toggleMod(body.file);
|
||||||
|
return ok({ needsRestart: true });
|
||||||
|
}
|
||||||
|
case "delete": {
|
||||||
|
if (!body.file) return fail("Missing file");
|
||||||
|
deleteModFile(body.file);
|
||||||
|
logActivity("mod_remove", null, body.file);
|
||||||
|
return ok({ needsRestart: true });
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fail("Unknown operation");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
import { listInstalledMods, managedSlugs, currentMcVersion } from "@/server/modrinth";
|
||||||
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return handle(async () => {
|
||||||
|
return ok({
|
||||||
|
installed: await listInstalledMods(),
|
||||||
|
managed: managedSlugs(),
|
||||||
|
mcVersion: currentMcVersion(),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { searchMods } from "@/server/modrinth";
|
||||||
|
import { ok, fail, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const q = req.nextUrl.searchParams.get("q")?.trim();
|
||||||
|
if (!q) return fail("Missing query");
|
||||||
|
return ok(await searchMods(q));
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const limit = Math.min(Number(req.nextUrl.searchParams.get("limit") ?? 50), 200);
|
||||||
|
const rows = db().prepare("SELECT * FROM notifications ORDER BY ts DESC LIMIT ?").all(limit);
|
||||||
|
const unread = (db().prepare("SELECT COUNT(*) AS n FROM notifications WHERE read = 0").get() as { n: number }).n;
|
||||||
|
return ok({ notifications: rows, unread });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body = { op: "read"; id: number } | { op: "read-all" } | { op: "clear" };
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const body = await readJson<Body>(req);
|
||||||
|
switch (body.op) {
|
||||||
|
case "read":
|
||||||
|
db().prepare("UPDATE notifications SET read = 1 WHERE id = ?").run(body.id);
|
||||||
|
return ok({});
|
||||||
|
case "read-all":
|
||||||
|
db().prepare("UPDATE notifications SET read = 1").run();
|
||||||
|
return ok({});
|
||||||
|
case "clear":
|
||||||
|
db().prepare("DELETE FROM notifications").run();
|
||||||
|
return ok({});
|
||||||
|
default:
|
||||||
|
return fail("Unknown operation");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { listOnlinePlayers } from "@/server/mc";
|
||||||
|
import { readAllPlayerStats } from "@/server/playerstats";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return handle(async () => {
|
||||||
|
let online: string[] = [];
|
||||||
|
let max = 0;
|
||||||
|
try {
|
||||||
|
const list = await listOnlinePlayers();
|
||||||
|
online = list.names;
|
||||||
|
max = list.max;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
const stats = readAllPlayerStats();
|
||||||
|
|
||||||
|
// latest ping per player (last 10 minutes)
|
||||||
|
const pings = db()
|
||||||
|
.prepare(
|
||||||
|
`SELECT player, ping FROM ping_samples
|
||||||
|
WHERE ts > ? AND id IN (SELECT MAX(id) FROM ping_samples GROUP BY player)`
|
||||||
|
)
|
||||||
|
.all(Date.now() - 10 * 60 * 1000) as Array<{ player: string; ping: number }>;
|
||||||
|
const pingMap = Object.fromEntries(pings.map((p) => [p.player.toLowerCase(), p.ping]));
|
||||||
|
|
||||||
|
const approved = db()
|
||||||
|
.prepare("SELECT username, status, last_seen_at FROM approved_players")
|
||||||
|
.all() as Array<{ username: string; status: string; last_seen_at: number | null }>;
|
||||||
|
const approvedMap = Object.fromEntries(approved.map((a) => [a.username.toLowerCase(), a]));
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
online,
|
||||||
|
max,
|
||||||
|
players: stats.map((s) => ({
|
||||||
|
...s,
|
||||||
|
isOnline: online.some((n) => n.toLowerCase() === s.name.toLowerCase()),
|
||||||
|
ping: pingMap[s.name.toLowerCase()] ?? null,
|
||||||
|
authStatus: approvedMap[s.name.toLowerCase()]?.status ?? null,
|
||||||
|
lastSeenAt: approvedMap[s.name.toLowerCase()]?.last_seen_at ?? null,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import { validateCron, reloadSchedules, runScheduleAction, type Schedule } from "@/server/scheduler";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
const ACTIONS = ["restart", "backup", "command", "announce"] as const;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return handle(async () => {
|
||||||
|
return ok(db().prepare("SELECT * FROM schedules ORDER BY id ASC").all());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
type Body =
|
||||||
|
| { op: "create"; name: string; cron: string; action: Schedule["action"]; payload?: string }
|
||||||
|
| { op: "update"; id: number; name?: string; cron?: string; action?: Schedule["action"]; payload?: string }
|
||||||
|
| { op: "toggle"; id: number }
|
||||||
|
| { op: "delete"; id: number }
|
||||||
|
| { op: "run"; id: number };
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const body = await readJson<Body>(req);
|
||||||
|
switch (body.op) {
|
||||||
|
case "create": {
|
||||||
|
if (!body.name?.trim()) return fail("Name required");
|
||||||
|
if (!validateCron(body.cron)) return fail("Invalid cron expression");
|
||||||
|
if (!ACTIONS.includes(body.action)) return fail("Invalid action");
|
||||||
|
db()
|
||||||
|
.prepare("INSERT INTO schedules(name, cron, action, payload, created_at) VALUES(?, ?, ?, ?, ?)")
|
||||||
|
.run(body.name.trim(), body.cron, body.action, body.payload ?? null, Date.now());
|
||||||
|
reloadSchedules();
|
||||||
|
return ok({ created: true });
|
||||||
|
}
|
||||||
|
case "update": {
|
||||||
|
const row = db().prepare("SELECT * FROM schedules WHERE id = ?").get(body.id) as Schedule | undefined;
|
||||||
|
if (!row) return fail("Schedule not found", 404);
|
||||||
|
const cronExpr = body.cron ?? row.cron;
|
||||||
|
if (!validateCron(cronExpr)) return fail("Invalid cron expression");
|
||||||
|
const action = body.action ?? row.action;
|
||||||
|
if (!ACTIONS.includes(action)) return fail("Invalid action");
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE schedules SET name = ?, cron = ?, action = ?, payload = ? WHERE id = ?")
|
||||||
|
.run(body.name ?? row.name, cronExpr, action, body.payload ?? row.payload, body.id);
|
||||||
|
reloadSchedules();
|
||||||
|
return ok({ updated: true });
|
||||||
|
}
|
||||||
|
case "toggle": {
|
||||||
|
db().prepare("UPDATE schedules SET enabled = 1 - enabled WHERE id = ?").run(body.id);
|
||||||
|
reloadSchedules();
|
||||||
|
return ok({ toggled: true });
|
||||||
|
}
|
||||||
|
case "delete": {
|
||||||
|
db().prepare("DELETE FROM schedules WHERE id = ?").run(body.id);
|
||||||
|
reloadSchedules();
|
||||||
|
return ok({ deleted: true });
|
||||||
|
}
|
||||||
|
case "run": {
|
||||||
|
const row = db().prepare("SELECT * FROM schedules WHERE id = ?").get(body.id) as Schedule | undefined;
|
||||||
|
if (!row) return fail("Schedule not found", 404);
|
||||||
|
const result = await runScheduleAction(row.action, row.payload);
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE schedules SET last_run_at = ?, last_result = ? WHERE id = ?")
|
||||||
|
.run(Date.now(), result.slice(0, 1000), row.id);
|
||||||
|
return ok({ result });
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return fail("Unknown operation");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { powerContainer, type PowerAction } from "@/server/docker";
|
||||||
|
import { logActivity } from "@/server/db";
|
||||||
|
import { notify } from "@/server/notify";
|
||||||
|
import { env } from "@/server/env";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
const ACTIONS: PowerAction[] = ["start", "stop", "restart", "kill"];
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const { action } = await readJson<{ action?: PowerAction }>(req);
|
||||||
|
if (!action || !ACTIONS.includes(action)) return fail("Invalid power action");
|
||||||
|
await powerContainer(env.mcContainer, action);
|
||||||
|
logActivity("power", null, action);
|
||||||
|
notify("info", `Server ${action} requested from the panel`, "", { discord: false });
|
||||||
|
return ok({ action });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { inspectContainer, containerEnv } from "@/server/docker";
|
||||||
|
import { listOnlinePlayers } from "@/server/mc";
|
||||||
|
import { db } from "@/server/db";
|
||||||
|
import { env } from "@/server/env";
|
||||||
|
import { getWorldSizeBytes } from "@/server/sampler";
|
||||||
|
import { readWorldInfo } from "@/server/playerstats";
|
||||||
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return handle(async () => {
|
||||||
|
let container;
|
||||||
|
let dockerAvailable = true;
|
||||||
|
try {
|
||||||
|
container = await inspectContainer(env.mcContainer);
|
||||||
|
} catch {
|
||||||
|
dockerAvailable = false;
|
||||||
|
container = { exists: false, running: false, status: "unknown" as const };
|
||||||
|
}
|
||||||
|
|
||||||
|
let players: { online: number; max: number; names: string[] } = { online: 0, max: 0, names: [] };
|
||||||
|
let version: string | null = null;
|
||||||
|
let serverType: string | null = null;
|
||||||
|
if (container.running) {
|
||||||
|
try {
|
||||||
|
players = await listOnlinePlayers();
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
const cenv = await containerEnv(env.mcContainer);
|
||||||
|
version = cenv["VERSION"] ?? null;
|
||||||
|
serverType = cenv["TYPE"] ?? null;
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const latest = db()
|
||||||
|
.prepare("SELECT * FROM metrics ORDER BY ts DESC LIMIT 1")
|
||||||
|
.get() as Record<string, number | null> | undefined;
|
||||||
|
|
||||||
|
const pendingCount = (
|
||||||
|
db().prepare("SELECT COUNT(*) AS n FROM approved_players WHERE status = 'pending'").get() as { n: number }
|
||||||
|
).n;
|
||||||
|
|
||||||
|
return ok({
|
||||||
|
dockerAvailable,
|
||||||
|
container,
|
||||||
|
players,
|
||||||
|
version,
|
||||||
|
serverType,
|
||||||
|
metrics: latest ?? null,
|
||||||
|
pendingApprovals: pendingCount,
|
||||||
|
world: readWorldInfo(getWorldSizeBytes()),
|
||||||
|
uptimeMs:
|
||||||
|
container.running && "startedAt" in container && container.startedAt
|
||||||
|
? Date.now() - new Date(container.startedAt).getTime()
|
||||||
|
: null,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { getSetting, setSetting, getBoolSetting } from "@/server/db";
|
||||||
|
import { env } from "@/server/env";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return handle(async () => {
|
||||||
|
return ok({
|
||||||
|
registrationWindowMinutes: Number(
|
||||||
|
getSetting("registration_window_minutes", String(env.registrationWindowMinutes))
|
||||||
|
),
|
||||||
|
notifyJoins: getBoolSetting("notify_joins", true),
|
||||||
|
discordEnabled: getBoolSetting("discord_enabled", true),
|
||||||
|
discordJoins: getBoolSetting("discord_joins", true),
|
||||||
|
discordWebhookUrl: getSetting("discord_webhook_url", env.discordWebhookUrl),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const body = await readJson<Record<string, unknown>>(req);
|
||||||
|
if (body.registrationWindowMinutes !== undefined) {
|
||||||
|
const n = Number(body.registrationWindowMinutes);
|
||||||
|
if (!Number.isFinite(n) || n < 0.5 || n > 120) return fail("Window must be between 0.5 and 120 minutes");
|
||||||
|
setSetting("registration_window_minutes", String(n));
|
||||||
|
}
|
||||||
|
if (body.notifyJoins !== undefined) setSetting("notify_joins", body.notifyJoins ? "1" : "0");
|
||||||
|
if (body.discordEnabled !== undefined) setSetting("discord_enabled", body.discordEnabled ? "1" : "0");
|
||||||
|
if (body.discordJoins !== undefined) setSetting("discord_joins", body.discordJoins ? "1" : "0");
|
||||||
|
if (body.discordWebhookUrl !== undefined) {
|
||||||
|
const url = String(body.discordWebhookUrl);
|
||||||
|
if (url && !/^https:\/\/(discord\.com|discordapp\.com)\/api\/webhooks\//.test(url)) {
|
||||||
|
return fail("Not a Discord webhook URL");
|
||||||
|
}
|
||||||
|
setSetting("discord_webhook_url", url);
|
||||||
|
}
|
||||||
|
return ok({ saved: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { notify } from "@/server/notify";
|
||||||
|
import { ok, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export async function POST() {
|
||||||
|
return handle(async () => {
|
||||||
|
notify("info", "Test notification", "If you can read this in Discord, the webhook works. 🎉");
|
||||||
|
return ok({ sent: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { bus } from "@/server/bus";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Multiplexed live event stream (SSE) for the UI: sampler metrics ticks,
|
||||||
|
* parsed game events, and new notifications.
|
||||||
|
*/
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const send = (event: string, data: unknown) => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`));
|
||||||
|
} catch {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const onMetrics = (m: unknown) => send("metrics", m);
|
||||||
|
const onEvent = (e: unknown) => send("mc-event", e);
|
||||||
|
const onNotification = (n: unknown) => send("notification", n);
|
||||||
|
bus().on("metrics", onMetrics);
|
||||||
|
bus().on("mc-event", onEvent);
|
||||||
|
bus().on("notification", onNotification);
|
||||||
|
const keepalive = setInterval(() => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||||
|
} catch {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
req.signal.addEventListener("abort", () => {
|
||||||
|
closed = true;
|
||||||
|
bus().off("metrics", onMetrics);
|
||||||
|
bus().off("mc-event", onEvent);
|
||||||
|
bus().off("notification", onNotification);
|
||||||
|
clearInterval(keepalive);
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import { bus, logRing } from "@/server/bus";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
/** Live console stream (SSE). Backfills recent lines, then follows. */
|
||||||
|
export async function GET(req: NextRequest) {
|
||||||
|
const encoder = new TextEncoder();
|
||||||
|
let closed = false;
|
||||||
|
|
||||||
|
const stream = new ReadableStream({
|
||||||
|
start(controller) {
|
||||||
|
const send = (line: string) => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(`data: ${JSON.stringify(line)}\n\n`));
|
||||||
|
} catch {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
for (const line of logRing()) send(line);
|
||||||
|
const onLog = (line: string) => send(line);
|
||||||
|
bus().on("log", onLog);
|
||||||
|
const keepalive = setInterval(() => {
|
||||||
|
if (closed) return;
|
||||||
|
try {
|
||||||
|
controller.enqueue(encoder.encode(": keepalive\n\n"));
|
||||||
|
} catch {
|
||||||
|
closed = true;
|
||||||
|
}
|
||||||
|
}, 15000);
|
||||||
|
req.signal.addEventListener("abort", () => {
|
||||||
|
closed = true;
|
||||||
|
bus().off("log", onLog);
|
||||||
|
clearInterval(keepalive);
|
||||||
|
try {
|
||||||
|
controller.close();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Response(stream, {
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "text/event-stream",
|
||||||
|
"Cache-Control": "no-cache, no-transform",
|
||||||
|
Connection: "keep-alive",
|
||||||
|
"X-Accel-Buffering": "no",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest } from "next/server";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { readWorldInfo } from "@/server/playerstats";
|
||||||
|
import { getWorldSizeBytes } from "@/server/sampler";
|
||||||
|
import { inspectContainer, powerContainer } from "@/server/docker";
|
||||||
|
import { safetyBackup } from "@/server/backups";
|
||||||
|
import { logActivity } from "@/server/db";
|
||||||
|
import { notify } from "@/server/notify";
|
||||||
|
import { env } from "@/server/env";
|
||||||
|
import { ok, fail, readJson, handle } from "@/server/api";
|
||||||
|
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
return handle(async () => ok(readWorldInfo(getWorldSizeBytes())));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Danger zone: archive the current world and generate a fresh one. */
|
||||||
|
export async function POST(req: NextRequest) {
|
||||||
|
return handle(async () => {
|
||||||
|
const { op, confirm } = await readJson<{ op?: string; confirm?: string }>(req);
|
||||||
|
if (op !== "reset") return fail("Unknown operation");
|
||||||
|
if (confirm !== "reset the world") return fail('Type "reset the world" to confirm');
|
||||||
|
|
||||||
|
const worldDir = path.join(env.mcDataDir, "world");
|
||||||
|
if (!fs.existsSync(worldDir)) return fail("No world folder found");
|
||||||
|
|
||||||
|
await safetyBackup("world reset");
|
||||||
|
const state = await inspectContainer(env.mcContainer);
|
||||||
|
if (state.running) await powerContainer(env.mcContainer, "stop");
|
||||||
|
|
||||||
|
const archived = `world.old-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
||||||
|
fs.renameSync(worldDir, path.join(env.mcDataDir, archived));
|
||||||
|
|
||||||
|
await powerContainer(env.mcContainer, "start");
|
||||||
|
logActivity("world_reset", null, `old world kept as ${archived}`);
|
||||||
|
notify("warn", "World reset", `A fresh world is generating. Old world kept as ${archived}.`);
|
||||||
|
return ok({ archived });
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
/*
|
||||||
|
* CraftDeck design tokens — dark-only admin panel.
|
||||||
|
* Chart series + status colors validated with the dataviz palette checker
|
||||||
|
* against the card surface (#11161d).
|
||||||
|
*/
|
||||||
|
@theme {
|
||||||
|
--color-bg: #0b0f14;
|
||||||
|
--color-surface: #11161d;
|
||||||
|
--color-surface2: #171e27;
|
||||||
|
--color-surface3: #1f2833;
|
||||||
|
--color-border: #232c37;
|
||||||
|
--color-ink: #e6ebf2;
|
||||||
|
--color-ink2: #9aa7b4;
|
||||||
|
--color-muted: #64748b;
|
||||||
|
--color-accent: #4ade80;
|
||||||
|
--color-accent-deep: #16a34a;
|
||||||
|
|
||||||
|
--color-good: #0ca30c;
|
||||||
|
--color-warn: #fab219;
|
||||||
|
--color-serious: #ec835a;
|
||||||
|
--color-crit: #d03b3b;
|
||||||
|
--color-info: #60a5fa;
|
||||||
|
|
||||||
|
--color-series1: #3987e5;
|
||||||
|
--color-series2: #199e70;
|
||||||
|
--color-series3: #c98500;
|
||||||
|
|
||||||
|
--font-sans: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||||
|
--font-mono: ui-monospace, "SF Mono", SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
html {
|
||||||
|
color-scheme: dark;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-ink);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* recessive scrollbars for console/log panes */
|
||||||
|
.scroll-slim::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
.scroll-slim::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--color-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.scroll-slim::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#0b0f14"/>
|
||||||
|
<rect x="7" y="7" width="8" height="8" fill="#4ade80"/>
|
||||||
|
<rect x="17" y="7" width="8" height="8" fill="#199e70"/>
|
||||||
|
<rect x="7" y="17" width="8" height="8" fill="#199e70"/>
|
||||||
|
<rect x="17" y="17" width="8" height="8" fill="#166534"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 359 B |
@@ -0,0 +1,15 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import "./globals.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "CraftDeck — Minecraft Server Panel",
|
||||||
|
description: "Self-hosted management dashboard for your Minecraft server",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<html lang="en">
|
||||||
|
<body className="antialiased">{children}</body>
|
||||||
|
</html>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
import { api } from "@/lib/api";
|
||||||
|
import { Button, Input } from "@/components/ui";
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const router = useRouter();
|
||||||
|
const [username, setUsername] = useState("admin");
|
||||||
|
const [password, setPassword] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
|
const submit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setBusy(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const res = await api<{ mustChange: boolean }>("/api/auth/login", { body: { username, password } });
|
||||||
|
router.push(res.mustChange ? "/settings" : "/");
|
||||||
|
} catch (err) {
|
||||||
|
setError((err as Error).message);
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen items-center justify-center bg-bg p-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<div className="mb-6 flex flex-col items-center gap-2">
|
||||||
|
<svg viewBox="0 0 32 32" className="h-12 w-12">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#11161d" />
|
||||||
|
<rect x="7" y="7" width="8" height="8" fill="#4ade80" />
|
||||||
|
<rect x="17" y="7" width="8" height="8" fill="#199e70" />
|
||||||
|
<rect x="7" y="17" width="8" height="8" fill="#199e70" />
|
||||||
|
<rect x="17" y="17" width="8" height="8" fill="#166534" />
|
||||||
|
</svg>
|
||||||
|
<h1 className="text-lg font-bold">CraftDeck</h1>
|
||||||
|
<p className="text-xs text-muted">Sign in to manage your Minecraft server</p>
|
||||||
|
</div>
|
||||||
|
<form onSubmit={submit} className="space-y-3 rounded-xl border border-border bg-surface p-5">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">Username</label>
|
||||||
|
<Input
|
||||||
|
value={username}
|
||||||
|
onChange={(e) => setUsername(e.target.value)}
|
||||||
|
autoComplete="username"
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-ink2">Password</label>
|
||||||
|
<Input
|
||||||
|
type="password"
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
autoComplete="current-password"
|
||||||
|
autoFocus
|
||||||
|
className="w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{error && <div className="rounded-lg bg-crit/10 px-3 py-2 text-xs text-crit">{error}</div>}
|
||||||
|
<Button type="submit" variant="primary" busy={busy} className="w-full">
|
||||||
|
Sign in
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
<p className="mt-4 text-center text-[11px] leading-relaxed text-muted">
|
||||||
|
First run? The initial password is in your <code>.env</code> (PANEL_ADMIN_PASSWORD)
|
||||||
|
<br />
|
||||||
|
or printed in the dashboard container logs.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,256 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
|
import { fmtTime } from "@/lib/format";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* SVG chart components following the dataviz method: recessive grid, thin
|
||||||
|
* 2px lines, muted axis ink, hover crosshair + tooltip, legend + direct
|
||||||
|
* labels when there are ≥2 series, text in ink tokens (never series color).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Point = { ts: number; v: number | null };
|
||||||
|
export type Series = { name: string; color: string; points: Point[] };
|
||||||
|
|
||||||
|
/* ── mini sparkline (stat tiles) ───────────────────────────────────── */
|
||||||
|
|
||||||
|
export function Sparkline({
|
||||||
|
points,
|
||||||
|
color = "var(--color-series1)",
|
||||||
|
height = 36,
|
||||||
|
yMax,
|
||||||
|
}: {
|
||||||
|
points: Point[];
|
||||||
|
color?: string;
|
||||||
|
height?: number;
|
||||||
|
yMax?: number;
|
||||||
|
}) {
|
||||||
|
const width = 220;
|
||||||
|
const valid = points.filter((p) => p.v !== null) as Array<{ ts: number; v: number }>;
|
||||||
|
if (valid.length < 2) {
|
||||||
|
return <div className="h-9 text-[11px] leading-9 text-muted">collecting data…</div>;
|
||||||
|
}
|
||||||
|
const xs = valid.map((p) => p.ts);
|
||||||
|
const ys = valid.map((p) => p.v);
|
||||||
|
const xMin = Math.min(...xs);
|
||||||
|
const xMax = Math.max(...xs);
|
||||||
|
const yTop = yMax ?? (Math.max(...ys) * 1.1 || 1);
|
||||||
|
const px = (t: number) => ((t - xMin) / Math.max(xMax - xMin, 1)) * width;
|
||||||
|
const py = (v: number) => height - 2 - (v / yTop) * (height - 6);
|
||||||
|
const d = valid.map((p, i) => `${i === 0 ? "M" : "L"}${px(p.ts).toFixed(1)},${py(p.v).toFixed(1)}`).join(" ");
|
||||||
|
const area = `${d} L${px(xMax).toFixed(1)},${height} L${px(xMin).toFixed(1)},${height} Z`;
|
||||||
|
return (
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="h-9 w-full" preserveAspectRatio="none" aria-hidden>
|
||||||
|
<path d={area} fill={color} opacity="0.12" />
|
||||||
|
<path d={d} fill="none" stroke={color} strokeWidth="2" strokeLinejoin="round" strokeLinecap="round" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── full time-series chart ────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function TimeSeriesChart({
|
||||||
|
series,
|
||||||
|
height = 200,
|
||||||
|
unit = "",
|
||||||
|
yMax: yMaxProp,
|
||||||
|
formatValue = (v: number) => `${Math.round(v * 10) / 10}${unit}`,
|
||||||
|
}: {
|
||||||
|
series: Series[];
|
||||||
|
height?: number;
|
||||||
|
unit?: string;
|
||||||
|
yMax?: number;
|
||||||
|
formatValue?: (v: number) => string;
|
||||||
|
}) {
|
||||||
|
const width = 760;
|
||||||
|
const pad = { l: 44, r: 70, t: 10, b: 22 };
|
||||||
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
|
const [hover, setHover] = useState<number | null>(null); // hovered timestamp
|
||||||
|
|
||||||
|
const { xMin, xMax, yMax, hasData } = useMemo(() => {
|
||||||
|
const all = series.flatMap((s) => s.points.filter((p) => p.v !== null));
|
||||||
|
if (all.length < 2) return { xMin: 0, xMax: 1, yMax: 1, hasData: false };
|
||||||
|
const xs = series.flatMap((s) => s.points.map((p) => p.ts));
|
||||||
|
return {
|
||||||
|
xMin: Math.min(...xs),
|
||||||
|
xMax: Math.max(...xs),
|
||||||
|
yMax: yMaxProp ?? (Math.max(...all.map((p) => p.v as number)) * 1.15 || 1),
|
||||||
|
hasData: true,
|
||||||
|
};
|
||||||
|
}, [series, yMaxProp]);
|
||||||
|
|
||||||
|
if (!hasData) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center text-xs text-muted" style={{ height }}>
|
||||||
|
Not enough data yet — the sampler records a point every 10 seconds.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const plotW = width - pad.l - pad.r;
|
||||||
|
const plotH = height - pad.t - pad.b;
|
||||||
|
const px = (t: number) => pad.l + ((t - xMin) / Math.max(xMax - xMin, 1)) * plotW;
|
||||||
|
const py = (v: number) => pad.t + plotH - (Math.min(v, yMax) / yMax) * plotH;
|
||||||
|
|
||||||
|
const gridYs = [0, 0.25, 0.5, 0.75, 1].map((f) => yMax * f);
|
||||||
|
const timeTicks = [0, 0.25, 0.5, 0.75, 1].map((f) => xMin + (xMax - xMin) * f);
|
||||||
|
|
||||||
|
// nearest points for tooltip
|
||||||
|
const hoverData =
|
||||||
|
hover === null
|
||||||
|
? null
|
||||||
|
: series
|
||||||
|
.map((s) => {
|
||||||
|
let best: Point | null = null;
|
||||||
|
for (const p of s.points) {
|
||||||
|
if (p.v === null) continue;
|
||||||
|
if (!best || Math.abs(p.ts - hover) < Math.abs(best.ts - hover)) best = p;
|
||||||
|
}
|
||||||
|
return { name: s.name, color: s.color, point: best };
|
||||||
|
})
|
||||||
|
.filter((h) => h.point !== null);
|
||||||
|
|
||||||
|
const onMove = (e: React.MouseEvent) => {
|
||||||
|
const rect = ref.current?.getBoundingClientRect();
|
||||||
|
if (!rect) return;
|
||||||
|
const fx = (e.clientX - rect.left) / rect.width; // 0..1 across rendered width
|
||||||
|
const svgX = fx * width;
|
||||||
|
if (svgX < pad.l || svgX > width - pad.r) return setHover(null);
|
||||||
|
setHover(xMin + ((svgX - pad.l) / plotW) * (xMax - xMin));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={ref} className="relative" onMouseMove={onMove} onMouseLeave={() => setHover(null)}>
|
||||||
|
<svg viewBox={`0 0 ${width} ${height}`} className="w-full" style={{ height }}>
|
||||||
|
{/* recessive grid */}
|
||||||
|
{gridYs.map((v) => (
|
||||||
|
<g key={v}>
|
||||||
|
<line x1={pad.l} x2={width - pad.r} y1={py(v)} y2={py(v)} stroke="var(--color-border)" strokeWidth="1" />
|
||||||
|
<text x={pad.l - 6} y={py(v) + 3} textAnchor="end" fontSize="10" fill="var(--color-muted)">
|
||||||
|
{formatValue(v)}
|
||||||
|
</text>
|
||||||
|
</g>
|
||||||
|
))}
|
||||||
|
{timeTicks.map((t) => (
|
||||||
|
<text key={t} x={px(t)} y={height - 6} textAnchor="middle" fontSize="10" fill="var(--color-muted)">
|
||||||
|
{fmtTime(t)}
|
||||||
|
</text>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* series lines (2px) + direct labels at line ends */}
|
||||||
|
{series.map((s) => {
|
||||||
|
const valid = s.points.filter((p) => p.v !== null) as Array<{ ts: number; v: number }>;
|
||||||
|
if (valid.length < 2) return null;
|
||||||
|
const d = valid
|
||||||
|
.map((p, i) => `${i === 0 ? "M" : "L"}${px(p.ts).toFixed(1)},${py(p.v).toFixed(1)}`)
|
||||||
|
.join(" ");
|
||||||
|
const last = valid[valid.length - 1];
|
||||||
|
return (
|
||||||
|
<g key={s.name}>
|
||||||
|
<path d={d} fill="none" stroke={s.color} strokeWidth="2" strokeLinejoin="round" />
|
||||||
|
{series.length > 1 && (
|
||||||
|
<text
|
||||||
|
x={width - pad.r + 6}
|
||||||
|
y={py(last.v) + 3}
|
||||||
|
fontSize="10"
|
||||||
|
fill="var(--color-ink2)"
|
||||||
|
>
|
||||||
|
{s.name}
|
||||||
|
</text>
|
||||||
|
)}
|
||||||
|
</g>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{/* hover crosshair + markers */}
|
||||||
|
{hover !== null && hoverData && (
|
||||||
|
<g>
|
||||||
|
<line
|
||||||
|
x1={px(hover)}
|
||||||
|
x2={px(hover)}
|
||||||
|
y1={pad.t}
|
||||||
|
y2={pad.t + plotH}
|
||||||
|
stroke="var(--color-muted)"
|
||||||
|
strokeWidth="1"
|
||||||
|
strokeDasharray="3 3"
|
||||||
|
/>
|
||||||
|
{hoverData.map((h) => (
|
||||||
|
<circle
|
||||||
|
key={h.name}
|
||||||
|
cx={px(h.point!.ts)}
|
||||||
|
cy={py(h.point!.v as number)}
|
||||||
|
r="4"
|
||||||
|
fill={h.color}
|
||||||
|
stroke="var(--color-surface)"
|
||||||
|
strokeWidth="2"
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</g>
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* tooltip */}
|
||||||
|
{hover !== null && hoverData && hoverData.length > 0 && (
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute top-2 z-10 rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-xs shadow-xl"
|
||||||
|
style={{
|
||||||
|
left: `${Math.min(Math.max((px(hover) / width) * 100, 8), 78)}%`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="mb-0.5 font-medium text-ink2">{fmtTime(hoverData[0].point!.ts)}</div>
|
||||||
|
{hoverData.map((h) => (
|
||||||
|
<div key={h.name} className="flex items-center gap-1.5 text-ink">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full" style={{ background: h.color }} />
|
||||||
|
<span className="text-ink2">{h.name}</span>
|
||||||
|
<span className="ml-auto pl-3 font-medium">{formatValue(h.point!.v as number)}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* legend (≥2 series) */}
|
||||||
|
{series.length > 1 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-3 pl-11">
|
||||||
|
{series.map((s) => (
|
||||||
|
<span key={s.name} className="flex items-center gap-1.5 text-[11px] text-ink2">
|
||||||
|
<span className="inline-block h-2 w-2 rounded-full" style={{ background: s.color }} />
|
||||||
|
{s.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── horizontal usage bar (disk etc.) ──────────────────────────────── */
|
||||||
|
|
||||||
|
export function UsageBar({
|
||||||
|
used,
|
||||||
|
total,
|
||||||
|
label,
|
||||||
|
format,
|
||||||
|
}: {
|
||||||
|
used: number;
|
||||||
|
total: number;
|
||||||
|
label?: string;
|
||||||
|
format: (v: number) => string;
|
||||||
|
}) {
|
||||||
|
const frac = total > 0 ? Math.min(used / total, 1) : 0;
|
||||||
|
const color = frac > 0.9 ? "var(--color-crit)" : frac > 0.75 ? "var(--color-warn)" : "var(--color-series1)";
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && (
|
||||||
|
<div className="mb-1 flex justify-between text-xs text-ink2">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span>
|
||||||
|
{format(used)} / {format(total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-surface3">
|
||||||
|
<div className="h-full rounded-full" style={{ width: `${frac * 100}%`, background: color }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,264 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
|
import {
|
||||||
|
Gauge,
|
||||||
|
TerminalSquare,
|
||||||
|
Users,
|
||||||
|
ShieldCheck,
|
||||||
|
Activity,
|
||||||
|
Package,
|
||||||
|
Archive,
|
||||||
|
FolderOpen,
|
||||||
|
Sliders,
|
||||||
|
CalendarClock,
|
||||||
|
Settings,
|
||||||
|
Bell,
|
||||||
|
LogOut,
|
||||||
|
CircleDot,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { api, useApi, useSSE, toast } from "@/lib/api";
|
||||||
|
import { Badge, Toaster } from "@/components/ui";
|
||||||
|
import { timeAgo } from "@/lib/format";
|
||||||
|
|
||||||
|
const NAV = [
|
||||||
|
{ href: "/", label: "Overview", icon: Gauge },
|
||||||
|
{ href: "/console", label: "Console", icon: TerminalSquare },
|
||||||
|
{ href: "/players", label: "Players", icon: Users },
|
||||||
|
{ href: "/access", label: "Access & Auth", icon: ShieldCheck },
|
||||||
|
{ href: "/performance", label: "Performance", icon: Activity },
|
||||||
|
{ href: "/mods", label: "Mods", icon: Package },
|
||||||
|
{ href: "/backups", label: "Backups", icon: Archive },
|
||||||
|
{ href: "/files", label: "Files", icon: FolderOpen },
|
||||||
|
{ href: "/config", label: "Configuration", icon: Sliders },
|
||||||
|
{ href: "/schedules", label: "Schedules", icon: CalendarClock },
|
||||||
|
{ href: "/settings", label: "Settings", icon: Settings },
|
||||||
|
];
|
||||||
|
|
||||||
|
type ServerSummary = {
|
||||||
|
container: { running: boolean; status: string };
|
||||||
|
players: { online: number; max: number };
|
||||||
|
pendingApprovals: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NotificationRow = {
|
||||||
|
id: number;
|
||||||
|
ts: number;
|
||||||
|
level: "info" | "success" | "warn" | "error";
|
||||||
|
title: string;
|
||||||
|
body: string | null;
|
||||||
|
read: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Shell({ children }: { children: React.ReactNode }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const router = useRouter();
|
||||||
|
const { data: server, refresh } = useApi<ServerSummary>("/api/server", 15000);
|
||||||
|
const { data: me } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me");
|
||||||
|
const [running, setRunning] = useState<boolean | null>(null);
|
||||||
|
const [players, setPlayers] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useSSE("/api/stream/events", {
|
||||||
|
metrics: (m) => {
|
||||||
|
const d = m as { running: boolean; players: number | null };
|
||||||
|
setRunning(d.running);
|
||||||
|
if (d.players !== null) setPlayers(d.players);
|
||||||
|
},
|
||||||
|
notification: (n) => {
|
||||||
|
const d = n as NotificationRow;
|
||||||
|
toast(d.title, d.level === "error" ? "error" : d.level === "warn" ? "warn" : d.level);
|
||||||
|
setUnreadBump((x) => x + 1);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const [unreadBump, setUnreadBump] = useState(0);
|
||||||
|
const isRunning = running ?? server?.container.running ?? false;
|
||||||
|
const playerCount = players ?? server?.players.online ?? 0;
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
await api("/api/auth/logout", { method: "POST" });
|
||||||
|
router.push("/login");
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen">
|
||||||
|
{/* sidebar */}
|
||||||
|
<aside className="fixed inset-y-0 left-0 z-40 flex w-52 flex-col border-r border-border bg-surface">
|
||||||
|
<div className="flex items-center gap-2.5 px-4 py-4">
|
||||||
|
<svg viewBox="0 0 32 32" className="h-7 w-7 rounded-md">
|
||||||
|
<rect width="32" height="32" rx="7" fill="#0b0f14" />
|
||||||
|
<rect x="7" y="7" width="8" height="8" fill="#4ade80" />
|
||||||
|
<rect x="17" y="7" width="8" height="8" fill="#199e70" />
|
||||||
|
<rect x="7" y="17" width="8" height="8" fill="#199e70" />
|
||||||
|
<rect x="17" y="17" width="8" height="8" fill="#166534" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold leading-none">CraftDeck</div>
|
||||||
|
<div className="mt-0.5 text-[10px] text-muted">server panel</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2 pb-4">
|
||||||
|
{NAV.map((item) => {
|
||||||
|
const active = pathname === item.href;
|
||||||
|
const Icon = item.icon;
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={`flex items-center gap-2.5 rounded-lg px-2.5 py-2 text-[13px] font-medium transition-colors ${
|
||||||
|
active ? "bg-surface3 text-ink" : "text-ink2 hover:bg-surface2 hover:text-ink"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={15} className={active ? "text-accent" : ""} />
|
||||||
|
{item.label}
|
||||||
|
{item.href === "/access" && (server?.pendingApprovals ?? 0) > 0 && (
|
||||||
|
<span className="ml-auto rounded-full bg-warn/20 px-1.5 text-[10px] font-semibold text-warn">
|
||||||
|
{server!.pendingApprovals}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<div className="border-t border-border p-3 text-[11px] text-muted">
|
||||||
|
Signed in as <span className="text-ink2">{me?.username ?? "…"}</span>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* main */}
|
||||||
|
<div className="ml-52 flex min-h-screen flex-1 flex-col">
|
||||||
|
<header className="sticky top-0 z-30 flex items-center justify-between border-b border-border bg-bg/90 px-6 py-3 backdrop-blur">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span
|
||||||
|
className={`flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-medium ${
|
||||||
|
isRunning ? "border-good/40 bg-good/10 text-good" : "border-crit/40 bg-crit/10 text-crit"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<CircleDot size={12} />
|
||||||
|
{isRunning ? "Online" : "Offline"}
|
||||||
|
</span>
|
||||||
|
{isRunning && (
|
||||||
|
<span className="text-xs text-ink2">
|
||||||
|
{playerCount} player{playerCount === 1 ? "" : "s"} online
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<NotificationBell bump={unreadBump} />
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="flex items-center gap-1.5 rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-xs text-ink2 hover:text-ink"
|
||||||
|
>
|
||||||
|
<LogOut size={13} /> Log out
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{me?.mustChange && pathname !== "/settings" && (
|
||||||
|
<div className="border-b border-warn/30 bg-warn/10 px-6 py-2 text-xs text-warn">
|
||||||
|
You are using the initial admin password —{" "}
|
||||||
|
<Link href="/settings" className="underline">
|
||||||
|
change it now
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<main className="flex-1 p-6">{children}</main>
|
||||||
|
</div>
|
||||||
|
<Toaster />
|
||||||
|
{/* refresh server summary when the tab regains focus */}
|
||||||
|
<FocusRefresher onFocus={refresh} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FocusRefresher({ onFocus }: { onFocus: () => void }) {
|
||||||
|
useEffect(() => {
|
||||||
|
window.addEventListener("focus", onFocus);
|
||||||
|
return () => window.removeEventListener("focus", onFocus);
|
||||||
|
}, [onFocus]);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationBell({ bump }: { bump: number }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [items, setItems] = useState<NotificationRow[]>([]);
|
||||||
|
const [unread, setUnread] = useState(0);
|
||||||
|
const panelRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
const d = await api<{ notifications: NotificationRow[]; unread: number }>("/api/notifications?limit=30");
|
||||||
|
setItems(d.notifications);
|
||||||
|
setUnread(d.unread);
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
}, [bump]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onClick = (e: MouseEvent) => {
|
||||||
|
if (!panelRef.current?.contains(e.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener("mousedown", onClick);
|
||||||
|
return () => document.removeEventListener("mousedown", onClick);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const markAll = async () => {
|
||||||
|
await api("/api/notifications", { body: { op: "read-all" } });
|
||||||
|
void load();
|
||||||
|
};
|
||||||
|
|
||||||
|
const levelColor = { info: "info", success: "good", warn: "warn", error: "crit" } as const;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" ref={panelRef}>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setOpen((v) => !v);
|
||||||
|
if (!open) void load();
|
||||||
|
}}
|
||||||
|
className="relative flex items-center rounded-lg border border-border bg-surface2 p-2 text-ink2 hover:text-ink"
|
||||||
|
>
|
||||||
|
<Bell size={14} />
|
||||||
|
{unread > 0 && (
|
||||||
|
<span className="absolute -right-1 -top-1 flex h-4 min-w-4 items-center justify-center rounded-full bg-crit px-1 text-[9px] font-bold text-white">
|
||||||
|
{unread > 99 ? "99+" : unread}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div className="absolute right-0 top-10 z-50 w-96 rounded-xl border border-border bg-surface shadow-2xl">
|
||||||
|
<header className="flex items-center justify-between border-b border-border px-3.5 py-2.5">
|
||||||
|
<span className="text-xs font-semibold">Notifications</span>
|
||||||
|
<button onClick={markAll} className="text-[11px] text-ink2 hover:text-ink">
|
||||||
|
Mark all read
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="scroll-slim max-h-96 overflow-y-auto">
|
||||||
|
{items.length === 0 && <div className="px-4 py-8 text-center text-xs text-muted">Nothing yet</div>}
|
||||||
|
{items.map((n) => (
|
||||||
|
<div
|
||||||
|
key={n.id}
|
||||||
|
className={`border-b border-border/50 px-3.5 py-2.5 ${n.read ? "opacity-55" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge color={levelColor[n.level]}>{n.level}</Badge>
|
||||||
|
<span className="text-xs font-medium text-ink">{n.title}</span>
|
||||||
|
<span className="ml-auto shrink-0 text-[10px] text-muted">{timeAgo(n.ts)}</span>
|
||||||
|
</div>
|
||||||
|
{n.body && <div className="mt-1 text-[11px] leading-relaxed text-ink2">{n.body}</div>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState, type ReactNode, type ButtonHTMLAttributes, type InputHTMLAttributes } from "react";
|
||||||
|
import { X, Loader2 } from "lucide-react";
|
||||||
|
import { toast, type ToastLevel } from "@/lib/api";
|
||||||
|
|
||||||
|
/* ── layout primitives ─────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function Card({
|
||||||
|
title,
|
||||||
|
actions,
|
||||||
|
children,
|
||||||
|
className = "",
|
||||||
|
pad = true,
|
||||||
|
}: {
|
||||||
|
title?: ReactNode;
|
||||||
|
actions?: ReactNode;
|
||||||
|
children: ReactNode;
|
||||||
|
className?: string;
|
||||||
|
pad?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className={`rounded-xl border border-border bg-surface ${className}`}>
|
||||||
|
{(title || actions) && (
|
||||||
|
<header className="flex items-center justify-between gap-3 border-b border-border px-4 py-2.5">
|
||||||
|
<h2 className="text-sm font-semibold text-ink">{title}</h2>
|
||||||
|
<div className="flex items-center gap-2">{actions}</div>
|
||||||
|
</header>
|
||||||
|
)}
|
||||||
|
<div className={pad ? "p-4" : ""}>{children}</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function StatTile({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
sub,
|
||||||
|
accent,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
label: string;
|
||||||
|
value: ReactNode;
|
||||||
|
sub?: ReactNode;
|
||||||
|
accent?: "good" | "warn" | "crit" | "info" | undefined;
|
||||||
|
children?: ReactNode; // e.g. a sparkline
|
||||||
|
}) {
|
||||||
|
const valueColor =
|
||||||
|
accent === "good"
|
||||||
|
? "text-good"
|
||||||
|
: accent === "warn"
|
||||||
|
? "text-warn"
|
||||||
|
: accent === "crit"
|
||||||
|
? "text-crit"
|
||||||
|
: "text-ink";
|
||||||
|
return (
|
||||||
|
<div className="rounded-xl border border-border bg-surface p-4">
|
||||||
|
<div className="text-xs font-medium uppercase tracking-wide text-muted">{label}</div>
|
||||||
|
<div className={`mt-1 text-2xl font-semibold ${valueColor}`}>{value}</div>
|
||||||
|
{sub && <div className="mt-0.5 text-xs text-ink2">{sub}</div>}
|
||||||
|
{children && <div className="mt-2">{children}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── controls ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
|
||||||
|
variant?: "primary" | "ghost" | "danger" | "warn";
|
||||||
|
size?: "sm" | "md";
|
||||||
|
busy?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
variant = "ghost",
|
||||||
|
size = "md",
|
||||||
|
busy = false,
|
||||||
|
className = "",
|
||||||
|
children,
|
||||||
|
disabled,
|
||||||
|
...rest
|
||||||
|
}: ButtonProps) {
|
||||||
|
const base =
|
||||||
|
"inline-flex items-center justify-center gap-1.5 rounded-lg font-medium transition-colors disabled:opacity-45 disabled:cursor-not-allowed";
|
||||||
|
const sizes = size === "sm" ? "px-2.5 py-1 text-xs" : "px-3.5 py-1.5 text-sm";
|
||||||
|
const variants = {
|
||||||
|
primary: "bg-accent-deep text-white hover:bg-accent-deep/80",
|
||||||
|
ghost: "border border-border bg-surface2 text-ink hover:bg-surface3",
|
||||||
|
danger: "border border-crit/40 bg-crit/10 text-crit hover:bg-crit/20",
|
||||||
|
warn: "border border-warn/40 bg-warn/10 text-warn hover:bg-warn/20",
|
||||||
|
}[variant];
|
||||||
|
return (
|
||||||
|
<button className={`${base} ${sizes} ${variants} ${className}`} disabled={disabled || busy} {...rest}>
|
||||||
|
{busy && <Loader2 size={14} className="animate-spin" />}
|
||||||
|
{children}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input(props: InputHTMLAttributes<HTMLInputElement>) {
|
||||||
|
const { className = "", ...rest } = props;
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
className={`rounded-lg border border-border bg-surface2 px-3 py-1.5 text-sm text-ink placeholder:text-muted focus:border-accent-deep focus:outline-none ${className}`}
|
||||||
|
{...rest}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
className = "",
|
||||||
|
}: {
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
options: Array<{ value: string; label: string }>;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<select
|
||||||
|
value={value}
|
||||||
|
onChange={(e) => onChange(e.target.value)}
|
||||||
|
className={`rounded-lg border border-border bg-surface2 px-2.5 py-1.5 text-sm text-ink focus:border-accent-deep focus:outline-none ${className}`}
|
||||||
|
>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>
|
||||||
|
{o.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Toggle({
|
||||||
|
checked,
|
||||||
|
onChange,
|
||||||
|
label,
|
||||||
|
}: {
|
||||||
|
checked: boolean;
|
||||||
|
onChange: (v: boolean) => void;
|
||||||
|
label?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="flex cursor-pointer items-center gap-2.5 text-sm text-ink">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="switch"
|
||||||
|
aria-checked={checked}
|
||||||
|
onClick={() => onChange(!checked)}
|
||||||
|
className={`relative h-5 w-9 rounded-full transition-colors ${checked ? "bg-accent-deep" : "bg-surface3"}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`absolute top-0.5 h-4 w-4 rounded-full bg-white transition-all ${checked ? "left-4.5" : "left-0.5"}`}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({
|
||||||
|
color = "muted",
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
color?: "good" | "warn" | "crit" | "info" | "muted" | "accent";
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
const map = {
|
||||||
|
good: "bg-good/15 text-good border-good/30",
|
||||||
|
warn: "bg-warn/15 text-warn border-warn/30",
|
||||||
|
crit: "bg-crit/15 text-crit border-crit/30",
|
||||||
|
info: "bg-info/15 text-info border-info/30",
|
||||||
|
accent: "bg-accent/15 text-accent border-accent/30",
|
||||||
|
muted: "bg-surface3 text-ink2 border-border",
|
||||||
|
}[color];
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[11px] font-medium ${map}`}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon, title, hint }: { icon?: ReactNode; title: string; hint?: string }) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1.5 py-10 text-center">
|
||||||
|
{icon && <div className="text-muted">{icon}</div>}
|
||||||
|
<div className="text-sm font-medium text-ink2">{title}</div>
|
||||||
|
{hint && <div className="max-w-sm text-xs text-muted">{hint}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── modal ─────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function Modal({
|
||||||
|
open,
|
||||||
|
onClose,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
wide = false,
|
||||||
|
}: {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: ReactNode;
|
||||||
|
wide?: boolean;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onKey = (e: KeyboardEvent) => e.key === "Escape" && onClose();
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}, [open, onClose]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className={`w-full ${wide ? "max-w-2xl" : "max-w-md"} rounded-xl border border-border bg-surface shadow-2xl`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-border px-4 py-3">
|
||||||
|
<h3 className="text-sm font-semibold">{title}</h3>
|
||||||
|
<button onClick={onClose} className="text-muted hover:text-ink">
|
||||||
|
<X size={16} />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── tabs ──────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
export function Tabs({
|
||||||
|
tabs,
|
||||||
|
active,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
tabs: Array<{ id: string; label: string }>;
|
||||||
|
active: string;
|
||||||
|
onChange: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 rounded-lg border border-border bg-surface2 p-1">
|
||||||
|
{tabs.map((t) => (
|
||||||
|
<button
|
||||||
|
key={t.id}
|
||||||
|
onClick={() => onChange(t.id)}
|
||||||
|
className={`rounded-md px-3 py-1 text-xs font-medium transition-colors ${
|
||||||
|
active === t.id ? "bg-surface3 text-ink" : "text-muted hover:text-ink2"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{t.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── toaster ───────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
type ToastItem = { id: number; message: string; level: ToastLevel };
|
||||||
|
|
||||||
|
export function Toaster() {
|
||||||
|
const [items, setItems] = useState<ToastItem[]>([]);
|
||||||
|
useEffect(() => {
|
||||||
|
let nextId = 1;
|
||||||
|
const onToast = (e: Event) => {
|
||||||
|
const { message, level } = (e as CustomEvent).detail as { message: string; level: ToastLevel };
|
||||||
|
const id = nextId++;
|
||||||
|
setItems((xs) => [...xs, { id, message, level }]);
|
||||||
|
setTimeout(() => setItems((xs) => xs.filter((x) => x.id !== id)), 5000);
|
||||||
|
};
|
||||||
|
window.addEventListener("craftdeck-toast", onToast);
|
||||||
|
return () => window.removeEventListener("craftdeck-toast", onToast);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const colors: Record<ToastLevel, string> = {
|
||||||
|
info: "border-info/40",
|
||||||
|
success: "border-good/40",
|
||||||
|
warn: "border-warn/40",
|
||||||
|
error: "border-crit/40",
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="pointer-events-none fixed bottom-4 right-4 z-[60] flex w-80 flex-col gap-2">
|
||||||
|
{items.map((t) => (
|
||||||
|
<div
|
||||||
|
key={t.id}
|
||||||
|
className={`pointer-events-auto rounded-lg border ${colors[t.level]} bg-surface2 px-3.5 py-2.5 text-sm text-ink shadow-xl`}
|
||||||
|
>
|
||||||
|
{t.message}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { toast };
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export async function register() {
|
||||||
|
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||||
|
const { boot } = await import("./server/boot");
|
||||||
|
boot();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
constructor(message: string, public status: number) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function api<T = unknown>(
|
||||||
|
path: string,
|
||||||
|
opts: { method?: string; body?: unknown } = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
method: opts.method ?? (opts.body !== undefined ? "POST" : "GET"),
|
||||||
|
headers: opts.body !== undefined ? { "Content-Type": "application/json" } : undefined,
|
||||||
|
body: opts.body !== undefined ? JSON.stringify(opts.body) : undefined,
|
||||||
|
});
|
||||||
|
if (res.status === 401) {
|
||||||
|
window.location.href = "/login";
|
||||||
|
throw new ApiError("Unauthorized", 401);
|
||||||
|
}
|
||||||
|
let payload: { ok?: boolean; data?: T; error?: string };
|
||||||
|
try {
|
||||||
|
payload = await res.json();
|
||||||
|
} catch {
|
||||||
|
throw new ApiError(`Request failed (${res.status})`, res.status);
|
||||||
|
}
|
||||||
|
if (!res.ok || payload.ok === false) {
|
||||||
|
throw new ApiError(payload.error ?? `Request failed (${res.status})`, res.status);
|
||||||
|
}
|
||||||
|
return payload.data as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll a GET endpoint; refresh() for manual refetch. */
|
||||||
|
export function useApi<T>(path: string, intervalMs = 0) {
|
||||||
|
const [data, setData] = useState<T | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const alive = useRef(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
const d = await api<T>(path);
|
||||||
|
if (!alive.current) return;
|
||||||
|
setData(d);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
if (!alive.current) return;
|
||||||
|
setError((err as Error).message);
|
||||||
|
} finally {
|
||||||
|
if (alive.current) setLoading(false);
|
||||||
|
}
|
||||||
|
}, [path]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
alive.current = true;
|
||||||
|
setLoading(true);
|
||||||
|
void refresh();
|
||||||
|
const timer = intervalMs > 0 ? setInterval(() => void refresh(), intervalMs) : null;
|
||||||
|
return () => {
|
||||||
|
alive.current = false;
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
};
|
||||||
|
}, [refresh, intervalMs]);
|
||||||
|
|
||||||
|
return { data, error, loading, refresh };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Subscribe to a server-sent-events endpoint. Reconnects automatically. */
|
||||||
|
export function useSSE(path: string, handlers: Record<string, (data: unknown) => void>) {
|
||||||
|
const handlersRef = useRef(handlers);
|
||||||
|
handlersRef.current = handlers;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const source = new EventSource(path);
|
||||||
|
const names = Object.keys(handlersRef.current);
|
||||||
|
const bound: Array<[string, (e: MessageEvent) => void]> = [];
|
||||||
|
for (const name of names) {
|
||||||
|
const fn = (e: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
handlersRef.current[name]?.(JSON.parse(e.data));
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
bound.push([name, fn]);
|
||||||
|
if (name === "message") source.onmessage = fn;
|
||||||
|
else source.addEventListener(name, fn);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
for (const [name, fn] of bound) {
|
||||||
|
if (name !== "message") source.removeEventListener(name, fn);
|
||||||
|
}
|
||||||
|
source.close();
|
||||||
|
};
|
||||||
|
}, [path]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── toasts (event-based so any code can raise one) ─────────────────────
|
||||||
|
|
||||||
|
export type ToastLevel = "info" | "success" | "warn" | "error";
|
||||||
|
|
||||||
|
export function toast(message: string, level: ToastLevel = "info") {
|
||||||
|
window.dispatchEvent(new CustomEvent("craftdeck-toast", { detail: { message, level } }));
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
export function fmtBytes(bytes: number | null | undefined): string {
|
||||||
|
if (bytes === null || bytes === undefined || Number.isNaN(bytes)) return "—";
|
||||||
|
const units = ["B", "KB", "MB", "GB", "TB"];
|
||||||
|
let v = bytes;
|
||||||
|
let i = 0;
|
||||||
|
while (v >= 1024 && i < units.length - 1) {
|
||||||
|
v /= 1024;
|
||||||
|
i++;
|
||||||
|
}
|
||||||
|
return `${v >= 100 ? Math.round(v) : v.toFixed(1)} ${units[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDuration(ms: number | null | undefined): string {
|
||||||
|
if (ms === null || ms === undefined || ms < 0) return "—";
|
||||||
|
const s = Math.floor(ms / 1000);
|
||||||
|
const d = Math.floor(s / 86400);
|
||||||
|
const h = Math.floor((s % 86400) / 3600);
|
||||||
|
const m = Math.floor((s % 3600) / 60);
|
||||||
|
if (d > 0) return `${d}d ${h}h`;
|
||||||
|
if (h > 0) return `${h}h ${m}m`;
|
||||||
|
if (m > 0) return `${m}m ${s % 60}s`;
|
||||||
|
return `${s}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function timeAgo(ts: number | null | undefined): string {
|
||||||
|
if (!ts) return "never";
|
||||||
|
const diff = Date.now() - ts;
|
||||||
|
if (diff < 30_000) return "just now";
|
||||||
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`;
|
||||||
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`;
|
||||||
|
return `${Math.floor(diff / 86_400_000)}d ago`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtTime(ts: number): string {
|
||||||
|
return new Date(ts).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtDateTime(ts: number | null | undefined): string {
|
||||||
|
if (!ts) return "—";
|
||||||
|
return new Date(ts).toLocaleString([], {
|
||||||
|
month: "short",
|
||||||
|
day: "numeric",
|
||||||
|
hour: "2-digit",
|
||||||
|
minute: "2-digit",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fmtCountdown(untilTs: number): string {
|
||||||
|
const left = untilTs - Date.now();
|
||||||
|
if (left <= 0) return "0:00";
|
||||||
|
const m = Math.floor(left / 60_000);
|
||||||
|
const s = Math.floor((left % 60_000) / 1000);
|
||||||
|
return `${m}:${String(s).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { SESSION_COOKIE, verifySessionToken } from "@/server/session";
|
||||||
|
|
||||||
|
const PUBLIC_PATHS = new Set(["/login", "/api/auth/login", "/api/health"]);
|
||||||
|
|
||||||
|
export async function proxy(req: NextRequest) {
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
if (PUBLIC_PATHS.has(pathname)) return NextResponse.next();
|
||||||
|
|
||||||
|
const token = req.cookies.get(SESSION_COOKIE)?.value;
|
||||||
|
const secret = process.env.PANEL_SESSION_SECRET || "craftdeck-dev-secret-do-not-use";
|
||||||
|
const session = await verifySessionToken(secret, token);
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
if (pathname.startsWith("/api/")) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
const url = req.nextUrl.clone();
|
||||||
|
url.pathname = "/login";
|
||||||
|
url.search = "";
|
||||||
|
return NextResponse.redirect(url);
|
||||||
|
}
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Everything except Next.js internals and static assets.
|
||||||
|
matcher: ["/((?!_next/static|_next/image|favicon.ico|icon.svg).*)"],
|
||||||
|
};
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { DockerError } from "./docker";
|
||||||
|
import { RconError } from "./rcon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Small helpers for route handlers. Authentication is enforced globally in
|
||||||
|
* middleware.ts; these standardize success/error payloads and translate
|
||||||
|
* infrastructure failures into friendly 503s.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function ok(data: unknown, init?: ResponseInit): NextResponse {
|
||||||
|
return NextResponse.json({ ok: true, data }, init);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fail(message: string, status = 400): NextResponse {
|
||||||
|
return NextResponse.json({ ok: false, error: message }, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function handle(fn: () => Promise<NextResponse> | NextResponse) {
|
||||||
|
return (async () => {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DockerError && err.kind === "unavailable") {
|
||||||
|
return fail("Docker is unreachable from the panel", 503);
|
||||||
|
}
|
||||||
|
if (err instanceof DockerError && err.kind === "not_found") {
|
||||||
|
return fail("Container not found — is the stack deployed?", 503);
|
||||||
|
}
|
||||||
|
if (err instanceof RconError) {
|
||||||
|
return fail(
|
||||||
|
err.kind === "auth"
|
||||||
|
? "RCON authentication failed — check RCON_PASSWORD"
|
||||||
|
: "Server console unreachable (is the server running?)",
|
||||||
|
503
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return fail((err as Error).message || "Internal error", 500);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function readJson<T>(req: Request): Promise<T> {
|
||||||
|
try {
|
||||||
|
return (await req.json()) as T;
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid JSON body");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,209 @@
|
|||||||
|
import { db, getSetting, logActivity } from "./db";
|
||||||
|
import { bus } from "./bus";
|
||||||
|
import { notify } from "./notify";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { generatePassword } from "./passwords";
|
||||||
|
import {
|
||||||
|
isValidUsername,
|
||||||
|
whitelistAdd,
|
||||||
|
whitelistRemove,
|
||||||
|
whitelistList,
|
||||||
|
kickPlayer,
|
||||||
|
easyauthList,
|
||||||
|
easyauthRemove,
|
||||||
|
easyauthSetPassword,
|
||||||
|
} from "./mc";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The approval state machine for offline-mode security:
|
||||||
|
*
|
||||||
|
* approve → whitelisted + "pending" with a short registration window
|
||||||
|
* pending + /register in time → "registered" (can play forever)
|
||||||
|
* pending + window lapses → whitelist removed, "expired"
|
||||||
|
* revoke → whitelist + EasyAuth account removed
|
||||||
|
*
|
||||||
|
* Enforcement runs in a background watcher that reconciles against
|
||||||
|
* EasyAuth's registered-account list via RCON.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type ApprovedPlayer = {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
status: "pending" | "registered" | "expired" | "revoked";
|
||||||
|
approved_at: number | null;
|
||||||
|
window_expires_at: number | null;
|
||||||
|
registered_at: number | null;
|
||||||
|
last_seen_at: number | null;
|
||||||
|
note: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function windowMs(): number {
|
||||||
|
const minutes = Number(getSetting("registration_window_minutes", String(env.registrationWindowMinutes)));
|
||||||
|
return Math.max(0.5, minutes) * 60 * 1000;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listApproved(): ApprovedPlayer[] {
|
||||||
|
return db()
|
||||||
|
.prepare("SELECT * FROM approved_players ORDER BY approved_at DESC")
|
||||||
|
.all() as unknown as ApprovedPlayer[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function approvePlayer(username: string, note?: string): Promise<ApprovedPlayer> {
|
||||||
|
if (!isValidUsername(username)) throw new Error("Invalid username (3–16 chars, letters/digits/underscore)");
|
||||||
|
const existing = db()
|
||||||
|
.prepare("SELECT * FROM approved_players WHERE username = ?")
|
||||||
|
.get(username) as unknown as ApprovedPlayer | undefined;
|
||||||
|
if (existing?.status === "registered") throw new Error(`${username} is already registered`);
|
||||||
|
if (existing?.status === "pending") throw new Error(`${username} already has an open registration window`);
|
||||||
|
|
||||||
|
await whitelistAdd(username); // throws if the server is unreachable — approval requires a running server
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const expires = now + windowMs();
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT INTO approved_players(username, status, approved_at, window_expires_at, note)
|
||||||
|
VALUES(?, 'pending', ?, ?, ?)
|
||||||
|
ON CONFLICT(username) DO UPDATE SET
|
||||||
|
status = 'pending', approved_at = excluded.approved_at,
|
||||||
|
window_expires_at = excluded.window_expires_at, registered_at = NULL,
|
||||||
|
note = COALESCE(excluded.note, approved_players.note)`
|
||||||
|
)
|
||||||
|
.run(username, now, expires, note ?? null);
|
||||||
|
logActivity("approve", username, `Registration window open for ${Math.round(windowMs() / 60000)} min`);
|
||||||
|
notify("info", `Approved ${username}`, "They can now join and /register — the window is ticking.");
|
||||||
|
return db().prepare("SELECT * FROM approved_players WHERE username = ?").get(username) as unknown as ApprovedPlayer;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function revokePlayer(username: string): Promise<void> {
|
||||||
|
if (!isValidUsername(username)) throw new Error("Invalid username");
|
||||||
|
try {
|
||||||
|
await whitelistRemove(username);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
await easyauthRemove(username);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
await kickPlayer(username, "Your access was revoked");
|
||||||
|
} catch {}
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE approved_players SET status = 'revoked', window_expires_at = NULL WHERE username = ?")
|
||||||
|
.run(username);
|
||||||
|
logActivity("revoke", username);
|
||||||
|
notify("warn", `Revoked ${username}`, "Whitelist entry and account removed.");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Re-open a registration window for an expired/revoked player. */
|
||||||
|
export async function reopenWindow(username: string): Promise<void> {
|
||||||
|
const row = db()
|
||||||
|
.prepare("SELECT * FROM approved_players WHERE username = ?")
|
||||||
|
.get(username) as unknown as ApprovedPlayer | undefined;
|
||||||
|
if (!row) throw new Error("Unknown player");
|
||||||
|
if (row.status === "registered") throw new Error("Player is already registered");
|
||||||
|
await whitelistAdd(username);
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE approved_players SET status = 'pending', approved_at = ?, window_expires_at = ? WHERE username = ?")
|
||||||
|
.run(Date.now(), Date.now() + windowMs(), username);
|
||||||
|
logActivity("approve", username, "Registration window re-opened");
|
||||||
|
notify("info", `Re-opened window for ${username}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set a temporary password for a registered player (returned once, never stored). */
|
||||||
|
export async function resetPassword(username: string): Promise<string> {
|
||||||
|
const temp = generatePassword(10);
|
||||||
|
await easyauthSetPassword(username, temp);
|
||||||
|
try {
|
||||||
|
await kickPlayer(username, "Your password was reset — log in with the new one");
|
||||||
|
} catch {}
|
||||||
|
logActivity("password_reset", username);
|
||||||
|
notify("info", `Password reset for ${username}`, "Share the temporary password with them privately.", {
|
||||||
|
discord: false,
|
||||||
|
});
|
||||||
|
return temp;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete the EasyAuth account but keep them whitelisted with a fresh window. */
|
||||||
|
export async function forceReregister(username: string): Promise<void> {
|
||||||
|
await easyauthRemove(username);
|
||||||
|
try {
|
||||||
|
await kickPlayer(username, "Please rejoin and /register a new password");
|
||||||
|
} catch {}
|
||||||
|
await whitelistAdd(username);
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE approved_players SET status = 'pending', window_expires_at = ?, registered_at = NULL WHERE username = ?")
|
||||||
|
.run(Date.now() + windowMs(), username);
|
||||||
|
logActivity("approve", username, "Forced re-registration");
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Import whitelist entries that were added outside the panel. */
|
||||||
|
export async function importFromWhitelist(): Promise<number> {
|
||||||
|
const [wl, registered] = await Promise.all([whitelistList(), easyauthList()]);
|
||||||
|
const registeredSet = new Set(registered.map((n) => n.toLowerCase()));
|
||||||
|
let added = 0;
|
||||||
|
const insert = db().prepare(
|
||||||
|
`INSERT OR IGNORE INTO approved_players(username, status, approved_at, registered_at, note)
|
||||||
|
VALUES(?, ?, ?, ?, 'imported from whitelist')`
|
||||||
|
);
|
||||||
|
for (const name of wl) {
|
||||||
|
const isReg = registeredSet.has(name.toLowerCase());
|
||||||
|
const res = insert.run(name, isReg ? "registered" : "expired", Date.now(), isReg ? Date.now() : null);
|
||||||
|
if (res.changes > 0) added++;
|
||||||
|
}
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── background watcher ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const g = globalThis as unknown as { __craftdeckAuthWatch?: { timer: NodeJS.Timeout } };
|
||||||
|
|
||||||
|
async function reconcile() {
|
||||||
|
const pending = db()
|
||||||
|
.prepare("SELECT * FROM approved_players WHERE status = 'pending'")
|
||||||
|
.all() as unknown as ApprovedPlayer[];
|
||||||
|
if (pending.length === 0) return;
|
||||||
|
|
||||||
|
let registered: Set<string> | null = null;
|
||||||
|
try {
|
||||||
|
registered = new Set((await easyauthList()).map((n) => n.toLowerCase()));
|
||||||
|
} catch {
|
||||||
|
// Server offline: don't expire windows while nobody could register anyway —
|
||||||
|
// extend windows by the poll interval instead of punishing the player.
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE approved_players SET window_expires_at = window_expires_at + 5000 WHERE status = 'pending'")
|
||||||
|
.run();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
for (const p of pending) {
|
||||||
|
if (registered.has(p.username.toLowerCase())) {
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE approved_players SET status = 'registered', registered_at = ?, window_expires_at = NULL WHERE id = ?")
|
||||||
|
.run(now, p.id);
|
||||||
|
logActivity("register", p.username);
|
||||||
|
notify("success", `${p.username} registered`, "They can now log in with their password.");
|
||||||
|
bus().emit("mc-event", { ts: now, type: "register", player: p.username });
|
||||||
|
} else if (p.window_expires_at !== null && p.window_expires_at < now) {
|
||||||
|
try {
|
||||||
|
await whitelistRemove(p.username);
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
await kickPlayer(p.username, "Registration window expired — ask the admin to approve you again");
|
||||||
|
} catch {}
|
||||||
|
db().prepare("UPDATE approved_players SET status = 'expired' WHERE id = ?").run(p.id);
|
||||||
|
logActivity("expire", p.username, "Registration window expired");
|
||||||
|
notify("warn", `Window expired for ${p.username}`, "They didn't register in time. Re-approve when they're ready.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startAuthWatcher() {
|
||||||
|
if (g.__craftdeckAuthWatch) return;
|
||||||
|
g.__craftdeckAuthWatch = {
|
||||||
|
timer: setInterval(() => void reconcile().catch(() => {}), 5000),
|
||||||
|
};
|
||||||
|
// React immediately to registration hints from the log watcher.
|
||||||
|
bus().on("mc-event", (e: { type: string }) => {
|
||||||
|
if (e.type === "register-hint") void reconcile().catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { execInContainer, powerContainer, inspectContainer } from "./docker";
|
||||||
|
import { db, logActivity } from "./db";
|
||||||
|
import { notify } from "./notify";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Backup management on top of the itzg/mc-backup sidecar (scheduled tars)
|
||||||
|
* plus panel-driven ad-hoc backups, restores, and deletion.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const BACKUP_NAME_RE = /^[\w][\w.\-]*\.(tgz|tar\.gz)$/;
|
||||||
|
|
||||||
|
export type BackupInfo = { name: string; size: number; mtime: number };
|
||||||
|
|
||||||
|
function backupPath(name: string): string {
|
||||||
|
if (!BACKUP_NAME_RE.test(name) || name !== path.basename(name)) {
|
||||||
|
throw new Error("Invalid backup file name");
|
||||||
|
}
|
||||||
|
return path.join(env.backupsDir, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function listBackups(): BackupInfo[] {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(env.backupsDir);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return entries
|
||||||
|
.filter((n) => BACKUP_NAME_RE.test(n))
|
||||||
|
.map((name) => {
|
||||||
|
const st = fs.statSync(path.join(env.backupsDir, name));
|
||||||
|
return { name, size: st.size, mtime: st.mtimeMs };
|
||||||
|
})
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function triggerBackup(): Promise<string> {
|
||||||
|
const before = new Set(listBackups().map((b) => b.name));
|
||||||
|
const res = await execInContainer(env.backupContainer, ["backup", "now"], 30 * 60 * 1000);
|
||||||
|
if (res.exitCode !== 0) {
|
||||||
|
notify("error", "Backup failed", res.output.slice(-800));
|
||||||
|
throw new Error(`Backup failed: ${res.output.slice(-300)}`);
|
||||||
|
}
|
||||||
|
const created = listBackups().find((b) => !before.has(b.name));
|
||||||
|
logActivity("backup", null, created?.name ?? "manual backup");
|
||||||
|
notify("success", "Backup completed", created ? created.name : "", { discord: false });
|
||||||
|
return created?.name ?? "backup completed";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function restoreBackup(name: string): Promise<void> {
|
||||||
|
const file = backupPath(name);
|
||||||
|
if (!fs.existsSync(file)) throw new Error("Backup file not found");
|
||||||
|
|
||||||
|
const state = await inspectContainer(env.mcContainer);
|
||||||
|
if (state.running) await powerContainer(env.mcContainer, "stop");
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
execFile(
|
||||||
|
"tar",
|
||||||
|
["-xpzf", file, "-C", env.mcDataDir],
|
||||||
|
{ timeout: 30 * 60 * 1000 },
|
||||||
|
(err, _stdout, stderr) => (err ? reject(new Error(stderr || err.message)) : resolve())
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
await powerContainer(env.mcContainer, "start");
|
||||||
|
logActivity("restore", null, name);
|
||||||
|
notify("success", "Backup restored", `World restored from ${name}. Server starting.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteBackup(name: string): void {
|
||||||
|
fs.unlinkSync(backupPath(name));
|
||||||
|
logActivity("backup_delete", null, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function backupsDirSize(): number {
|
||||||
|
return listBackups().reduce((sum, b) => sum + b.size, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBackupStream(name: string): { stream: fs.ReadStream; size: number } {
|
||||||
|
const file = backupPath(name);
|
||||||
|
const st = fs.statSync(file);
|
||||||
|
return { stream: fs.createReadStream(file), size: st.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take a safety backup and wait for it, used before risky operations. */
|
||||||
|
export async function safetyBackup(reason: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await triggerBackup();
|
||||||
|
} catch (err) {
|
||||||
|
db();
|
||||||
|
notify("warn", "Safety backup failed", `Proceeding with ${reason} anyway: ${(err as Error).message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { db } from "./db";
|
||||||
|
import { env } from "./env";
|
||||||
|
import { hashPassword, generatePassword } from "./passwords";
|
||||||
|
import { startLogWatcher } from "./logwatch";
|
||||||
|
import { startSampler } from "./sampler";
|
||||||
|
import { startAuthWatcher } from "./authflow";
|
||||||
|
import { startScheduler } from "./scheduler";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One-time process bootstrap, invoked from instrumentation.ts when the
|
||||||
|
* Next.js server starts. Guarded against dev-mode HMR re-execution.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const g = globalThis as unknown as { __craftdeckBooted?: boolean };
|
||||||
|
|
||||||
|
export function boot() {
|
||||||
|
if (g.__craftdeckBooted) return;
|
||||||
|
g.__craftdeckBooted = true;
|
||||||
|
|
||||||
|
for (const dir of [env.appDataDir, env.mcDataDir, env.backupsDir]) {
|
||||||
|
try {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
seedAdminUser();
|
||||||
|
startLogWatcher();
|
||||||
|
startSampler();
|
||||||
|
startAuthWatcher();
|
||||||
|
startScheduler();
|
||||||
|
|
||||||
|
console.log(`[craftdeck] panel services started (data: ${env.appDataDir})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function seedAdminUser() {
|
||||||
|
const count = (db().prepare("SELECT COUNT(*) AS n FROM panel_users").get() as { n: number }).n;
|
||||||
|
if (count > 0) return;
|
||||||
|
let password = env.initialAdminPassword;
|
||||||
|
let generated = false;
|
||||||
|
if (!password) {
|
||||||
|
password = generatePassword(14);
|
||||||
|
generated = true;
|
||||||
|
}
|
||||||
|
db()
|
||||||
|
.prepare("INSERT INTO panel_users(username, pass_hash, must_change, created_at) VALUES(?, ?, 1, ?)")
|
||||||
|
.run("admin", hashPassword(password), Date.now());
|
||||||
|
if (generated) {
|
||||||
|
console.log("┌──────────────────────────────────────────────────────┐");
|
||||||
|
console.log("│ CraftDeck first run — dashboard login │");
|
||||||
|
console.log(`│ username: admin │`);
|
||||||
|
console.log(`│ password: ${password.padEnd(40)}│`);
|
||||||
|
console.log("│ You will be asked to change it on first login. │");
|
||||||
|
console.log("└──────────────────────────────────────────────────────┘");
|
||||||
|
} else {
|
||||||
|
console.log("[craftdeck] admin user seeded from PANEL_ADMIN_PASSWORD (change it on first login)");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { EventEmitter } from "node:events";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* In-process event bus for fanning out live data to SSE subscribers.
|
||||||
|
* Events:
|
||||||
|
* "log" (line: string) raw server console line
|
||||||
|
* "mc-event" (e: McEvent) parsed game event
|
||||||
|
* "metrics" (m: Record<string, unknown>) fresh sampler tick
|
||||||
|
* "notification" (n: Record<string, unknown>) new panel notification
|
||||||
|
*/
|
||||||
|
export type McEvent = {
|
||||||
|
ts: number;
|
||||||
|
type: string;
|
||||||
|
player?: string;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
const g = globalThis as unknown as { __craftdeckBus?: EventEmitter };
|
||||||
|
|
||||||
|
export function bus(): EventEmitter {
|
||||||
|
if (!g.__craftdeckBus) {
|
||||||
|
const e = new EventEmitter();
|
||||||
|
e.setMaxListeners(100); // one listener per open SSE connection
|
||||||
|
g.__craftdeckBus = e;
|
||||||
|
}
|
||||||
|
return g.__craftdeckBus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ring buffer of recent console lines so new SSE clients get instant backfill. */
|
||||||
|
const gl = globalThis as unknown as { __craftdeckLogRing?: string[] };
|
||||||
|
|
||||||
|
export function logRing(): string[] {
|
||||||
|
if (!gl.__craftdeckLogRing) gl.__craftdeckLogRing = [];
|
||||||
|
return gl.__craftdeckLogRing;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function pushLogLine(line: string) {
|
||||||
|
const ring = logRing();
|
||||||
|
ring.push(line);
|
||||||
|
if (ring.length > 500) ring.splice(0, ring.length - 500);
|
||||||
|
bus().emit("log", line);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Apply configuration changes by re-running docker compose for a service.
|
||||||
|
* The dashboard image bundles the docker CLI + compose plugin and mounts the
|
||||||
|
* project directory, so compose picks up .env edits and recreates only what
|
||||||
|
* changed.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function runCompose(args: string[]): Promise<{ ok: boolean; output: string }> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile(
|
||||||
|
"docker",
|
||||||
|
["compose", "--project-directory", env.projectDir, ...args],
|
||||||
|
{ timeout: 10 * 60 * 1000, maxBuffer: 4 * 1024 * 1024 },
|
||||||
|
(err, stdout, stderr) => {
|
||||||
|
resolve({ ok: !err, output: [stdout, stderr, err?.message].filter(Boolean).join("\n") });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recreate the Minecraft container so new .env values take effect. */
|
||||||
|
export function applyMcConfig(): Promise<{ ok: boolean; output: string }> {
|
||||||
|
return runCompose(["up", "-d", "--no-deps", env.mcContainer]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recreate the backup sidecar (after backup schedule changes). */
|
||||||
|
export function applyBackupConfig(): Promise<{ ok: boolean; output: string }> {
|
||||||
|
return runCompose(["up", "-d", "--no-deps", "backup"]);
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
import { DatabaseSync } from "node:sqlite";
|
||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel database (node:sqlite — no native deps). Single connection reused
|
||||||
|
* across HMR reloads via globalThis.
|
||||||
|
*/
|
||||||
|
const g = globalThis as unknown as { __craftdeckDb?: DatabaseSync };
|
||||||
|
|
||||||
|
function open(): DatabaseSync {
|
||||||
|
fs.mkdirSync(env.appDataDir, { recursive: true });
|
||||||
|
const db = new DatabaseSync(path.join(env.appDataDir, "craftdeck.db"));
|
||||||
|
db.exec("PRAGMA journal_mode = WAL");
|
||||||
|
db.exec("PRAGMA busy_timeout = 5000");
|
||||||
|
migrate(db);
|
||||||
|
return db;
|
||||||
|
}
|
||||||
|
|
||||||
|
function migrate(db: DatabaseSync) {
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS panel_users (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE NOT NULL,
|
||||||
|
pass_hash TEXT NOT NULL,
|
||||||
|
must_change INTEGER NOT NULL DEFAULT 0,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS approved_players (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
username TEXT UNIQUE COLLATE NOCASE NOT NULL,
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending', -- pending | registered | expired | revoked
|
||||||
|
approved_at INTEGER,
|
||||||
|
window_expires_at INTEGER,
|
||||||
|
registered_at INTEGER,
|
||||||
|
last_seen_at INTEGER,
|
||||||
|
note TEXT
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS activity (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
type TEXT NOT NULL, -- join | leave | register | approve | expire | revoke | server_online | server_offline | backup | chat_command | crash
|
||||||
|
player TEXT,
|
||||||
|
detail TEXT
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_activity_ts ON activity(ts DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS metrics (
|
||||||
|
ts INTEGER PRIMARY KEY,
|
||||||
|
cpu REAL, mem_used INTEGER, mem_limit INTEGER,
|
||||||
|
net_rx INTEGER, net_tx INTEGER,
|
||||||
|
tps REAL, mspt REAL, players INTEGER,
|
||||||
|
disk_used INTEGER, disk_free INTEGER
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS ping_samples (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
player TEXT NOT NULL,
|
||||||
|
ping INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ping_ts ON ping_samples(ts DESC);
|
||||||
|
CREATE TABLE IF NOT EXISTS notifications (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
level TEXT NOT NULL, -- info | success | warn | error
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
body TEXT,
|
||||||
|
read INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS schedules (
|
||||||
|
id INTEGER PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
cron TEXT NOT NULL,
|
||||||
|
action TEXT NOT NULL, -- restart | backup | command | announce
|
||||||
|
payload TEXT,
|
||||||
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
last_run_at INTEGER,
|
||||||
|
last_result TEXT,
|
||||||
|
created_at INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE IF NOT EXISTS settings (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function db(): DatabaseSync {
|
||||||
|
if (!g.__craftdeckDb) g.__craftdeckDb = open();
|
||||||
|
return g.__craftdeckDb;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── settings helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getSetting(key: string, fallback: string): string {
|
||||||
|
const row = db().prepare("SELECT value FROM settings WHERE key = ?").get(key) as
|
||||||
|
| { value: string }
|
||||||
|
| undefined;
|
||||||
|
return row?.value ?? fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setSetting(key: string, value: string) {
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
"INSERT INTO settings(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value"
|
||||||
|
)
|
||||||
|
.run(key, value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getBoolSetting(key: string, fallback: boolean): boolean {
|
||||||
|
return getSetting(key, fallback ? "1" : "0") === "1";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── activity helpers ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function logActivity(type: string, player?: string | null, detail?: string | null) {
|
||||||
|
db()
|
||||||
|
.prepare("INSERT INTO activity(ts, type, player, detail) VALUES(?, ?, ?, ?)")
|
||||||
|
.run(Date.now(), type, player ?? null, detail ?? null);
|
||||||
|
}
|
||||||
@@ -0,0 +1,296 @@
|
|||||||
|
import http from "node:http";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Docker Engine API client over the unix socket (no dependencies).
|
||||||
|
* Everything degrades to typed errors so the UI can render "docker
|
||||||
|
* unavailable" / "container missing" states instead of crashing.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class DockerError extends Error {
|
||||||
|
constructor(
|
||||||
|
message: string,
|
||||||
|
public kind: "unavailable" | "not_found" | "api" = "api",
|
||||||
|
public statusCode?: number
|
||||||
|
) {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type ReqOptions = {
|
||||||
|
method?: string;
|
||||||
|
body?: unknown;
|
||||||
|
timeoutMs?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function request(
|
||||||
|
path: string,
|
||||||
|
opts: ReqOptions = {}
|
||||||
|
): Promise<{ status: number; body: Buffer }> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const payload = opts.body !== undefined ? JSON.stringify(opts.body) : undefined;
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
socketPath: env.dockerSocket,
|
||||||
|
path,
|
||||||
|
method: opts.method ?? "GET",
|
||||||
|
headers: payload
|
||||||
|
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
|
||||||
|
: {},
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
const chunks: Buffer[] = [];
|
||||||
|
res.on("data", (c) => chunks.push(c));
|
||||||
|
res.on("end", () =>
|
||||||
|
resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks) })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.setTimeout(opts.timeoutMs ?? 30_000, () => req.destroy(new Error("docker API timeout")));
|
||||||
|
req.on("error", (err: NodeJS.ErrnoException) => {
|
||||||
|
if (err.code === "ENOENT" || err.code === "ECONNREFUSED" || err.code === "EACCES") {
|
||||||
|
reject(new DockerError(`Docker socket unavailable (${err.code})`, "unavailable"));
|
||||||
|
} else {
|
||||||
|
reject(new DockerError(err.message));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if (payload) req.write(payload);
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function json<T>(path: string, opts: ReqOptions = {}): Promise<T> {
|
||||||
|
const { status, body } = await request(path, opts);
|
||||||
|
if (status === 404) throw new DockerError(`Not found: ${path}`, "not_found", 404);
|
||||||
|
if (status >= 400) {
|
||||||
|
let msg = body.toString("utf8");
|
||||||
|
try {
|
||||||
|
msg = (JSON.parse(msg) as { message?: string }).message ?? msg;
|
||||||
|
} catch {}
|
||||||
|
throw new DockerError(msg, "api", status);
|
||||||
|
}
|
||||||
|
return body.length ? (JSON.parse(body.toString("utf8")) as T) : (undefined as T);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── container state ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ContainerState = {
|
||||||
|
exists: boolean;
|
||||||
|
running: boolean;
|
||||||
|
status: string; // running | exited | restarting | created | missing
|
||||||
|
health?: string; // healthy | unhealthy | starting
|
||||||
|
startedAt?: string;
|
||||||
|
exitCode?: number;
|
||||||
|
image?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type InspectResponse = {
|
||||||
|
State: {
|
||||||
|
Status: string;
|
||||||
|
Running: boolean;
|
||||||
|
StartedAt: string;
|
||||||
|
ExitCode: number;
|
||||||
|
Health?: { Status: string };
|
||||||
|
};
|
||||||
|
Config: { Image: string; Env: string[] };
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function inspectContainer(name: string): Promise<ContainerState> {
|
||||||
|
try {
|
||||||
|
const data = await json<InspectResponse>(`/containers/${name}/json`);
|
||||||
|
return {
|
||||||
|
exists: true,
|
||||||
|
running: data.State.Running,
|
||||||
|
status: data.State.Status,
|
||||||
|
health: data.State.Health?.Status,
|
||||||
|
startedAt: data.State.StartedAt,
|
||||||
|
exitCode: data.State.ExitCode,
|
||||||
|
image: data.Config.Image,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof DockerError && err.kind === "not_found") {
|
||||||
|
return { exists: false, running: false, status: "missing" };
|
||||||
|
}
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function containerEnv(name: string): Promise<Record<string, string>> {
|
||||||
|
const data = await json<InspectResponse>(`/containers/${name}/json`);
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
for (const kv of data.Config.Env ?? []) {
|
||||||
|
const i = kv.indexOf("=");
|
||||||
|
if (i > 0) out[kv.slice(0, i)] = kv.slice(i + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── power actions ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type PowerAction = "start" | "stop" | "restart" | "kill";
|
||||||
|
|
||||||
|
export async function powerContainer(name: string, action: PowerAction): Promise<void> {
|
||||||
|
const timeout = action === "stop" || action === "restart" ? "?t=90" : "";
|
||||||
|
const { status, body } = await request(`/containers/${name}/${action}${timeout}`, {
|
||||||
|
method: "POST",
|
||||||
|
// stop/restart of a busy MC server can take a while; wait past t=90
|
||||||
|
timeoutMs: 150_000,
|
||||||
|
});
|
||||||
|
// 204 = done, 304 = already in desired state
|
||||||
|
if (status !== 204 && status !== 304) {
|
||||||
|
throw new DockerError(body.toString("utf8") || `${action} failed (${status})`, "api", status);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── stats ───────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type ContainerStats = {
|
||||||
|
cpuPercent: number;
|
||||||
|
memUsed: number;
|
||||||
|
memLimit: number;
|
||||||
|
netRx: number; // cumulative bytes
|
||||||
|
netTx: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type StatsResponse = {
|
||||||
|
cpu_stats: {
|
||||||
|
cpu_usage: { total_usage: number };
|
||||||
|
system_cpu_usage?: number;
|
||||||
|
online_cpus?: number;
|
||||||
|
};
|
||||||
|
precpu_stats: {
|
||||||
|
cpu_usage: { total_usage: number };
|
||||||
|
system_cpu_usage?: number;
|
||||||
|
};
|
||||||
|
memory_stats: { usage?: number; stats?: { inactive_file?: number }; limit?: number };
|
||||||
|
networks?: Record<string, { rx_bytes: number; tx_bytes: number }>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function containerStats(name: string): Promise<ContainerStats> {
|
||||||
|
// one-shot=false + stream=false makes the daemon take two samples so the
|
||||||
|
// precpu fields are populated and CPU% is computable from a single call
|
||||||
|
const s = await json<StatsResponse>(`/containers/${name}/stats?stream=false`);
|
||||||
|
const cpuDelta = s.cpu_stats.cpu_usage.total_usage - s.precpu_stats.cpu_usage.total_usage;
|
||||||
|
const sysDelta = (s.cpu_stats.system_cpu_usage ?? 0) - (s.precpu_stats.system_cpu_usage ?? 0);
|
||||||
|
const cpus = s.cpu_stats.online_cpus ?? 1;
|
||||||
|
const cpuPercent = sysDelta > 0 && cpuDelta > 0 ? (cpuDelta / sysDelta) * cpus * 100 : 0;
|
||||||
|
const rawUsage = s.memory_stats.usage ?? 0;
|
||||||
|
const memUsed = rawUsage - (s.memory_stats.stats?.inactive_file ?? 0);
|
||||||
|
let netRx = 0;
|
||||||
|
let netTx = 0;
|
||||||
|
for (const nic of Object.values(s.networks ?? {})) {
|
||||||
|
netRx += nic.rx_bytes;
|
||||||
|
netTx += nic.tx_bytes;
|
||||||
|
}
|
||||||
|
return { cpuPercent, memUsed, memLimit: s.memory_stats.limit ?? 0, netRx, netTx };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── logs (multiplexed stream demux) ─────────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Docker multiplexes stdout/stderr into frames: 8-byte header
|
||||||
|
* [type, 0, 0, 0, len_be32] followed by len payload bytes.
|
||||||
|
*/
|
||||||
|
function createDemuxer(onLine: (line: string) => void) {
|
||||||
|
let buf = Buffer.alloc(0);
|
||||||
|
let textCarry = "";
|
||||||
|
return (chunk: Buffer) => {
|
||||||
|
buf = Buffer.concat([buf, chunk]);
|
||||||
|
while (buf.length >= 8) {
|
||||||
|
const first = buf[0];
|
||||||
|
// Frames start with stream type 0/1/2; if not, the container was
|
||||||
|
// started with a TTY and the stream is raw text.
|
||||||
|
if (first > 2) {
|
||||||
|
textCarry += buf.toString("utf8");
|
||||||
|
buf = Buffer.alloc(0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
const len = buf.readUInt32BE(4);
|
||||||
|
if (buf.length < 8 + len) break;
|
||||||
|
textCarry += buf.subarray(8, 8 + len).toString("utf8");
|
||||||
|
buf = buf.subarray(8 + len);
|
||||||
|
}
|
||||||
|
let nl;
|
||||||
|
while ((nl = textCarry.indexOf("\n")) >= 0) {
|
||||||
|
onLine(textCarry.slice(0, nl).replace(/\r$/, ""));
|
||||||
|
textCarry = textCarry.slice(nl + 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export type LogStreamHandle = { stop: () => void };
|
||||||
|
|
||||||
|
export function streamContainerLogs(
|
||||||
|
name: string,
|
||||||
|
opts: { tail: number; follow: boolean },
|
||||||
|
onLine: (line: string) => void,
|
||||||
|
onEnd?: (err?: Error) => void
|
||||||
|
): LogStreamHandle {
|
||||||
|
const req = http.request({
|
||||||
|
socketPath: env.dockerSocket,
|
||||||
|
path: `/containers/${name}/logs?stdout=1&stderr=1&tail=${opts.tail}&follow=${opts.follow ? 1 : 0}`,
|
||||||
|
method: "GET",
|
||||||
|
});
|
||||||
|
let stopped = false;
|
||||||
|
req.on("response", (res) => {
|
||||||
|
if ((res.statusCode ?? 500) >= 400) {
|
||||||
|
res.resume();
|
||||||
|
onEnd?.(new DockerError(`logs failed (${res.statusCode})`, "api", res.statusCode));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const demux = createDemuxer(onLine);
|
||||||
|
res.on("data", (c: Buffer) => demux(c));
|
||||||
|
res.on("end", () => onEnd?.());
|
||||||
|
res.on("error", (e) => onEnd?.(e as Error));
|
||||||
|
});
|
||||||
|
req.on("error", (e) => {
|
||||||
|
if (!stopped) onEnd?.(e as Error);
|
||||||
|
});
|
||||||
|
req.end();
|
||||||
|
return {
|
||||||
|
stop: () => {
|
||||||
|
stopped = true;
|
||||||
|
req.destroy();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── exec ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function execInContainer(
|
||||||
|
name: string,
|
||||||
|
cmd: string[],
|
||||||
|
timeoutMs = 15 * 60 * 1000
|
||||||
|
): Promise<{ exitCode: number; output: string }> {
|
||||||
|
const { Id } = await json<{ Id: string }>(`/containers/${name}/exec`, {
|
||||||
|
method: "POST",
|
||||||
|
body: { AttachStdout: true, AttachStderr: true, Cmd: cmd },
|
||||||
|
});
|
||||||
|
|
||||||
|
const output = await new Promise<string>((resolve, reject) => {
|
||||||
|
const req = http.request(
|
||||||
|
{
|
||||||
|
socketPath: env.dockerSocket,
|
||||||
|
path: `/exec/${Id}/start`,
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
},
|
||||||
|
(res) => {
|
||||||
|
let text = "";
|
||||||
|
const demux = createDemuxer((line) => {
|
||||||
|
text += line + "\n";
|
||||||
|
});
|
||||||
|
res.on("data", (c: Buffer) => demux(c));
|
||||||
|
res.on("end", () => resolve(text));
|
||||||
|
res.on("error", reject);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
req.setTimeout(timeoutMs, () => req.destroy(new Error("exec timeout")));
|
||||||
|
req.on("error", reject);
|
||||||
|
req.write(JSON.stringify({ Detach: false, Tty: false }));
|
||||||
|
req.end();
|
||||||
|
});
|
||||||
|
|
||||||
|
const info = await json<{ ExitCode: number | null }>(`/exec/${Id}/json`);
|
||||||
|
return { exitCode: info.ExitCode ?? 0, output };
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import path from "node:path";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Central environment access. In production these are set by docker-compose;
|
||||||
|
* in local dev sensible fallbacks point at ./dev-data so the app boots
|
||||||
|
* without the container stack (integrations degrade to "offline" states).
|
||||||
|
*/
|
||||||
|
const dev = process.env.NODE_ENV !== "production";
|
||||||
|
|
||||||
|
function str(key: string, fallback: string): string {
|
||||||
|
const v = process.env[key];
|
||||||
|
return v !== undefined && v !== "" ? v : fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const env = {
|
||||||
|
dev,
|
||||||
|
sessionSecret: str("PANEL_SESSION_SECRET", dev ? "craftdeck-dev-secret-do-not-use" : ""),
|
||||||
|
initialAdminPassword: str("PANEL_ADMIN_PASSWORD", dev ? "admin" : ""),
|
||||||
|
|
||||||
|
rconHost: str("RCON_HOST", "127.0.0.1"),
|
||||||
|
rconPort: Number(str("RCON_PORT", "25575")),
|
||||||
|
rconPassword: str("RCON_PASSWORD", "minecraft"),
|
||||||
|
|
||||||
|
dockerSocket: str("DOCKER_SOCKET", "/var/run/docker.sock"),
|
||||||
|
mcContainer: str("MC_CONTAINER", "mc"),
|
||||||
|
backupContainer: str("BACKUP_CONTAINER", "mc-backup"),
|
||||||
|
|
||||||
|
mcDataDir: path.resolve(str("MC_DATA_DIR", dev ? "./dev-data/mc" : "/mc-data")),
|
||||||
|
backupsDir: path.resolve(str("BACKUPS_DIR", dev ? "./dev-data/backups" : "/backups")),
|
||||||
|
appDataDir: path.resolve(str("APP_DATA_DIR", dev ? "./dev-data/app" : "/app-data")),
|
||||||
|
projectDir: path.resolve(str("PROJECT_DIR", dev ? ".." : "/project")),
|
||||||
|
|
||||||
|
registrationWindowMinutes: Number(str("REGISTRATION_WINDOW_MINUTES", "2")),
|
||||||
|
discordWebhookUrl: str("DISCORD_WEBHOOK_URL", ""),
|
||||||
|
};
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read/write the project .env file while preserving comments and ordering.
|
||||||
|
* This file is the single source of truth for server configuration
|
||||||
|
* (MC version, memory, mod list, backup schedule, ...).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function envFilePath(): string {
|
||||||
|
return path.join(env.projectDir, ".env");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readEnvFile(): Record<string, string> {
|
||||||
|
const out: Record<string, string> = {};
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = fs.readFileSync(envFilePath(), "utf8");
|
||||||
|
} catch {
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
for (const line of text.split("\n")) {
|
||||||
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=(.*)$/);
|
||||||
|
if (m) out[m[1]] = m[2].trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeEnvFile(updates: Record<string, string>): void {
|
||||||
|
const file = envFilePath();
|
||||||
|
let lines: string[];
|
||||||
|
try {
|
||||||
|
lines = fs.readFileSync(file, "utf8").split("\n");
|
||||||
|
} catch {
|
||||||
|
lines = [];
|
||||||
|
}
|
||||||
|
const remaining = new Map(Object.entries(updates));
|
||||||
|
const next = lines.map((line) => {
|
||||||
|
const m = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/);
|
||||||
|
if (m && remaining.has(m[1])) {
|
||||||
|
const value = remaining.get(m[1])!;
|
||||||
|
remaining.delete(m[1]);
|
||||||
|
return `${m[1]}=${value}`;
|
||||||
|
}
|
||||||
|
return line;
|
||||||
|
});
|
||||||
|
for (const [key, value] of remaining) next.push(`${key}=${value}`);
|
||||||
|
fs.writeFileSync(file, next.join("\n"));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Keys the config UI is allowed to edit (guards against arbitrary writes). */
|
||||||
|
export const EDITABLE_ENV_KEYS = [
|
||||||
|
"MC_VERSION",
|
||||||
|
"SERVER_TYPE",
|
||||||
|
"MC_MEMORY",
|
||||||
|
"MC_CONTAINER_MEM_LIMIT",
|
||||||
|
"MC_PORT",
|
||||||
|
"MC_MOTD",
|
||||||
|
"MC_DIFFICULTY",
|
||||||
|
"MC_MAX_PLAYERS",
|
||||||
|
"MC_VIEW_DISTANCE",
|
||||||
|
"MC_SIMULATION_DISTANCE",
|
||||||
|
"MC_SEED",
|
||||||
|
"MODRINTH_PROJECTS",
|
||||||
|
"MODRINTH_MODPACK",
|
||||||
|
"BACKUP_INTERVAL",
|
||||||
|
"BACKUP_RETENTION_DAYS",
|
||||||
|
"REGISTRATION_WINDOW_MINUTES",
|
||||||
|
"DISCORD_WEBHOOK_URL",
|
||||||
|
"TZ",
|
||||||
|
] as const;
|
||||||
@@ -0,0 +1,124 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sandboxed file access for the file browser / editors. Every path is
|
||||||
|
* resolved against the server data root and verified to stay inside it
|
||||||
|
* (including through symlinks).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAX_EDITABLE_BYTES = 2 * 1024 * 1024;
|
||||||
|
|
||||||
|
// The Minecraft server runs as uid/gid 1000 (itzg image default); files the
|
||||||
|
// panel creates must stay writable by it.
|
||||||
|
const MC_UID = 1000;
|
||||||
|
const MC_GID = 1000;
|
||||||
|
|
||||||
|
export function resolveSafe(rel: string): string {
|
||||||
|
const root = env.mcDataDir;
|
||||||
|
const cleaned = path.normalize(rel).replace(/^([/\\])+/, "");
|
||||||
|
const abs = path.resolve(root, cleaned);
|
||||||
|
if (abs !== root && !abs.startsWith(root + path.sep)) {
|
||||||
|
throw new Error("Path escapes the server data directory");
|
||||||
|
}
|
||||||
|
// Make sure symlinks don't lead outside the root. Check the path itself
|
||||||
|
// when it exists, otherwise its parent (for files about to be created).
|
||||||
|
const target = fs.existsSync(abs) ? abs : path.dirname(abs);
|
||||||
|
if (fs.existsSync(target)) {
|
||||||
|
const real = fs.realpathSync(target);
|
||||||
|
const realRoot = fs.realpathSync(root);
|
||||||
|
if (real !== realRoot && !real.startsWith(realRoot + path.sep)) {
|
||||||
|
throw new Error("Path escapes the server data directory");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return abs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type DirEntry = {
|
||||||
|
name: string;
|
||||||
|
type: "file" | "dir";
|
||||||
|
size: number;
|
||||||
|
mtime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function listDir(rel: string): DirEntry[] {
|
||||||
|
const abs = resolveSafe(rel);
|
||||||
|
return fs
|
||||||
|
.readdirSync(abs, { withFileTypes: true })
|
||||||
|
.map((e) => {
|
||||||
|
let size = 0;
|
||||||
|
let mtime = 0;
|
||||||
|
try {
|
||||||
|
const st = fs.statSync(path.join(abs, e.name));
|
||||||
|
size = st.size;
|
||||||
|
mtime = st.mtimeMs;
|
||||||
|
} catch {}
|
||||||
|
return {
|
||||||
|
name: e.name,
|
||||||
|
type: e.isDirectory() ? ("dir" as const) : ("file" as const),
|
||||||
|
size,
|
||||||
|
mtime,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => (a.type === b.type ? a.name.localeCompare(b.name) : a.type === "dir" ? -1 : 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readTextFile(rel: string): { content: string; truncated: boolean } {
|
||||||
|
const abs = resolveSafe(rel);
|
||||||
|
const st = fs.statSync(abs);
|
||||||
|
if (st.size > MAX_EDITABLE_BYTES) {
|
||||||
|
const fd = fs.openSync(abs, "r");
|
||||||
|
const buf = Buffer.alloc(MAX_EDITABLE_BYTES);
|
||||||
|
fs.readSync(fd, buf, 0, MAX_EDITABLE_BYTES, 0);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
return { content: buf.toString("utf8"), truncated: true };
|
||||||
|
}
|
||||||
|
const buf = fs.readFileSync(abs);
|
||||||
|
if (buf.subarray(0, 8000).includes(0)) throw new Error("Binary file — download it instead");
|
||||||
|
return { content: buf.toString("utf8"), truncated: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeTextFile(rel: string, content: string): void {
|
||||||
|
const abs = resolveSafe(rel);
|
||||||
|
fs.writeFileSync(abs, content);
|
||||||
|
fixOwnership(abs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function writeBinaryFile(rel: string, data: Buffer): void {
|
||||||
|
const abs = resolveSafe(rel);
|
||||||
|
fs.writeFileSync(abs, data);
|
||||||
|
fixOwnership(abs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deletePath(rel: string): void {
|
||||||
|
const abs = resolveSafe(rel);
|
||||||
|
if (abs === env.mcDataDir) throw new Error("Refusing to delete the data root");
|
||||||
|
const st = fs.statSync(abs);
|
||||||
|
if (st.isDirectory()) {
|
||||||
|
fs.rmdirSync(abs); // only empty dirs — bulk deletion is deliberately unsupported
|
||||||
|
} else {
|
||||||
|
fs.unlinkSync(abs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function makeDir(rel: string): void {
|
||||||
|
const abs = resolveSafe(rel);
|
||||||
|
fs.mkdirSync(abs, { recursive: true });
|
||||||
|
fixOwnership(abs);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fileStream(rel: string): { stream: fs.ReadStream; size: number; name: string } {
|
||||||
|
const abs = resolveSafe(rel);
|
||||||
|
const st = fs.statSync(abs);
|
||||||
|
if (!st.isFile()) throw new Error("Not a file");
|
||||||
|
return { stream: fs.createReadStream(abs), size: st.size, name: path.basename(abs) };
|
||||||
|
}
|
||||||
|
|
||||||
|
function fixOwnership(abs: string) {
|
||||||
|
try {
|
||||||
|
fs.chownSync(abs, MC_UID, MC_GID);
|
||||||
|
} catch {
|
||||||
|
// Not root (local dev) — fine.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
import { streamContainerLogs, type LogStreamHandle } from "./docker";
|
||||||
|
import { bus, pushLogLine } from "./bus";
|
||||||
|
import { db, logActivity, getBoolSetting } from "./db";
|
||||||
|
import { notify } from "./notify";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tails the Minecraft container's console, feeds the live console stream,
|
||||||
|
* and parses game events (joins, leaves, server ready, ...) into the
|
||||||
|
* activity feed + notifications.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type State = {
|
||||||
|
handle: LogStreamHandle | null;
|
||||||
|
attachedAt: number;
|
||||||
|
timer: NodeJS.Timeout | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const g = globalThis as unknown as { __craftdeckLogwatch?: State };
|
||||||
|
|
||||||
|
// Strip "[12:34:56] [Server thread/INFO]: " style prefixes.
|
||||||
|
function messageOf(line: string): string {
|
||||||
|
const m = line.match(/^\[[^\]]*\]\s*\[[^\]]*\]:?\s*(.*)$/);
|
||||||
|
return m ? m[1] : line;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseEvent(line: string, backfill: boolean) {
|
||||||
|
const msg = messageOf(line).replace(/§[0-9a-fk-or]/gi, "");
|
||||||
|
const emit = (type: string, player?: string, detail?: string) => {
|
||||||
|
if (backfill) return; // don't re-record history after a panel restart
|
||||||
|
logActivity(type, player, detail);
|
||||||
|
if (player) touchLastSeen(player, type);
|
||||||
|
bus().emit("mc-event", { ts: Date.now(), type, player, detail });
|
||||||
|
if (type === "join" && getBoolSetting("notify_joins", true)) {
|
||||||
|
notify("info", `${player} joined the server`, "", { discord: getBoolSetting("discord_joins", true) });
|
||||||
|
}
|
||||||
|
if (type === "leave" && getBoolSetting("notify_joins", true)) {
|
||||||
|
notify("info", `${player} left the server`, "", { discord: getBoolSetting("discord_joins", true) });
|
||||||
|
}
|
||||||
|
if (type === "server_online") {
|
||||||
|
notify("success", "Server is online", detail ?? "");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let m;
|
||||||
|
if ((m = msg.match(/^([A-Za-z0-9_]{3,16}) joined the game/))) {
|
||||||
|
emit("join", m[1]);
|
||||||
|
} else if ((m = msg.match(/^([A-Za-z0-9_]{3,16}) left the game/))) {
|
||||||
|
emit("leave", m[1]);
|
||||||
|
} else if ((m = msg.match(/^([A-Za-z0-9_]{3,16})\[\/([0-9a-fA-F.:]+):\d+\] logged in/))) {
|
||||||
|
emit("connect", m[1], m[2]);
|
||||||
|
} else if ((m = msg.match(/^Done \(([\d.]+)s\)/))) {
|
||||||
|
emit("server_online", undefined, `Started in ${m[1]}s`);
|
||||||
|
} else if (/^Stopping the server|^Stopping server/.test(msg)) {
|
||||||
|
emit("server_stopping");
|
||||||
|
} else if (/registered/i.test(msg) && !backfill) {
|
||||||
|
// EasyAuth registration hint — the auth watcher reconciles immediately.
|
||||||
|
bus().emit("mc-event", { ts: Date.now(), type: "register-hint" });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function touchLastSeen(player: string, type: string) {
|
||||||
|
if (type !== "join" && type !== "leave" && type !== "connect") return;
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE approved_players SET last_seen_at = ? WHERE username = ?")
|
||||||
|
.run(Date.now(), player);
|
||||||
|
}
|
||||||
|
|
||||||
|
function attach(state: State) {
|
||||||
|
state.attachedAt = Date.now();
|
||||||
|
state.handle = streamContainerLogs(
|
||||||
|
env.mcContainer,
|
||||||
|
{ tail: 200, follow: true },
|
||||||
|
(line) => {
|
||||||
|
pushLogLine(line);
|
||||||
|
// Lines arriving in the first 2s are the tail backfill, not live events.
|
||||||
|
parseEvent(line, Date.now() - state.attachedAt < 2000);
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
state.handle = null; // container stopped or docker went away; retry loop reattaches
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startLogWatcher() {
|
||||||
|
if (g.__craftdeckLogwatch) return;
|
||||||
|
const state: State = { handle: null, attachedAt: 0, timer: null };
|
||||||
|
g.__craftdeckLogwatch = state;
|
||||||
|
attach(state);
|
||||||
|
state.timer = setInterval(() => {
|
||||||
|
if (!state.handle) attach(state);
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
@@ -0,0 +1,131 @@
|
|||||||
|
import { rcon, stripColors, RconError } from "./rcon";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Game-level operations on top of RCON: player list, whitelist, EasyAuth
|
||||||
|
* account management, and spark performance queries. All parsers are
|
||||||
|
* intentionally tolerant of minor message-format changes between versions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const NAME_RE = /^[A-Za-z0-9_]{3,16}$/;
|
||||||
|
|
||||||
|
export function isValidUsername(name: string): boolean {
|
||||||
|
return NAME_RE.test(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertValidUsername(name: string) {
|
||||||
|
if (!isValidUsername(name)) throw new Error(`Invalid Minecraft username: ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── players ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function listOnlinePlayers(): Promise<{ online: number; max: number; names: string[] }> {
|
||||||
|
const out = stripColors(await rcon().exec("list"));
|
||||||
|
// "There are 2 of a max of 10 players online: Alice, Bob"
|
||||||
|
const m = out.match(/There are (\d+) of a max of (\d+) players online:?\s*(.*)/i);
|
||||||
|
if (!m) return { online: 0, max: 0, names: [] };
|
||||||
|
const names = m[3]
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => NAME_RE.test(s));
|
||||||
|
return { online: Number(m[1]), max: Number(m[2]), names };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function kickPlayer(name: string, reason: string): Promise<string> {
|
||||||
|
assertValidUsername(name);
|
||||||
|
return stripColors(await rcon().exec(`kick ${name} ${reason}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function say(message: string): Promise<void> {
|
||||||
|
await rcon().exec(`say ${message.replace(/\n/g, " ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── whitelist ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export async function whitelistAdd(name: string): Promise<string> {
|
||||||
|
assertValidUsername(name);
|
||||||
|
return stripColors(await rcon().exec(`whitelist add ${name}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function whitelistRemove(name: string): Promise<string> {
|
||||||
|
assertValidUsername(name);
|
||||||
|
return stripColors(await rcon().exec(`whitelist remove ${name}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function whitelistList(): Promise<string[]> {
|
||||||
|
const out = stripColors(await rcon().exec("whitelist list"));
|
||||||
|
// "There are 3 whitelisted player(s): Alice, Bob, Carol"
|
||||||
|
const colon = out.indexOf(":");
|
||||||
|
if (colon < 0) return [];
|
||||||
|
return out
|
||||||
|
.slice(colon + 1)
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter((s) => NAME_RE.test(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── EasyAuth ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Registered account names according to EasyAuth. */
|
||||||
|
export async function easyauthList(): Promise<string[]> {
|
||||||
|
const out = stripColors(await rcon().exec("auth list"));
|
||||||
|
// Output is a list of registered usernames; extract all name-like tokens
|
||||||
|
// after a colon if present, otherwise from the whole string.
|
||||||
|
const colon = out.indexOf(":");
|
||||||
|
const source = colon >= 0 ? out.slice(colon + 1) : out;
|
||||||
|
const names = source.match(/[A-Za-z0-9_]{3,16}/g) ?? [];
|
||||||
|
// Filter obvious non-name words that may appear in the message.
|
||||||
|
const noise = new Set(["registered", "players", "list", "accounts", "there", "are", "the"]);
|
||||||
|
return [...new Set(names.filter((n) => !noise.has(n.toLowerCase())))];
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function easyauthRemove(name: string): Promise<string> {
|
||||||
|
assertValidUsername(name);
|
||||||
|
return stripColors(await rcon().exec(`auth remove ${name}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function easyauthSetPassword(name: string, password: string): Promise<string> {
|
||||||
|
assertValidUsername(name);
|
||||||
|
if (!/^[\x21-\x7e]{1,64}$/.test(password)) throw new Error("Unsupported password characters");
|
||||||
|
return stripColors(await rcon().exec(`auth update ${name} ${password}`));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── spark (performance) ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type TpsSample = { tps: number | null; mspt: number | null };
|
||||||
|
|
||||||
|
export async function sparkTps(): Promise<TpsSample> {
|
||||||
|
try {
|
||||||
|
const out = stripColors(await rcon().exec("spark tps"));
|
||||||
|
// "TPS from last 5s, 10s, 1m, 5m, 15m: 20.0, 20.0, 20.0, 20.0, 20.0"
|
||||||
|
// "Tick durations (min/med/95%ile/max ms) from last 10s, 1m: 0.8/1.2/2.4/8.0; ..."
|
||||||
|
const tpsMatch = out.match(/TPS[^:]*:\s*\*?([\d.]+)/i);
|
||||||
|
const msptMatch = out.match(/durations[^:]*:\s*[\d.]+\/([\d.]+)/i);
|
||||||
|
return {
|
||||||
|
tps: tpsMatch ? Math.min(Number(tpsMatch[1]), 20) : null,
|
||||||
|
mspt: msptMatch ? Number(msptMatch[1]) : null,
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
if (err instanceof RconError) return { tps: null, mspt: null };
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Per-player ping in ms, via spark. */
|
||||||
|
export async function sparkPings(): Promise<Record<string, number>> {
|
||||||
|
try {
|
||||||
|
const out = stripColors(await rcon().exec("spark ping"));
|
||||||
|
// Lines like "Alice: 23ms" / "Average Ping: 25.3ms"
|
||||||
|
const result: Record<string, number> = {};
|
||||||
|
for (const m of out.matchAll(/([A-Za-z0-9_]{3,16})\s*[:-]\s*([\d.]+)\s*ms/g)) {
|
||||||
|
if (m[1].toLowerCase() === "ping") continue;
|
||||||
|
result[m[1]] = Math.round(Number(m[2]));
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function sparkHealthReport(): Promise<string> {
|
||||||
|
return stripColors(await rcon().exec("spark healthreport"));
|
||||||
|
}
|
||||||
@@ -0,0 +1,145 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { readEnvFile, writeEnvFile } from "./envfile";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mod management: the MODRINTH_PROJECTS list in .env is the source of truth
|
||||||
|
* for "managed" mods (the itzg image auto-resolves compatible versions on
|
||||||
|
* every server start). Manually uploaded jars in mods/ are also surfaced.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MODRINTH_API = "https://api.modrinth.com/v2";
|
||||||
|
const UA = "craftdeck-panel/1.0 (self-hosted server dashboard)";
|
||||||
|
|
||||||
|
export type ModSearchResult = {
|
||||||
|
slug: string;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
downloads: number;
|
||||||
|
icon_url: string | null;
|
||||||
|
server_side: string;
|
||||||
|
client_side: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function currentMcVersion(): string {
|
||||||
|
return readEnvFile()["MC_VERSION"] || "26.2";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function searchMods(query: string): Promise<ModSearchResult[]> {
|
||||||
|
const facets = JSON.stringify([
|
||||||
|
["project_type:mod"],
|
||||||
|
["categories:fabric"],
|
||||||
|
[`versions:${currentMcVersion()}`],
|
||||||
|
]);
|
||||||
|
const url = `${MODRINTH_API}/search?query=${encodeURIComponent(query)}&facets=${encodeURIComponent(facets)}&limit=20`;
|
||||||
|
const res = await fetch(url, { headers: { "User-Agent": UA }, signal: AbortSignal.timeout(10_000) });
|
||||||
|
if (!res.ok) throw new Error(`Modrinth search failed (${res.status})`);
|
||||||
|
const data = (await res.json()) as { hits: ModSearchResult[] };
|
||||||
|
return data.hits;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function managedSlugs(): string[] {
|
||||||
|
return (readEnvFile()["MODRINTH_PROJECTS"] ?? "")
|
||||||
|
.split(",")
|
||||||
|
.map((s) => s.trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
const PROTECTED_SLUGS = new Set(["fabric-api", "easyauth"]);
|
||||||
|
|
||||||
|
export function addManagedMod(slug: string): string[] {
|
||||||
|
if (!/^[a-z0-9][a-z0-9\-_]{0,63}$/i.test(slug)) throw new Error("Invalid Modrinth slug");
|
||||||
|
const slugs = managedSlugs();
|
||||||
|
if (slugs.some((s) => s.toLowerCase() === slug.toLowerCase())) return slugs;
|
||||||
|
const next = [...slugs, slug.toLowerCase()];
|
||||||
|
writeEnvFile({ MODRINTH_PROJECTS: next.join(",") });
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeManagedMod(slug: string): string[] {
|
||||||
|
if (PROTECTED_SLUGS.has(slug.toLowerCase())) {
|
||||||
|
throw new Error(`${slug} is required by the panel (auth/loader) and can't be removed here`);
|
||||||
|
}
|
||||||
|
const next = managedSlugs().filter((s) => s.toLowerCase() !== slug.toLowerCase());
|
||||||
|
writeEnvFile({ MODRINTH_PROJECTS: next.join(",") });
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── installed jar files ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type InstalledMod = {
|
||||||
|
file: string;
|
||||||
|
name: string;
|
||||||
|
id: string | null;
|
||||||
|
version: string | null;
|
||||||
|
disabled: boolean;
|
||||||
|
size: number;
|
||||||
|
mtime: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function modsDir(): string {
|
||||||
|
return path.join(env.mcDataDir, "mods");
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFabricModJson(jarPath: string): Promise<{ id?: string; name?: string; version?: string } | null> {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
execFile(
|
||||||
|
"unzip",
|
||||||
|
["-p", jarPath, "fabric.mod.json"],
|
||||||
|
{ timeout: 10_000, maxBuffer: 2 * 1024 * 1024 },
|
||||||
|
(err, stdout) => {
|
||||||
|
if (err) return resolve(null);
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(stdout.replace(/^/, "")));
|
||||||
|
} catch {
|
||||||
|
resolve(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function listInstalledMods(): Promise<InstalledMod[]> {
|
||||||
|
let entries: string[];
|
||||||
|
try {
|
||||||
|
entries = fs.readdirSync(modsDir());
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const jars = entries.filter((n) => n.endsWith(".jar") || n.endsWith(".jar.disabled"));
|
||||||
|
const out: InstalledMod[] = [];
|
||||||
|
for (const file of jars) {
|
||||||
|
const full = path.join(modsDir(), file);
|
||||||
|
const st = fs.statSync(full);
|
||||||
|
const meta = await readFabricModJson(full);
|
||||||
|
out.push({
|
||||||
|
file,
|
||||||
|
name: meta?.name ?? file.replace(/\.jar(\.disabled)?$/, ""),
|
||||||
|
id: meta?.id ?? null,
|
||||||
|
version: meta?.version ?? null,
|
||||||
|
disabled: file.endsWith(".disabled"),
|
||||||
|
size: st.size,
|
||||||
|
mtime: st.mtimeMs,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeModFile(file: string): string {
|
||||||
|
if (file !== path.basename(file) || !/\.jar(\.disabled)?$/.test(file)) {
|
||||||
|
throw new Error("Invalid mod file name");
|
||||||
|
}
|
||||||
|
return path.join(modsDir(), file);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function toggleMod(file: string): void {
|
||||||
|
const full = safeModFile(file);
|
||||||
|
const target = full.endsWith(".disabled") ? full.slice(0, -".disabled".length) : `${full}.disabled`;
|
||||||
|
fs.renameSync(full, target);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteModFile(file: string): void {
|
||||||
|
fs.unlinkSync(safeModFile(file));
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { db, getBoolSetting, getSetting } from "./db";
|
||||||
|
import { bus } from "./bus";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
export type NotificationLevel = "info" | "success" | "warn" | "error";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Panel notifications: stored for the notification center, fanned out live
|
||||||
|
* over SSE, and optionally mirrored to a Discord webhook.
|
||||||
|
*/
|
||||||
|
export function notify(
|
||||||
|
level: NotificationLevel,
|
||||||
|
title: string,
|
||||||
|
body = "",
|
||||||
|
opts: { discord?: boolean } = {}
|
||||||
|
) {
|
||||||
|
const ts = Date.now();
|
||||||
|
const res = db()
|
||||||
|
.prepare("INSERT INTO notifications(ts, level, title, body) VALUES(?, ?, ?, ?)")
|
||||||
|
.run(ts, level, title, body);
|
||||||
|
bus().emit("notification", { id: Number(res.lastInsertRowid), ts, level, title, body, read: 0 });
|
||||||
|
|
||||||
|
if (opts.discord !== false) void sendDiscord(level, title, body);
|
||||||
|
}
|
||||||
|
|
||||||
|
const LEVEL_COLORS: Record<NotificationLevel, number> = {
|
||||||
|
info: 0x60a5fa,
|
||||||
|
success: 0x4ade80,
|
||||||
|
warn: 0xfbbf24,
|
||||||
|
error: 0xf87171,
|
||||||
|
};
|
||||||
|
|
||||||
|
async function sendDiscord(level: NotificationLevel, title: string, body: string) {
|
||||||
|
if (!getBoolSetting("discord_enabled", true)) return;
|
||||||
|
const url = getSetting("discord_webhook_url", env.discordWebhookUrl);
|
||||||
|
if (!url) return;
|
||||||
|
try {
|
||||||
|
await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
username: "CraftDeck",
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title,
|
||||||
|
description: body || undefined,
|
||||||
|
color: LEVEL_COLORS[level],
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(5000),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// Discord being down must never break the panel.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { randomBytes, scryptSync, timingSafeEqual } from "node:crypto";
|
||||||
|
|
||||||
|
/** scrypt password hashing — built into Node, no native deps. */
|
||||||
|
|
||||||
|
export function hashPassword(password: string): string {
|
||||||
|
const salt = randomBytes(16);
|
||||||
|
const hash = scryptSync(password, salt, 64);
|
||||||
|
return `scrypt:${salt.toString("hex")}:${hash.toString("hex")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function verifyPassword(password: string, stored: string): boolean {
|
||||||
|
const [scheme, saltHex, hashHex] = stored.split(":");
|
||||||
|
if (scheme !== "scrypt" || !saltHex || !hashHex) return false;
|
||||||
|
const expected = Buffer.from(hashHex, "hex");
|
||||||
|
const actual = scryptSync(password, Buffer.from(saltHex, "hex"), expected.length);
|
||||||
|
return timingSafeEqual(actual, expected);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function generatePassword(length = 12): string {
|
||||||
|
const alphabet = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKMNPQRSTUVWXYZ23456789";
|
||||||
|
const bytes = randomBytes(length);
|
||||||
|
let out = "";
|
||||||
|
for (let i = 0; i < length; i++) out += alphabet[bytes[i] % alphabet.length];
|
||||||
|
return out;
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import path from "node:path";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Player statistics from the world's stats/*.json files (written by the
|
||||||
|
* server) joined with usercache.json for name↔UUID mapping. Works with
|
||||||
|
* offline-mode UUIDs.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type UserCacheEntry = { name: string; uuid: string };
|
||||||
|
|
||||||
|
export type PlayerStats = {
|
||||||
|
name: string;
|
||||||
|
uuid: string;
|
||||||
|
playTimeHours: number;
|
||||||
|
deaths: number;
|
||||||
|
mobKills: number;
|
||||||
|
playerKills: number;
|
||||||
|
distanceKm: number;
|
||||||
|
jumps: number;
|
||||||
|
damageDealt: number;
|
||||||
|
blocksMined: number;
|
||||||
|
lastModified: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function worldDir(): string {
|
||||||
|
return path.join(env.mcDataDir, "world");
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readUserCache(): UserCacheEntry[] {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(path.join(env.mcDataDir, "usercache.json"), "utf8"));
|
||||||
|
if (Array.isArray(raw)) return raw as UserCacheEntry[];
|
||||||
|
} catch {}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function num(obj: Record<string, number> | undefined, key: string): number {
|
||||||
|
return obj?.[key] ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function readAllPlayerStats(): PlayerStats[] {
|
||||||
|
const statsDir = path.join(worldDir(), "stats");
|
||||||
|
let files: string[];
|
||||||
|
try {
|
||||||
|
files = fs.readdirSync(statsDir).filter((f) => f.endsWith(".json"));
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const nameByUuid = new Map(readUserCache().map((e) => [e.uuid.toLowerCase(), e.name]));
|
||||||
|
const out: PlayerStats[] = [];
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const uuid = file.replace(/\.json$/, "");
|
||||||
|
let data: { stats?: Record<string, Record<string, number>> };
|
||||||
|
let mtime = 0;
|
||||||
|
try {
|
||||||
|
const full = path.join(statsDir, file);
|
||||||
|
mtime = fs.statSync(full).mtimeMs;
|
||||||
|
data = JSON.parse(fs.readFileSync(full, "utf8"));
|
||||||
|
} catch {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const s = data.stats ?? {};
|
||||||
|
const custom = s["minecraft:custom"];
|
||||||
|
// distance stats are in centimeters
|
||||||
|
const distanceCm =
|
||||||
|
num(custom, "minecraft:walk_one_cm") +
|
||||||
|
num(custom, "minecraft:sprint_one_cm") +
|
||||||
|
num(custom, "minecraft:swim_one_cm") +
|
||||||
|
num(custom, "minecraft:fly_one_cm") +
|
||||||
|
num(custom, "minecraft:boat_one_cm") +
|
||||||
|
num(custom, "minecraft:horse_one_cm") +
|
||||||
|
num(custom, "minecraft:minecart_one_cm");
|
||||||
|
const blocksMined = Object.values(s["minecraft:mined"] ?? {}).reduce((a, b) => a + b, 0);
|
||||||
|
|
||||||
|
out.push({
|
||||||
|
name: nameByUuid.get(uuid.toLowerCase()) ?? uuid.slice(0, 8),
|
||||||
|
uuid,
|
||||||
|
playTimeHours: Math.round((num(custom, "minecraft:play_time") / 20 / 3600) * 10) / 10,
|
||||||
|
deaths: num(custom, "minecraft:deaths"),
|
||||||
|
mobKills: num(custom, "minecraft:mob_kills"),
|
||||||
|
playerKills: num(custom, "minecraft:player_kills"),
|
||||||
|
distanceKm: Math.round(distanceCm / 100 / 1000),
|
||||||
|
jumps: num(custom, "minecraft:jump"),
|
||||||
|
damageDealt: Math.round(num(custom, "minecraft:damage_dealt") / 10),
|
||||||
|
blocksMined,
|
||||||
|
lastModified: mtime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return out.sort((a, b) => b.playTimeHours - a.playTimeHours);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type WorldInfo = {
|
||||||
|
seed: string | null;
|
||||||
|
sizeBytes: number | null;
|
||||||
|
dimensions: string[];
|
||||||
|
levelName: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function readWorldInfo(worldSizeBytes: number | null): WorldInfo {
|
||||||
|
const dims: string[] = [];
|
||||||
|
const wd = worldDir();
|
||||||
|
if (fs.existsSync(wd)) dims.push("overworld");
|
||||||
|
if (fs.existsSync(path.join(wd, "DIM-1"))) dims.push("nether");
|
||||||
|
if (fs.existsSync(path.join(wd, "DIM1"))) dims.push("end");
|
||||||
|
|
||||||
|
let seed: string | null = null;
|
||||||
|
let levelName: string | null = null;
|
||||||
|
try {
|
||||||
|
const props = fs.readFileSync(path.join(env.mcDataDir, "server.properties"), "utf8");
|
||||||
|
seed = props.match(/^level-seed=(.*)$/m)?.[1] || null;
|
||||||
|
levelName = props.match(/^level-name=(.*)$/m)?.[1] || null;
|
||||||
|
} catch {}
|
||||||
|
return { seed, sizeBytes: worldSizeBytes, dimensions: dims, levelName };
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
import net from "node:net";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal Source-RCON client (the protocol Minecraft uses). One persistent
|
||||||
|
* connection, commands serialized through a queue, automatic reconnect.
|
||||||
|
* Long responses arrive as multiple packets with the same request id; we
|
||||||
|
* collect until a short quiet period.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const TYPE_AUTH = 3;
|
||||||
|
const TYPE_COMMAND = 2;
|
||||||
|
|
||||||
|
export class RconError extends Error {
|
||||||
|
constructor(message: string, public kind: "offline" | "auth" | "timeout" = "offline") {
|
||||||
|
super(message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodePacket(id: number, type: number, body: string): Buffer {
|
||||||
|
const bodyBuf = Buffer.from(body, "utf8");
|
||||||
|
const buf = Buffer.alloc(14 + bodyBuf.length);
|
||||||
|
buf.writeInt32LE(10 + bodyBuf.length, 0);
|
||||||
|
buf.writeInt32LE(id, 4);
|
||||||
|
buf.writeInt32LE(type, 8);
|
||||||
|
bodyBuf.copy(buf, 12);
|
||||||
|
return buf;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Pending = {
|
||||||
|
id: number;
|
||||||
|
chunks: string[];
|
||||||
|
resolve: (out: string) => void;
|
||||||
|
reject: (err: Error) => void;
|
||||||
|
quietTimer?: NodeJS.Timeout;
|
||||||
|
hardTimer: NodeJS.Timeout;
|
||||||
|
};
|
||||||
|
|
||||||
|
class RconClient {
|
||||||
|
private socket: net.Socket | null = null;
|
||||||
|
private buffer = Buffer.alloc(0);
|
||||||
|
private nextId = 1;
|
||||||
|
private authed = false;
|
||||||
|
private authWaiter: { resolve: () => void; reject: (e: Error) => void } | null = null;
|
||||||
|
private pending: Pending | null = null;
|
||||||
|
private queue: Array<() => void> = [];
|
||||||
|
|
||||||
|
private connect(): Promise<void> {
|
||||||
|
if (this.authed && this.socket && !this.socket.destroyed) return Promise.resolve();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.teardown();
|
||||||
|
const socket = net.createConnection({ host: env.rconHost, port: env.rconPort });
|
||||||
|
this.socket = socket;
|
||||||
|
const connectTimeout = setTimeout(() => {
|
||||||
|
socket.destroy();
|
||||||
|
reject(new RconError("RCON connect timeout"));
|
||||||
|
}, 5000);
|
||||||
|
socket.on("connect", () => {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
|
this.authWaiter = { resolve, reject };
|
||||||
|
socket.write(encodePacket(0, TYPE_AUTH, env.rconPassword));
|
||||||
|
});
|
||||||
|
socket.on("data", (chunk: Buffer) => this.onData(chunk));
|
||||||
|
socket.on("error", (err) => {
|
||||||
|
clearTimeout(connectTimeout);
|
||||||
|
const e = new RconError(`RCON unavailable: ${(err as Error).message}`);
|
||||||
|
this.failAll(e);
|
||||||
|
reject(e);
|
||||||
|
});
|
||||||
|
socket.on("close", () => {
|
||||||
|
this.authed = false;
|
||||||
|
this.failAll(new RconError("RCON connection closed"));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
private teardown() {
|
||||||
|
this.socket?.destroy();
|
||||||
|
this.socket = null;
|
||||||
|
this.authed = false;
|
||||||
|
this.buffer = Buffer.alloc(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private failAll(err: Error) {
|
||||||
|
this.authWaiter?.reject(err);
|
||||||
|
this.authWaiter = null;
|
||||||
|
if (this.pending) {
|
||||||
|
clearTimeout(this.pending.hardTimer);
|
||||||
|
if (this.pending.quietTimer) clearTimeout(this.pending.quietTimer);
|
||||||
|
this.pending.reject(err);
|
||||||
|
this.pending = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private onData(chunk: Buffer) {
|
||||||
|
this.buffer = Buffer.concat([this.buffer, chunk]);
|
||||||
|
while (this.buffer.length >= 4) {
|
||||||
|
const len = this.buffer.readInt32LE(0);
|
||||||
|
if (this.buffer.length < 4 + len) break;
|
||||||
|
const id = this.buffer.readInt32LE(4);
|
||||||
|
const body = this.buffer.subarray(12, 4 + len - 2).toString("utf8");
|
||||||
|
this.buffer = this.buffer.subarray(4 + len);
|
||||||
|
|
||||||
|
if (this.authWaiter) {
|
||||||
|
if (id === -1) {
|
||||||
|
this.authWaiter.reject(new RconError("RCON auth failed — wrong password", "auth"));
|
||||||
|
this.authWaiter = null;
|
||||||
|
this.teardown();
|
||||||
|
} else {
|
||||||
|
this.authed = true;
|
||||||
|
this.authWaiter.resolve();
|
||||||
|
this.authWaiter = null;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const p = this.pending;
|
||||||
|
if (p && id === p.id) {
|
||||||
|
p.chunks.push(body);
|
||||||
|
if (p.quietTimer) clearTimeout(p.quietTimer);
|
||||||
|
p.quietTimer = setTimeout(() => this.finish(), 150);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private finish() {
|
||||||
|
const p = this.pending;
|
||||||
|
if (!p) return;
|
||||||
|
clearTimeout(p.hardTimer);
|
||||||
|
this.pending = null;
|
||||||
|
p.resolve(p.chunks.join(""));
|
||||||
|
this.drain();
|
||||||
|
}
|
||||||
|
|
||||||
|
private drain() {
|
||||||
|
const next = this.queue.shift();
|
||||||
|
if (next) next();
|
||||||
|
}
|
||||||
|
|
||||||
|
exec(command: string): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const run = async () => {
|
||||||
|
try {
|
||||||
|
await this.connect();
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
this.drain();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const id = this.nextId++;
|
||||||
|
this.pending = {
|
||||||
|
id,
|
||||||
|
chunks: [],
|
||||||
|
resolve,
|
||||||
|
reject,
|
||||||
|
hardTimer: setTimeout(() => {
|
||||||
|
this.pending = null;
|
||||||
|
reject(new RconError("RCON command timeout", "timeout"));
|
||||||
|
this.drain();
|
||||||
|
}, 10_000),
|
||||||
|
};
|
||||||
|
this.socket!.write(encodePacket(id, TYPE_COMMAND, command));
|
||||||
|
};
|
||||||
|
if (this.pending || this.queue.length > 0) this.queue.push(run);
|
||||||
|
else void run();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const g = globalThis as unknown as { __craftdeckRcon?: RconClient };
|
||||||
|
|
||||||
|
export function rcon(): RconClient {
|
||||||
|
if (!g.__craftdeckRcon) g.__craftdeckRcon = new RconClient();
|
||||||
|
return g.__craftdeckRcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Strip Minecraft § color/formatting codes from RCON output. */
|
||||||
|
export function stripColors(s: string): string {
|
||||||
|
return s.replace(/§[0-9a-fk-or]/gi, "");
|
||||||
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
|
import { execFile } from "node:child_process";
|
||||||
|
import { inspectContainer, containerStats } from "./docker";
|
||||||
|
import { listOnlinePlayers, sparkTps, sparkPings } from "./mc";
|
||||||
|
import { db } from "./db";
|
||||||
|
import { bus } from "./bus";
|
||||||
|
import { notify } from "./notify";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Periodic metrics collection into the panel DB (ring buffer with retention)
|
||||||
|
* plus threshold alerts: server offline/crash, sustained low TPS, low disk.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type SamplerState = {
|
||||||
|
timer: NodeJS.Timeout | null;
|
||||||
|
tick: number;
|
||||||
|
lastRunning: boolean | null;
|
||||||
|
cachedTps: number | null;
|
||||||
|
cachedMspt: number | null;
|
||||||
|
lowTpsStreak: number;
|
||||||
|
lastTpsAlert: number;
|
||||||
|
lastDiskAlert: number;
|
||||||
|
worldSizeBytes: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const g = globalThis as unknown as { __craftdeckSampler?: SamplerState };
|
||||||
|
|
||||||
|
const INTERVAL_MS = 10_000;
|
||||||
|
|
||||||
|
async function sampleTick(state: SamplerState) {
|
||||||
|
state.tick++;
|
||||||
|
let running = false;
|
||||||
|
try {
|
||||||
|
const c = await inspectContainer(env.mcContainer);
|
||||||
|
running = c.running;
|
||||||
|
|
||||||
|
// online/offline transitions → alerts
|
||||||
|
if (state.lastRunning !== null && state.lastRunning !== running) {
|
||||||
|
if (!running) {
|
||||||
|
if ((c.exitCode ?? 0) !== 0) {
|
||||||
|
notify("error", "Server crashed", `Container exited with code ${c.exitCode}`);
|
||||||
|
} else {
|
||||||
|
notify("warn", "Server stopped");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state.lastRunning = running;
|
||||||
|
} catch {
|
||||||
|
// docker unavailable — nothing to sample
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ts = Date.now();
|
||||||
|
let cpu: number | null = null;
|
||||||
|
let memUsed: number | null = null;
|
||||||
|
let memLimit: number | null = null;
|
||||||
|
let netRx: number | null = null;
|
||||||
|
let netTx: number | null = null;
|
||||||
|
let players: number | null = null;
|
||||||
|
|
||||||
|
if (running) {
|
||||||
|
try {
|
||||||
|
const s = await containerStats(env.mcContainer);
|
||||||
|
cpu = Math.round(s.cpuPercent * 10) / 10;
|
||||||
|
memUsed = s.memUsed;
|
||||||
|
memLimit = s.memLimit;
|
||||||
|
netRx = s.netRx;
|
||||||
|
netTx = s.netTx;
|
||||||
|
} catch {}
|
||||||
|
try {
|
||||||
|
players = (await listOnlinePlayers()).online;
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// spark TPS every 3rd tick (30s)
|
||||||
|
if (state.tick % 3 === 0) {
|
||||||
|
const t = await sparkTps();
|
||||||
|
state.cachedTps = t.tps;
|
||||||
|
state.cachedMspt = t.mspt;
|
||||||
|
if (t.tps !== null && t.tps < 15) {
|
||||||
|
state.lowTpsStreak++;
|
||||||
|
if (state.lowTpsStreak >= 3 && ts - state.lastTpsAlert > 15 * 60 * 1000) {
|
||||||
|
state.lastTpsAlert = ts;
|
||||||
|
notify("warn", "Server is lagging", `TPS dropped to ${t.tps.toFixed(1)} (target 20)`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.lowTpsStreak = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// per-player pings every 6th tick (60s)
|
||||||
|
if (state.tick % 6 === 0) {
|
||||||
|
const pings = await sparkPings();
|
||||||
|
const insert = db().prepare("INSERT INTO ping_samples(ts, player, ping) VALUES(?, ?, ?)");
|
||||||
|
for (const [player, ping] of Object.entries(pings)) insert.run(ts, player, ping);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
state.cachedTps = null;
|
||||||
|
state.cachedMspt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// disk stats every tick (cheap statfs)
|
||||||
|
let diskUsed: number | null = null;
|
||||||
|
let diskFree: number | null = null;
|
||||||
|
try {
|
||||||
|
const st = fs.statfsSync(env.mcDataDir);
|
||||||
|
diskFree = st.bavail * st.bsize;
|
||||||
|
diskUsed = (st.blocks - st.bfree) * st.bsize;
|
||||||
|
if (diskFree < 2 * 1024 ** 3 && ts - state.lastDiskAlert > 6 * 60 * 60 * 1000) {
|
||||||
|
state.lastDiskAlert = ts;
|
||||||
|
notify("error", "Low disk space", `Only ${(diskFree / 1024 ** 3).toFixed(1)} GB free`);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// world folder size every 60th tick (10 min) — du can be slow
|
||||||
|
if (state.tick % 60 === 1) {
|
||||||
|
execFile("du", ["-sk", env.mcDataDir], { timeout: 60_000 }, (err, stdout) => {
|
||||||
|
if (!err) {
|
||||||
|
const kb = parseInt(stdout.split("\t")[0], 10);
|
||||||
|
if (!Number.isNaN(kb)) state.worldSizeBytes = kb * 1024;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
db()
|
||||||
|
.prepare(
|
||||||
|
`INSERT OR REPLACE INTO metrics(ts, cpu, mem_used, mem_limit, net_rx, net_tx, tps, mspt, players, disk_used, disk_free)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||||
|
)
|
||||||
|
.run(ts, cpu, memUsed, memLimit, netRx, netTx, state.cachedTps, state.cachedMspt, players, diskUsed, diskFree);
|
||||||
|
|
||||||
|
bus().emit("metrics", {
|
||||||
|
ts,
|
||||||
|
running,
|
||||||
|
cpu,
|
||||||
|
memUsed,
|
||||||
|
memLimit,
|
||||||
|
tps: state.cachedTps,
|
||||||
|
mspt: state.cachedMspt,
|
||||||
|
players,
|
||||||
|
diskFree,
|
||||||
|
});
|
||||||
|
|
||||||
|
// retention pruning hourly
|
||||||
|
if (state.tick % 360 === 2) {
|
||||||
|
const week = ts - 7 * 24 * 60 * 60 * 1000;
|
||||||
|
const month = ts - 30 * 24 * 60 * 60 * 1000;
|
||||||
|
db().prepare("DELETE FROM metrics WHERE ts < ?").run(week);
|
||||||
|
db().prepare("DELETE FROM ping_samples WHERE ts < ?").run(week);
|
||||||
|
db().prepare("DELETE FROM activity WHERE ts < ?").run(month);
|
||||||
|
db().prepare("DELETE FROM notifications WHERE ts < ?").run(month);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startSampler() {
|
||||||
|
if (g.__craftdeckSampler) return;
|
||||||
|
const state: SamplerState = {
|
||||||
|
timer: null,
|
||||||
|
tick: 0,
|
||||||
|
lastRunning: null,
|
||||||
|
cachedTps: null,
|
||||||
|
cachedMspt: null,
|
||||||
|
lowTpsStreak: 0,
|
||||||
|
lastTpsAlert: 0,
|
||||||
|
lastDiskAlert: 0,
|
||||||
|
worldSizeBytes: null,
|
||||||
|
};
|
||||||
|
g.__craftdeckSampler = state;
|
||||||
|
state.timer = setInterval(() => void sampleTick(state).catch(() => {}), INTERVAL_MS);
|
||||||
|
void sampleTick(state).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getWorldSizeBytes(): number | null {
|
||||||
|
return g.__craftdeckSampler?.worldSizeBytes ?? null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import cron, { type ScheduledTask } from "node-cron";
|
||||||
|
import { db } from "./db";
|
||||||
|
import { notify } from "./notify";
|
||||||
|
import { powerContainer, execInContainer } from "./docker";
|
||||||
|
import { rcon } from "./rcon";
|
||||||
|
import { say } from "./mc";
|
||||||
|
import { env } from "./env";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scheduled tasks (cron-based): restarts, backups, announcements, arbitrary
|
||||||
|
* console commands. Definitions live in the panel DB; jobs are (re)loaded
|
||||||
|
* whenever they change.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type Schedule = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
cron: string;
|
||||||
|
action: "restart" | "backup" | "command" | "announce";
|
||||||
|
payload: string | null;
|
||||||
|
enabled: number;
|
||||||
|
last_run_at: number | null;
|
||||||
|
last_result: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const g = globalThis as unknown as { __craftdeckJobs?: Map<number, ScheduledTask> };
|
||||||
|
|
||||||
|
function jobs(): Map<number, ScheduledTask> {
|
||||||
|
if (!g.__craftdeckJobs) g.__craftdeckJobs = new Map();
|
||||||
|
return g.__craftdeckJobs;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function validateCron(expr: string): boolean {
|
||||||
|
return cron.validate(expr);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function runScheduleAction(action: Schedule["action"], payload: string | null): Promise<string> {
|
||||||
|
switch (action) {
|
||||||
|
case "restart": {
|
||||||
|
try {
|
||||||
|
await say("Server restarting in 30 seconds!");
|
||||||
|
await new Promise((r) => setTimeout(r, 30_000));
|
||||||
|
} catch {
|
||||||
|
// server not reachable over RCON — restart anyway
|
||||||
|
}
|
||||||
|
await powerContainer(env.mcContainer, "restart");
|
||||||
|
return "Restarted";
|
||||||
|
}
|
||||||
|
case "backup": {
|
||||||
|
const res = await execInContainer(env.backupContainer, ["backup", "now"]);
|
||||||
|
if (res.exitCode !== 0) throw new Error(res.output.slice(-500) || "backup failed");
|
||||||
|
return "Backup completed";
|
||||||
|
}
|
||||||
|
case "command": {
|
||||||
|
if (!payload) throw new Error("No command configured");
|
||||||
|
return (await rcon().exec(payload)).slice(0, 500);
|
||||||
|
}
|
||||||
|
case "announce": {
|
||||||
|
if (!payload) throw new Error("No message configured");
|
||||||
|
await say(payload);
|
||||||
|
return "Announced";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function execute(schedule: Schedule) {
|
||||||
|
let result: string;
|
||||||
|
try {
|
||||||
|
result = await runScheduleAction(schedule.action, schedule.payload);
|
||||||
|
if (schedule.action !== "announce") {
|
||||||
|
notify("success", `Schedule "${schedule.name}" ran`, result, { discord: false });
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
result = `ERROR: ${(err as Error).message}`;
|
||||||
|
notify("error", `Schedule "${schedule.name}" failed`, (err as Error).message);
|
||||||
|
}
|
||||||
|
db()
|
||||||
|
.prepare("UPDATE schedules SET last_run_at = ?, last_result = ? WHERE id = ?")
|
||||||
|
.run(Date.now(), result.slice(0, 1000), schedule.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reloadSchedules() {
|
||||||
|
for (const task of jobs().values()) task.stop();
|
||||||
|
jobs().clear();
|
||||||
|
const rows = db().prepare("SELECT * FROM schedules WHERE enabled = 1").all() as unknown as Schedule[];
|
||||||
|
for (const row of rows) {
|
||||||
|
if (!cron.validate(row.cron)) continue;
|
||||||
|
jobs().set(
|
||||||
|
row.id,
|
||||||
|
cron.schedule(row.cron, () => void execute(row))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function startScheduler() {
|
||||||
|
reloadSchedules();
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
/**
|
||||||
|
* Stateless signed-cookie sessions. Implemented with the Web Crypto API only,
|
||||||
|
* so the exact same code runs in the Edge middleware and Node route handlers.
|
||||||
|
* Token format: base64url(payload-json) + "." + base64url(hmac-sha256).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const SESSION_COOKIE = "craftdeck_session";
|
||||||
|
export const SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
type SessionPayload = { u: string; iat: number; exp: number };
|
||||||
|
|
||||||
|
const enc = new TextEncoder();
|
||||||
|
|
||||||
|
function b64url(bytes: Uint8Array): string {
|
||||||
|
let bin = "";
|
||||||
|
for (const b of bytes) bin += String.fromCharCode(b);
|
||||||
|
return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function b64urlDecode(s: string): Uint8Array {
|
||||||
|
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - (s.length % 4));
|
||||||
|
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
||||||
|
return Uint8Array.from(bin, (c) => c.charCodeAt(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function hmacKey(secret: string): Promise<CryptoKey> {
|
||||||
|
return crypto.subtle.importKey(
|
||||||
|
"raw",
|
||||||
|
enc.encode(secret),
|
||||||
|
{ name: "HMAC", hash: "SHA-256" },
|
||||||
|
false,
|
||||||
|
["sign", "verify"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createSessionToken(secret: string, username: string): Promise<string> {
|
||||||
|
const payload: SessionPayload = { u: username, iat: Date.now(), exp: Date.now() + SESSION_TTL_MS };
|
||||||
|
const body = b64url(enc.encode(JSON.stringify(payload)));
|
||||||
|
const sig = await crypto.subtle.sign("HMAC", await hmacKey(secret), enc.encode(body));
|
||||||
|
return `${body}.${b64url(new Uint8Array(sig))}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function verifySessionToken(
|
||||||
|
secret: string,
|
||||||
|
token: string | undefined
|
||||||
|
): Promise<SessionPayload | null> {
|
||||||
|
if (!token) return null;
|
||||||
|
const dot = token.lastIndexOf(".");
|
||||||
|
if (dot < 1) return null;
|
||||||
|
const body = token.slice(0, dot);
|
||||||
|
const sig = token.slice(dot + 1);
|
||||||
|
try {
|
||||||
|
const ok = await crypto.subtle.verify(
|
||||||
|
"HMAC",
|
||||||
|
await hmacKey(secret),
|
||||||
|
b64urlDecode(sig) as unknown as ArrayBuffer,
|
||||||
|
enc.encode(body)
|
||||||
|
);
|
||||||
|
if (!ok) return null;
|
||||||
|
const payload = JSON.parse(new TextDecoder().decode(b64urlDecode(body))) as SessionPayload;
|
||||||
|
if (typeof payload.exp !== "number" || payload.exp < Date.now()) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sessionCookieAttributes(): string {
|
||||||
|
// Secure flag intentionally omitted: the panel runs over plain HTTP on a
|
||||||
|
// trusted LAN/VPN (see docs/networking.md). Add TLS + Secure if exposing it.
|
||||||
|
return `Path=/; HttpOnly; SameSite=Lax; Max-Age=${Math.floor(SESSION_TTL_MS / 1000)}`;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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.
|
||||||
Executable
+25
@@ -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."
|
||||||
Executable
+35
@@ -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"
|
||||||
Reference in new issue
Block a user