commit 279d137ce5b0e2b598e3ca3153d27ba7de137a87 Author: Aryan Singh Date: Fri Jul 10 11:07:07 2026 -0500 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 diff --git a/.claude/launch.json b/.claude/launch.json new file mode 100644 index 0000000..db971da --- /dev/null +++ b/.claude/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.0.1", + "configurations": [ + { + "name": "dashboard", + "runtimeExecutable": "sh", + "runtimeArgs": ["-c", "cd dashboard && npm run dev"], + "port": 3000 + } + ] +} diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6ed8b47 --- /dev/null +++ b/.env.example @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a14d5af --- /dev/null +++ b/.gitignore @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..7628b72 --- /dev/null +++ b/README.md @@ -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 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://\:8080**, log in (`admin` / password printed by setup.sh), +change the password, and you're live: + +- Minecraft: `:25565` +- Dashboard: `http://: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 `:25565` **right away** (TLauncher: any username works, + but it must match the approved name exactly) +3. In game they run `/register ` before the countdown ends + (default 2 minutes — configurable in Settings) +4. Done. On every future visit they just `/login ` + +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. diff --git a/dashboard/.dockerignore b/dashboard/.dockerignore new file mode 100644 index 0000000..daf787e --- /dev/null +++ b/dashboard/.dockerignore @@ -0,0 +1,4 @@ +node_modules +.next +Dockerfile +.dockerignore diff --git a/dashboard/Dockerfile b/dashboard/Dockerfile new file mode 100644 index 0000000..94221c5 --- /dev/null +++ b/dashboard/Dockerfile @@ -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"] diff --git a/dashboard/next.config.ts b/dashboard/next.config.ts new file mode 100644 index 0000000..a10bc84 --- /dev/null +++ b/dashboard/next.config.ts @@ -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; diff --git a/dashboard/package-lock.json b/dashboard/package-lock.json new file mode 100644 index 0000000..dc1c6a4 --- /dev/null +++ b/dashboard/package-lock.json @@ -0,0 +1,2038 @@ +{ + "name": "craftdeck", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "craftdeck", + "version": "1.0.0", + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.3", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.3.tgz", + "integrity": "sha512-tlosUqb+3BbxCxZdu4tKeRghPFC+QM7q4X5YhKV2eCmPG+1r2F3f4AaSz5sCrFqUtX4Jh20VFTKecl16MgiV9g==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.4", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.4.tgz", + "integrity": "sha512-Ryk9y9T0FFVF0cUGhAknveAyUOl/A1qReTFi+qPKtOh2Z9F4AUBz3XOrYD4ZEgZirdugVzHvd/2/Wcwy5OliTg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.7.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/lang-json": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz", + "integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/json": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-yaml": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/lang-yaml/-/lang-yaml-6.1.3.tgz", + "integrity": "sha512-AZ8DJBuXGVHybpBQhmZtgew5//4hv3tdkXnr3vDmOUMJRuB6vn/uuwtmTOTlqEaQFg3hQSVeA90NmvIQyUV6FQ==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.2.0", + "@lezer/lr": "^1.0.0", + "@lezer/yaml": "^1.0.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.4", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.4.tgz", + "integrity": "sha512-1q4PaT+o6PbgpkJt4Q8Fv5XJxTy4FUZ4MWETtyiDw3J0Pyr9E2vqcKL+k9wcvjNTIsauxvE7OfmWj3FRPHQ76A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.7", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz", + "integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.1.tgz", + "integrity": "sha512-uMe5UO6PamJtSHrXhhHOzSX3ReWtiJrva6GnPMwSOrZtiExb5X5eExhr2OUZQVvdxPsKpY3Ro2mFbQadpPWmHA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.1.tgz", + "integrity": "sha512-9QzNDgE4EYDnAHfrTlR2lwiPciiOymLtwKK+8yHQzCc7GXhAP9xdEbEJFy2IWB1j9UGUl9BsgMmTo/ImA02T7A==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.43.6", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.6.tgz", + "integrity": "sha512-EVunGSYN1wz1p75WY1s3Xg7t3i8Yol0kGZGizNdX9BUFgMFILYVe8/u6EVpo7Ff5PwbZuILb4QAq7IZoKzIEQA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.7.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.2", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.2.tgz", + "integrity": "sha512-kyOl3X0DuTiT1h2ft8r2fYO8JYtU9a9Xis/zBSiGArNaagCOWx90N1k2wxp18czFDH+OgcWGb5ZP/XMt3dcyPA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/json": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz", + "integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@lezer/yaml": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@lezer/yaml/-/yaml-1.0.4.tgz", + "integrity": "sha512-2lrrHqxalACEbxIbsjhqGpSW8kWpUKuY6RHgnSAFZa6qK62wvnPxA8hGOwOoDbwHcOFs5M4o27mjGu+P7TvBmw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.4.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.3.tgz", + "integrity": "sha512-FY+MKLBoTsLNJF/eLWaOsXGdz6uh3Iu1axjPf6TUq92IYumcTcXWHoS747JARLkcdlJ/Waiaxc5wQfFO8jC6NA==", + "license": "MIT" + }, + "node_modules/@next/env": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.10.tgz", + "integrity": "sha512-zLPxg9M0MEHmygpj5OuxjQ+vHMiy/K7cSp74G8ecYolmgUWw0RwN02tF56npup/+qaI8JB97hQgS/r2Hb6QwVA==", + "license": "MIT" + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.10.tgz", + "integrity": "sha512-v9IdJCa0H0mbo+8z5zwUpOk1Vj7RjkcI5uNYf5Ws1y6szf/p3Mzl9hLaST8SCt6L9h8NGnruZcd2+o0NTNwDhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.10.tgz", + "integrity": "sha512-17IS0jJRViROGmA9uGdNR8VPJpfbnaVG7E9qhso5jDLkmyd0lSDORWxbcKINzcFqzZqGwGtMSnrFRxBpuUYjLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.10.tgz", + "integrity": "sha512-GRQRsRtuciNJvB54AvvuQTiq0oZtFwa1owQqtZD8wwnGpM2L39MV22kpI72YSXLKIyY40LC66EiLFv4PiicXxg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.10.tgz", + "integrity": "sha512-zkN9MQYS7UQBro+FnISUq1itaQjXI9xqISzuQ+2bc921NcJ1x4yPCqrn77tVN6/dOOXaaWVX3k6/bR07pPwK+A==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.10.tgz", + "integrity": "sha512-iCVJnwvrPYECvA6WM/7+oo+OiTvedIKLxtCLAZP4xZR3nXa1zmzZyLPbYCmWvpd4CvMYF1EMTafd0ii3DygLvA==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.10.tgz", + "integrity": "sha512-ov2g4H0dHY9bPoOU83m91hWT7Iq5qy13bUnyyshLU3HGR1Ownn0X9QpmDPc5iIUaahTp7f7LeGAhV4DSFtackw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.10.tgz", + "integrity": "sha512-DwAnhLX76HQiFFQNgWlcK+JzlnD1rZ+UK/WY0ZMI/deXpvgnesjNYrqcfo1JzBuz4Kf7o3brIBL0glI1junatA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.10.tgz", + "integrity": "sha512-0JXq3b85Jk9Jg4ntLUbXSPvoDw3gpZou7twuKdoFG2jOw635v7+IiXfTaa0TxVMyx78pUjnrVYwLgjKfX4e6/A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.2.tgz", + "integrity": "sha512-yWP/sqEcBLaD8JuA6zNwxoYKr75qxTioYwlRwekj5Jr/I5GXnoJfjetH/psLUIv74cYTH2lBUEzBkinthoYcBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.2.tgz", + "integrity": "sha512-z8ZgnzX8gdNoWLBLqBPoh/sjnxkwvf9ZuWjnO0l0yIzbLa5/9S+eC5QxGZKRobVHIC3/1BoMWjHblqWjcgFgag==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-arm64": "4.3.2", + "@tailwindcss/oxide-darwin-x64": "4.3.2", + "@tailwindcss/oxide-freebsd-x64": "4.3.2", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.2", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.2", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.2", + "@tailwindcss/oxide-linux-x64-musl": "4.3.2", + "@tailwindcss/oxide-wasm32-wasi": "4.3.2", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.2", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.2" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.2.tgz", + "integrity": "sha512-WHxqIuHpvZ5VtdX6GTl1Ik/Vp2YuN42Et+0CdeaVd/frQ9jAvGmvR8vLT+jk3e8/Q3x8kECB9+R17pgpp2BulA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.2.tgz", + "integrity": "sha512-GZypeUY/IDJW3877KeM+O67vbXr3MBnbtEL4aYhNErv/JWZhye2vGSWWG9tB6iiqR2MqRNkY8IOUy4NdSZV26w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.2.tgz", + "integrity": "sha512-UIIzmefR6KO1sDU7MzRqAxC8iBpft/VhkGjTjnhoS6k7Z3rQ9wEgA1ODSiyH/tcSYssulNm4Ci3hOeK1jH7ccQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.2.tgz", + "integrity": "sha512-GN+uAmcI6DNspnCDwtOAZrTz6oukJnp337qZvxqCGLd3BHBzJpO0ZbTLRvJNdztOeAmTzewewGIMPb0tk2R4WA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.2.tgz", + "integrity": "sha512-4ABn7qSbdHRwTiDiuWNegCyb5+2FJ4vKIKc3DmKrvAFw7MU1Lm11dIkTPwUaFdTzc7IsOpDbqBrlh0x6y36U/w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.2.tgz", + "integrity": "sha512-wDgEIGwoM8w8pufh9LVt1PahDgNdKXrLC2qfAnV3vAmococ9RWbxeAw4pxPttd/TsJfwjyLf90Dg1y9y8I6Emw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.2.tgz", + "integrity": "sha512-J5Nuk0uZQIiMTJj3LEx4sAA9tMFUoXQZFv1J6An+QGYe53HKRJuFDi0rpq/tuouCZeAbOBY3kQ6g8qeD4TUjtA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.2.tgz", + "integrity": "sha512-kqCZpSKOBEJO4mz7OqWoofBZeXTAwaVGPj0ErAj7CojmhKpWVWVOnrt9dE8odoIraZq4oj3ausM37kXi+Tow8w==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.2.tgz", + "integrity": "sha512-cixpqbh2toJDmkuCRI68nXA8ZxNmdK9Y+9v5h3MC3ZQKy/0BO8AWzlkWyRM7JAFSGBlfig4YVTPsK6MVgqz1uw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.2.tgz", + "integrity": "sha512-4ec2Z/LOmRsAgU23CS4xeJfcJlmRg94A/XrbGRCF1gyU/zdDfRLYDVsS+ynSZCmGNxQ1jQriQOKMQeQxBA3Isw==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.11.1", + "@emnapi/runtime": "^1.11.1", + "@emnapi/wasi-threads": "^1.2.2", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.2.tgz", + "integrity": "sha512-Zyr/M0+XcYZu3bZrUytc7TXvrk0ftWfl8gN2MwekNDzhqhKRUucMPSeOzM0o0wH5AWOU49BsKRrfKxI2atCPMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.2.tgz", + "integrity": "sha512-QI9BO7KlNZsp2GuO0jwAAj5jCDABOKXRkCk2XuKTSaNEFSdfzqswYVTtCHBNKHLsqyjFyFkqlDiwkNbTYSssMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.2.tgz", + "integrity": "sha512-rjVWYCa7Ngbi5AarT6k8TkxUG3Wl1QKzHdIZVsjZSzf36Jmo2IKZt/NHRAwly8oDkbBOH0YTu+CHuf9jPxMc+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.2", + "@tailwindcss/oxide": "4.3.2", + "postcss": "^8.5.15", + "tailwindcss": "4.3.2" + } + }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.11", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.11.tgz", + "integrity": "sha512-otyFa+n9IOYtEjaKOxPedHkj15fTPUF21wdR9pv0GpZPfuGl27cvmcv6+tognbRu9VvEcsHKE+ESoszeo3KfTw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.11", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.11.tgz", + "integrity": "sha512-DYVFAKLX+F/4JS9N/7xexh+TICrlncwkX9HKKInrP1bwO0tSfc3k0GB6oawTYhelVKh20cX3TuRx+NJSkVXuMw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.11", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.42", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.42.tgz", + "integrity": "sha512-c/jurFrDLyui7o1J86yLkRu4LMsTYcBohveus7/I2Hzdn9KIP2bdJPTue/lR1KH46enoPbD77GKeSYNdyPoD3Q==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001803", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", + "integrity": "sha512-g/uHREV2ZpK9qMalCsWaxmA6ol+DX8GYhuf3T40RKoP+oL7vhRJh8LNt73PCjpnR6l14FzfPrB5Yux4PKm2meg==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, + "node_modules/crelt": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz", + "integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==", + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lucide-react": { + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz", + "integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/next": { + "version": "16.2.10", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.10.tgz", + "integrity": "sha512-2som5AVXb3kE6Yjine3/mNbBayYF58eguBWIVVUdr1y/L426xyVEgYxgBG+1QC34P2x5E+tcDup6XkuOAX3dCA==", + "license": "MIT", + "dependencies": { + "@next/env": "16.2.10", + "@swc/helpers": "0.5.15", + "baseline-browser-mapping": "^2.9.19", + "caniuse-lite": "^1.0.30001579", + "postcss": "8.4.31", + "styled-jsx": "5.1.6" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "optionalDependencies": { + "@next/swc-darwin-arm64": "16.2.10", + "@next/swc-darwin-x64": "16.2.10", + "@next/swc-linux-arm64-gnu": "16.2.10", + "@next/swc-linux-arm64-musl": "16.2.10", + "@next/swc-linux-x64-gnu": "16.2.10", + "@next/swc-linux-x64-musl": "16.2.10", + "@next/swc-win32-arm64-msvc": "16.2.10", + "@next/swc-win32-x64-msvc": "16.2.10", + "sharp": "^0.34.5" + }, + "peerDependencies": { + "@opentelemetry/api": "^1.1.0", + "@playwright/test": "^1.51.1", + "babel-plugin-react-compiler": "*", + "react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", + "sass": "^1.3.0" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-cron": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-4.6.0.tgz", + "integrity": "sha512-Si/bzYiKRHOB8/a99T2+SDGN582ONDMSTlJr5oCkT6GtnqPjZ2s10eoQRYkW9ZHwjVxONL+W8Fb+qR0AHMQsdg==", + "license": "ISC", + "engines": { + "node": ">=20" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.16", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", + "integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz", + "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-darwin-arm64": "0.34.5", + "@img/sharp-darwin-x64": "0.34.5", + "@img/sharp-libvips-darwin-arm64": "1.2.4", + "@img/sharp-libvips-darwin-x64": "1.2.4", + "@img/sharp-libvips-linux-arm": "1.2.4", + "@img/sharp-libvips-linux-arm64": "1.2.4", + "@img/sharp-libvips-linux-ppc64": "1.2.4", + "@img/sharp-libvips-linux-riscv64": "1.2.4", + "@img/sharp-libvips-linux-s390x": "1.2.4", + "@img/sharp-libvips-linux-x64": "1.2.4", + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4", + "@img/sharp-libvips-linuxmusl-x64": "1.2.4", + "@img/sharp-linux-arm": "0.34.5", + "@img/sharp-linux-arm64": "0.34.5", + "@img/sharp-linux-ppc64": "0.34.5", + "@img/sharp-linux-riscv64": "0.34.5", + "@img/sharp-linux-s390x": "0.34.5", + "@img/sharp-linux-x64": "0.34.5", + "@img/sharp-linuxmusl-arm64": "0.34.5", + "@img/sharp-linuxmusl-x64": "0.34.5", + "@img/sharp-wasm32": "0.34.5", + "@img/sharp-win32-arm64": "0.34.5", + "@img/sharp-win32-ia32": "0.34.5", + "@img/sharp-win32-x64": "0.34.5" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/tailwindcss": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.2.tgz", + "integrity": "sha512-WtctNNSH8A9jlMIqxzuYumOHU5uGZyRv0Q5svQl+oEPy5w84YpBxdb7MdqyiSPQge5jTJ6zFQLq0PFygdccSBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + } + } +} diff --git a/dashboard/package.json b/dashboard/package.json new file mode 100644 index 0000000..e2e52db --- /dev/null +++ b/dashboard/package.json @@ -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" + } +} diff --git a/dashboard/postcss.config.mjs b/dashboard/postcss.config.mjs new file mode 100644 index 0000000..c2ddf74 --- /dev/null +++ b/dashboard/postcss.config.mjs @@ -0,0 +1,5 @@ +export default { + plugins: { + "@tailwindcss/postcss": {}, + }, +}; diff --git a/dashboard/public/.gitkeep b/dashboard/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/dashboard/src/app/(panel)/access/page.tsx b/dashboard/src/app/(panel)/access/page.tsx new file mode 100644 index 0000000..704805d --- /dev/null +++ b/dashboard/src/app/(panel)/access/page.tsx @@ -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 = { + 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("/api/approved", 10000); + const [newName, setNewName] = useState(""); + const [busy, setBusy] = useState(null); + const [tempPassword, setTempPassword] = useState<{ user: string; password: string } | null>(null); + const [confirmRevoke, setConfirmRevoke] = useState(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, 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 ( +
+
+

Access & Auth

+

+ Only approved usernames can join. New players must /register within the window, then{" "} + /login on every visit. +

+
+ + {/* approve box */} + +
+
+ + setNewName(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && approve()} + placeholder="e.g. Steve_123" + className="w-full font-mono" + maxLength={16} + /> +
+ +
+

+ Approving whitelists the name and starts the registration countdown. Tell your friend to join{" "} + right away and run /register <password> <password>. If the timer + expires first, the name is automatically removed from the whitelist. +

+
+ + {error && ( +
{error}
+ )} + + {/* player table */} + + 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 whitelist + + } + pad={false} + > + {players.length === 0 ? ( + } + title="Nobody approved yet" + hint="Approve your friends' usernames above. Nobody else can join the server." + /> + ) : ( + + + + + + + + + + + + {players.map((p) => { + const badge = STATUS_BADGE[p.status]; + return ( + + + + + + + + ); + })} + +
PlayerStatusLast seenRegisteredActions
{p.username} + {badge.label} + {p.status === "pending" && p.window_expires_at && ( + + {fmtCountdown(p.window_expires_at)} + + )} + {p.note && ({p.note})} + {timeAgo(p.last_seen_at)}{fmtDateTime(p.registered_at)} +
+ {(p.status === "expired" || p.status === "revoked") && ( + + )} + {p.status === "registered" && ( + <> + + + )} + {p.status !== "revoked" && ( + + )} +
+
+ )} +
+ + {/* whitelist drift warning */} + {outsideWhitelist.length > 0 && ( + +
+ {outsideWhitelist.map((w) => ( + + {w} + + ))} + — use “Import whitelist” to manage them from the panel. +
+
+ )} + + {/* temp password modal */} + setTempPassword(null)} + title={`Temporary password for ${tempPassword?.user}`} + > +

+ Share this privately. They log in with it and can change it in game via{" "} + /account. +

+
+ {tempPassword?.password} + +
+

+ This password is shown once and never stored by the panel. +

+
+ + {/* revoke confirm modal */} + setConfirmRevoke(null)} title={`Revoke ${confirmRevoke}?`}> +

+ This removes them from the whitelist, deletes their password account, and kicks them if online. You + can re-approve them later. +

+
+ + +
+
+
+ ); +} diff --git a/dashboard/src/app/(panel)/backups/page.tsx b/dashboard/src/app/(panel)/backups/page.tsx new file mode 100644 index 0000000..961852f --- /dev/null +++ b/dashboard/src/app/(panel)/backups/page.tsx @@ -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("/api/backups", 30000); + const [creating, setCreating] = useState(false); + const [deleting, setDeleting] = useState(null); + const [restoreTarget, setRestoreTarget] = useState(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 ( +
+
+
+

Backups

+

+ Automatic every {data?.interval ?? "24h"}, kept {data?.retentionDays ?? "7"} days ·{" "} + {fmtBytes(data?.totalSize)} total — change in{" "} + + Configuration + +

+
+ +
+ + + {!data || data.backups.length === 0 ? ( + } + title="No backups yet" + hint='The backup sidecar creates the first one automatically shortly after the server starts, or press "Back up now".' + /> + ) : ( + + + + + + + + + + + {data.backups.map((b) => ( + + + + + + + ))} + +
BackupCreatedSizeActions
{b.name} + {timeAgo(b.mtime)} + {fmtBytes(b.size)} +
+ + + + + +
+
+ )} +
+ + { + setRestoreTarget(null); + setRestoreConfirm(""); + }} + title="Restore backup" + > +
+

+ Restoring {restoreTarget} will: +

+
    +
  1. Stop the server (players are disconnected)
  2. +
  3. + Overwrite the current world and settings with the backup —{" "} + everything since the backup is lost +
  4. +
  5. Start the server again
  6. +
+

+ Type restore to confirm: +

+ setRestoreConfirm(e.target.value)} + placeholder="restore" + className="w-full font-mono" + /> +
+
+ + +
+
+
+ ); +} diff --git a/dashboard/src/app/(panel)/config/page.tsx b/dashboard/src/app/(panel)/config/page.tsx new file mode 100644 index 0000000..ce9d7ed --- /dev/null +++ b/dashboard/src/app/(panel)/config/page.tsx @@ -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; + +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("/api/config", 0); + const [values, setValues] = useState({}); + 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 ( +
+
+
+

Configuration

+

+ Values live in .env. Save writes the file; Apply recreates the server so they take + effect. Raw files (server.properties, EasyAuth) are editable in{" "} + + Files + + . +

+
+
+ + +
+
+ + {versionChanged && ( +
+ + 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. +
+ )} + +
+ {SECTIONS.map((section) => ( + +
+ {FIELDS.filter((f) => f.section === section.id).map((f) => ( +
+ + set(f.key, e.target.value)} + className="w-full font-mono" + placeholder={f.placeholder} + /> + {f.hint &&

{f.hint}

} +
+ ))} +
+
+ ))} +
+ + setApplyOpen(false)} title="Apply configuration?"> +
+

+ The server (and backup sidecar) containers are recreated with the saved .env. Players + online are disconnected while the server restarts. +

+ +
+
+ + +
+
+
+ ); +} diff --git a/dashboard/src/app/(panel)/console/page.tsx b/dashboard/src/app/(panel)/console/page.tsx new file mode 100644 index 0000000..f0bbe89 --- /dev/null +++ b/dashboard/src/app/(panel)/console/page.tsx @@ -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([]); + const [command, setCommand] = useState(""); + const [history, setHistory] = useState([]); + const [histIdx, setHistIdx] = useState(-1); + const [filter, setFilter] = useState(""); + const [follow, setFollow] = useState(true); + const [busy, setBusy] = useState(false); + const paneRef = useRef(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 ( +
+
+
+

Console

+

Live server log with RCON command input

+
+
+ setFilter(e.target.value)} + className="w-48" + /> + + {!follow && ( + + )} +
+
+ +
+ {visible.length === 0 && ( +
+ Waiting for log output… (is the server running?) +
+ )} + {visible.map((line, i) => ( +
+ {line} +
+ ))} +
+ +
+
+
+ + > + + setCommand(e.target.value)} + onKeyDown={onKeyDown} + placeholder="Type a server command… (↑ for history)" + className="w-full pl-7 font-mono" + spellCheck={false} + /> +
+ +
+
+ {QUICK_COMMANDS.map((c) => ( + + ))} +
+
+
+ ); +} diff --git a/dashboard/src/app/(panel)/files/page.tsx b/dashboard/src/app/(panel)/files/page.tsx new file mode 100644 index 0000000..83aca63 --- /dev/null +++ b/dashboard/src/app/(panel)/files/page.tsx @@ -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(null); + const [error, setError] = useState(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(null); + const uploadRef = useRef(null); + + const load = useCallback(async (p: string) => { + setError(null); + try { + setEntries(await api(`/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 ( +
+
+
+

Files

+

Server data directory — world, configs, mods, logs

+
+
+ { + const f = e.target.files?.[0]; + if (f) void upload(f); + e.target.value = ""; + }} + /> + + +
+
+ + {/* breadcrumbs */} +
+ + {crumbs.map((c, i) => ( + + + + + ))} +
+ + {error &&
{error}
} + + + {entries === null ? ( + + ) : entries.length === 0 && !error ? ( + } title="Empty folder" /> + ) : ( + + + {entries.map((e) => ( + + + + + + + ))} + +
void open(e)}> + + {e.type === "dir" ? ( + + ) : ( + + )} + {e.name} + + {e.type === "file" ? fmtBytes(e.size) : "—"}{fmtDateTime(e.mtime)} +
+ {e.type === "file" && ( + + + + )} + +
+
+ )} +
+ + {/* editor modal */} + {editing && ( +
+
+
+
+ {editing.path} + {editing.dirty && ●} + {editing.truncated && (truncated — file over 2 MB)} +
+
+ + +
+
+
+ setEditing((e) => (e ? { ...e, content: v, dirty: true } : e))} + extensions={langFor(editing.path)} + theme={oneDark} + height="100%" + style={{ fontSize: 12, height: "100%" }} + /> +
+
+
+ )} + + {/* new folder modal */} + setMkdirOpen(false)} title="New folder"> + 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); + } + }} + /> +
+ + +
+
+ + {/* delete confirm */} + setConfirmDelete(null)} title={`Delete ${confirmDelete?.name}?`}> +

+ {confirmDelete?.type === "dir" + ? "Only empty folders can be deleted from the panel." + : "This permanently deletes the file from the server data directory."} +

+
+ + +
+
+
+ ); +} diff --git a/dashboard/src/app/(panel)/layout.tsx b/dashboard/src/app/(panel)/layout.tsx new file mode 100644 index 0000000..4ce0025 --- /dev/null +++ b/dashboard/src/app/(panel)/layout.tsx @@ -0,0 +1,5 @@ +import { Shell } from "@/components/shell"; + +export default function PanelLayout({ children }: { children: React.ReactNode }) { + return {children}; +} diff --git a/dashboard/src/app/(panel)/mods/page.tsx b/dashboard/src/app/(panel)/mods/page.tsx new file mode 100644 index 0000000..63eda78 --- /dev/null +++ b/dashboard/src/app/(panel)/mods/page.tsx @@ -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("/api/mods", 0); + const [query, setQuery] = useState(""); + const [hits, setHits] = useState(null); + const [searching, setSearching] = useState(false); + const [busy, setBusy] = useState(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(`/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, 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 ( +
+
+
+

Mods

+

+ Managed mods auto-update to the newest build for Minecraft {data?.mcVersion ?? "…"} on every apply +

+
+ +
+ +
+ {/* Modrinth search */} + +
+ setQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && void search()} + placeholder={`Search Fabric mods for ${data?.mcVersion ?? "26.2"}…`} + className="w-full" + /> + +
+
+ {hits === null && ( +

+ Search Modrinth — results are pre-filtered to Fabric + your server version. +

+ )} + {hits?.length === 0 &&

No compatible mods found.

} + {hits?.map((h) => { + const already = managedSet.has(h.slug.toLowerCase()); + return ( +
+ {h.icon_url ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ +
+ )} +
+
+ {h.title} + {h.client_side === "required" && ( + needs client install + )} +
+
{h.description}
+
+ +
+ ); + })} +
+
+ + {/* managed list */} + +

+ These Modrinth projects are pinned in .env (MODRINTH_PROJECTS). The server resolves the + newest compatible build on every start, so updating Minecraft also updates these automatically. +

+
    + {(data?.managed ?? []).map((slug) => ( +
  • + {slug} + {PROTECTED.has(slug.toLowerCase()) && ( + + required + + )} + + {!PROTECTED.has(slug.toLowerCase()) && ( + + )} + +
  • + ))} +
+
+
+ + {/* installed jars */} + + {!data || data.installed.length === 0 ? ( + } + 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)." + /> + ) : ( + + + + + + + + + + + + {data.installed.map((m) => ( + + + + + + + + ))} + +
ModVersionFileSizeActions
+ {m.name} {m.disabled && disabled} + {m.version ?? "—"}{m.file}{fmtBytes(m.size)} +
+ + +
+
+ )} +
+ + setConfirmApply(false)} title="Apply mod changes?"> +

+ 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}. +

+
+ + +
+
+
+ ); +} diff --git a/dashboard/src/app/(panel)/page.tsx b/dashboard/src/app/(panel)/page.tsx new file mode 100644 index 0000000..d5e4894 --- /dev/null +++ b/dashboard/src/app/(panel)/page.tsx @@ -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 | 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 = { + 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("/api/server", 10000); + const { data: metricRows, refresh: refreshMetrics } = useApi("/api/metrics?range=1h", 30000); + const { data: activity, refresh: refreshActivity } = useApi("/api/activity?limit=14", 20000); + const { data: approvedData, refresh: refreshApproved } = useApi<{ approved: ApprovedRow[] }>( + "/api/approved", + 15000 + ); + const [busyAction, setBusyAction] = useState(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 ( +
+ {/* header row */} +
+
+

Overview

+

+ {server?.serverType ?? "Fabric"} · Minecraft {server?.version ?? "…"} + {running && server?.uptimeMs != null && <> · up {fmtDuration(server.uptimeMs)}} +

+
+
+ + + +
+
+ + {!server?.dockerAvailable && server && ( +
+ Docker is unreachable from the panel. If you're running the dashboard outside the compose stack, + server control and metrics are unavailable. +
+ )} + + {/* stat tiles */} +
+ = 18 ? "good" : Number(m.tps) >= 15 ? "warn" : "crit") : undefined} + > + + + + + + + + + + + +
+ +
+ {/* activity feed */} + + {!activity || activity.length === 0 ? ( + + ) : ( +
    + {activity.map((a) => { + const meta = ACTIVITY_LABELS[a.type] ?? { label: a.type, color: "muted" as const }; + return ( +
  • + {meta.label} + + {a.player && {a.player} } + {a.detail} + + {timeAgo(a.ts)} +
  • + ); + })} +
+ )} +
+ +
+ {/* pending registrations */} + {pending.length > 0 && ( + +
    + {pending.map((p) => ( +
  • + pending + {p.username} + + {p.window_expires_at ? fmtCountdown(p.window_expires_at) : ""} + +
  • + ))} +
+

+ They must join and /register <password> <password> before the timer runs + out. Manage in{" "} + + Access & Auth + + . +

+
+ )} + + {/* world + disk */} + +
+
+ + World + + {server?.world.levelName ?? "world"} · {fmtBytes(server?.world.sizeBytes)} + +
+
+ + Dimensions + + {server?.world.dimensions.length ? server.world.dimensions.join(" · ") : "not generated yet"} + +
+ {server?.world.seed && ( +
+ Seed + {server.world.seed} +
+ )} +
+ {m?.disk_used != null && m?.disk_free != null ? ( + + ) : ( +
+ disk stats unavailable +
+ )} +
+
+
+
+
+
+ ); +} diff --git a/dashboard/src/app/(panel)/performance/page.tsx b/dashboard/src/app/(panel)/performance/page.tsx new file mode 100644 index 0000000..8b47c65 --- /dev/null +++ b/dashboard/src/app/(panel)/performance/page.tsx @@ -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 = { + "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(`/api/metrics?range=${range}`, 30000); + const { data: lag, refresh: refreshLag } = useApi("/api/lag", 30000); + const [report, setReport] = useState(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 ( +
+
+
+

Performance & Lag

+

Server tick health, resource usage, and who's actually lagging

+
+ +
+ + {/* lag verdict */} + + Full health report + + } + > +
+ {verdict.title} +

{verdict.hint}

+
+ {[ + ["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]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+ + {/* per-player connections */} +
+
Player connections (last hour)
+ {!lag || lag.players.length === 0 ? ( +
Nobody online right now.
+ ) : ( +
+ {lag.players.map((p) => ( +
+
+ + {p.name} + + {p.ping !== null ? `${p.ping} ms` : "—"} + +
+
+ ({ 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} + /> +
+
+ {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."} +
+
+ ))} +
+ )} +
+
+ + {/* charts — one measure per chart (never dual-axis) */} + {!rows || rows.length < 2 ? ( + + + + ) : ( +
+ + + + + + + + + + + fmtBytes(v)} + /> + + + `${fmtBytes(v)}/s`} + /> + + + String(Math.round(v))} + /> + +
+ )} + + {/* spark health report modal */} + setReport(null)} title="spark health report" wide> +
+          {report}
+        
+
+
+ ); +} diff --git a/dashboard/src/app/(panel)/players/page.tsx b/dashboard/src/app/(panel)/players/page.tsx new file mode 100644 index 0000000..695837a --- /dev/null +++ b/dashboard/src/app/(panel)/players/page.tsx @@ -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("/api/players", 15000); + const { data: joins, refresh: refreshJoins } = useApi("/api/activity?limit=30", 20000); + const [detail, setDetail] = useState(null); + const [kicking, setKicking] = useState(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 ( + + {ping} ms + + ); + }; + + return ( +
+
+

Players

+

+ {online.length} online{data?.max ? ` of ${data.max} max` : ""} · {players.length} have played +

+
+ +
+
+ + {players.length === 0 ? ( + } + title="No player data yet" + hint="Stats appear after someone plays on the server." + /> + ) : ( + + + + + + + + + + + {players.map((p) => ( + setDetail(p)} + > + + + + + + + ))} + +
PlayerPlaytimeDeathsLast seen +
+
+ + {p.name} + {p.isOnline && pingBadge(p.ping)} +
+
{p.playTimeHours} h{p.deaths} + {p.isOnline ? online now : timeAgo(p.lastSeenAt)} + + {p.isOnline && ( + + )} +
+ )} +
+
+ + {/* join/leave feed */} + + {joinEvents.length === 0 ? ( + + ) : ( +
    + {joinEvents.map((e) => ( +
  • + + {e.type === "connect" ? "connecting" : e.type} + + {e.player} + {e.type === "connect" && e.detail && ( + {e.detail} + )} + {timeAgo(e.ts)} +
  • + ))} +
+ )} +
+
+ + {/* stats drawer */} + setDetail(null)} title={detail?.name ?? ""} wide> + {detail && ( +
+
+ {detail.isOnline ? online : offline} + {detail.authStatus && {detail.authStatus}} + {detail.isOnline && detail.ping !== null && pingBadge(detail.ping)} +
+
+ {[ + ["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]) => ( +
+
{label}
+
{value}
+
+ ))} +
+
+
UUID: {detail.uuid}
+
Last seen: {detail.isOnline ? "online now" : fmtDateTime(detail.lastSeenAt)}
+
+
+ )} +
+
+ ); +} diff --git a/dashboard/src/app/(panel)/schedules/page.tsx b/dashboard/src/app/(panel)/schedules/page.tsx new file mode 100644 index 0000000..1a2f670 --- /dev/null +++ b/dashboard/src/app/(panel)/schedules/page.tsx @@ -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("/api/schedules", 15000); + const [draft, setDraft] = useState(null); + const [busy, setBusy] = useState(null); + + const run = async (label: string, body: Record, 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 ( +
+
+
+

Schedules

+

Automatic restarts, backups, announcements and commands (cron)

+
+ +
+ + + {!schedules || schedules.length === 0 ? ( + } + title="No schedules yet" + hint="A nightly restart (e.g. daily at 5:00) keeps modded servers fresh, and extra backups never hurt." + /> + ) : ( + + + + + + + + + + + + + {schedules.map((s) => ( + + + + + + + + + ))} + +
NameWhen (cron)ActionLast runEnabledActions
{s.name}{s.cron} + {s.action} + {s.payload && ( + + {s.payload.length > 40 ? s.payload.slice(0, 40) + "…" : s.payload} + + )} + + {fmtDateTime(s.last_run_at)} + {s.last_result?.startsWith("ERROR") && ( + + failed + + )} + + void run(`toggle-${s.id}`, { op: "toggle", id: s.id })} + /> + +
+ + + +
+
+ )} +
+ + setDraft(null)} title={draft?.id === undefined ? "New schedule" : "Edit schedule"}> + {draft && ( +
+
+ + setDraft({ ...draft, name: e.target.value })} + placeholder="e.g. Nightly restart" + className="w-full" + /> +
+
+ + setDraft({ ...draft, payload: e.target.value })} + placeholder={draft.action === "announce" ? "Backup starting soon!" : "save-all"} + className="w-full font-mono" + /> +
+ )} +
+ + setDraft({ ...draft, cron: e.target.value })} + className="w-full font-mono" + /> +
+ {PRESETS.map((p) => ( + + ))} +
+

minute hour day-of-month month day-of-week (server timezone)

+
+
+ + +
+
+ )} +
+
+ ); +} diff --git a/dashboard/src/app/(panel)/settings/page.tsx b/dashboard/src/app/(panel)/settings/page.tsx new file mode 100644 index 0000000..0c77431 --- /dev/null +++ b/dashboard/src/app/(panel)/settings/page.tsx @@ -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("/api/settings", 0); + const { data: me, refresh: refreshMe } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me"); + const { data: world } = useApi("/api/world", 0); + + const [values, setValues] = useState(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 ( +
+
+

Settings

+

Panel account, authentication window, notifications, danger zone

+
+ + {me?.mustChange && ( +
+ You're still on the initial admin password — change it below. +
+ )} + +
+ {/* password */} + +
+
+ + setCurrentPw(e.target.value)} className="w-full" /> +
+
+
+ + setNewPw(e.target.value)} className="w-full" /> +
+
+ + setNewPw2(e.target.value)} className="w-full" /> +
+
+ +

At least 8 characters.

+
+
+ + {/* auth window */} + +
+
+ + + setValues((v) => (v ? { ...v, registrationWindowMinutes: Number(e.target.value) } : v)) + } + className="w-32 font-mono" + /> +

+ After you approve a username, the player has this long to join and /register. When + it lapses the name is automatically removed from the whitelist again. +

+
+ setValues((s) => (s ? { ...s, notifyJoins: v } : s))} + label="Notify on player joins/leaves" + /> +
+
+ + {/* discord */} + +
+
+ + setValues((v) => (v ? { ...v, discordWebhookUrl: e.target.value } : v))} + placeholder="https://discord.com/api/webhooks/…" + className="w-full font-mono text-xs" + /> +

+ Discord → Server settings → Integrations → Webhooks → New webhook → Copy URL +

+
+ setValues((s) => (s ? { ...s, discordEnabled: v } : s))} + label="Send alerts to Discord" + /> + setValues((s) => (s ? { ...s, discordJoins: v } : s))} + label="Include joins/leaves" + /> + +
+
+ + {/* danger zone */} + +
+
+ +
+
Reset world
+
+ Current world: {world?.levelName ?? "world"} ({fmtBytes(world?.sizeBytes)}) — archived, then a + fresh world generates. A safety backup is taken first. +
+
+ +
+
+
+
+ +
+ +
+ + setResetOpen(false)} title="Reset the world?"> +
+

+ 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. +

+

+ Type reset the world to confirm: +

+ setResetConfirm(e.target.value)} + placeholder="reset the world" + className="w-full font-mono" + /> +
+
+ + +
+
+
+ ); +} diff --git a/dashboard/src/app/api/activity/route.ts b/dashboard/src/app/api/activity/route.ts new file mode 100644 index 0000000..9ae7d25 --- /dev/null +++ b/dashboard/src/app/api/activity/route.ts @@ -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); + }); +} diff --git a/dashboard/src/app/api/approved/route.ts b/dashboard/src/app/api/approved/route.ts new file mode 100644 index 0000000..1aab3b1 --- /dev/null +++ b/dashboard/src/app/api/approved/route.ts @@ -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(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"); + } + }); +} diff --git a/dashboard/src/app/api/auth/login/route.ts b/dashboard/src/app/api/auth/login/route.ts new file mode 100644 index 0000000..0063af5 --- /dev/null +++ b/dashboard/src/app/api/auth/login/route.ts @@ -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(); + +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; + }); +} diff --git a/dashboard/src/app/api/auth/logout/route.ts b/dashboard/src/app/api/auth/logout/route.ts new file mode 100644 index 0000000..f6be222 --- /dev/null +++ b/dashboard/src/app/api/auth/logout/route.ts @@ -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; +} diff --git a/dashboard/src/app/api/auth/me/route.ts b/dashboard/src/app/api/auth/me/route.ts new file mode 100644 index 0000000..9691b61 --- /dev/null +++ b/dashboard/src/app/api/auth/me/route.ts @@ -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 }); + }); +} diff --git a/dashboard/src/app/api/auth/password/route.ts b/dashboard/src/app/api/auth/password/route.ts new file mode 100644 index 0000000..05a20b2 --- /dev/null +++ b/dashboard/src/app/api/auth/password/route.ts @@ -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 }); + }); +} diff --git a/dashboard/src/app/api/backups/file/route.ts b/dashboard/src/app/api/backups/file/route.ts new file mode 100644 index 0000000..d4a76c0 --- /dev/null +++ b/dashboard/src/app/api/backups/file/route.ts @@ -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 }); + }); +} diff --git a/dashboard/src/app/api/backups/restore/route.ts b/dashboard/src/app/api/backups/restore/route.ts new file mode 100644 index 0000000..0b7f47d --- /dev/null +++ b/dashboard/src/app/api/backups/restore/route.ts @@ -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 }); + }); +} diff --git a/dashboard/src/app/api/backups/route.ts b/dashboard/src/app/api/backups/route.ts new file mode 100644 index 0000000..19f6a88 --- /dev/null +++ b/dashboard/src/app/api/backups/route.ts @@ -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 }); + }); +} diff --git a/dashboard/src/app/api/config/apply/route.ts b/dashboard/src/app/api/config/apply/route.ts new file mode 100644 index 0000000..653f16c --- /dev/null +++ b/dashboard/src/app/api/config/apply/route.ts @@ -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) }); + }); +} diff --git a/dashboard/src/app/api/config/route.ts b/dashboard/src/app/api/config/route.ts new file mode 100644 index 0000000..1a7444e --- /dev/null +++ b/dashboard/src/app/api/config/route.ts @@ -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 = {}; + 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 }>(req); + if (!updates || typeof updates !== "object") return fail("Missing updates"); + const allowed = new Set(EDITABLE_ENV_KEYS); + const filtered: Record = {}; + 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 }); + }); +} diff --git a/dashboard/src/app/api/console/route.ts b/dashboard/src/app/api/console/route.ts new file mode 100644 index 0000000..1e39259 --- /dev/null +++ b/dashboard/src/app/api/console/route.ts @@ -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 }); + }); +} diff --git a/dashboard/src/app/api/files/download/route.ts b/dashboard/src/app/api/files/download/route.ts new file mode 100644 index 0000000..9eec058 --- /dev/null +++ b/dashboard/src/app/api/files/download/route.ts @@ -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); + } +} diff --git a/dashboard/src/app/api/files/route.ts b/dashboard/src/app/api/files/route.ts new file mode 100644 index 0000000..7684642 --- /dev/null +++ b/dashboard/src/app/api/files/route.ts @@ -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(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"); + } + }); +} diff --git a/dashboard/src/app/api/files/upload/route.ts b/dashboard/src/app/api/files/upload/route.ts new file mode 100644 index 0000000..dfb4982 --- /dev/null +++ b/dashboard/src/app/api/files/upload/route.ts @@ -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 }); + }); +} diff --git a/dashboard/src/app/api/health/route.ts b/dashboard/src/app/api/health/route.ts new file mode 100644 index 0000000..11f2901 --- /dev/null +++ b/dashboard/src/app/api/health/route.ts @@ -0,0 +1,5 @@ +import { NextResponse } from "next/server"; + +export function GET() { + return NextResponse.json({ ok: true }); +} diff --git a/dashboard/src/app/api/lag/healthreport/route.ts b/dashboard/src/app/api/lag/healthreport/route.ts new file mode 100644 index 0000000..85407a9 --- /dev/null +++ b/dashboard/src/app/api/lag/healthreport/route.ts @@ -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 }); + }); +} diff --git a/dashboard/src/app/api/lag/route.ts b/dashboard/src/app/api/lag/route.ts new file mode 100644 index 0000000..3b9cf98 --- /dev/null +++ b/dashboard/src/app/api/lag/route.ts @@ -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 = {}; + 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> = {}; + 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 }); + }); +} diff --git a/dashboard/src/app/api/metrics/route.ts b/dashboard/src/app/api/metrics/route.ts new file mode 100644 index 0000000..961b9ec --- /dev/null +++ b/dashboard/src/app/api/metrics/route.ts @@ -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 = { + "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); + }); +} diff --git a/dashboard/src/app/api/mods/apply/route.ts b/dashboard/src/app/api/mods/apply/route.ts new file mode 100644 index 0000000..a418acf --- /dev/null +++ b/dashboard/src/app/api/mods/apply/route.ts @@ -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) }); + }); +} diff --git a/dashboard/src/app/api/mods/manage/route.ts b/dashboard/src/app/api/mods/manage/route.ts new file mode 100644 index 0000000..f7fac2a --- /dev/null +++ b/dashboard/src/app/api/mods/manage/route.ts @@ -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(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"); + } + }); +} diff --git a/dashboard/src/app/api/mods/route.ts b/dashboard/src/app/api/mods/route.ts new file mode 100644 index 0000000..61c2bcf --- /dev/null +++ b/dashboard/src/app/api/mods/route.ts @@ -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(), + }); + }); +} diff --git a/dashboard/src/app/api/mods/search/route.ts b/dashboard/src/app/api/mods/search/route.ts new file mode 100644 index 0000000..f730879 --- /dev/null +++ b/dashboard/src/app/api/mods/search/route.ts @@ -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)); + }); +} diff --git a/dashboard/src/app/api/notifications/route.ts b/dashboard/src/app/api/notifications/route.ts new file mode 100644 index 0000000..b1efeaf --- /dev/null +++ b/dashboard/src/app/api/notifications/route.ts @@ -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(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"); + } + }); +} diff --git a/dashboard/src/app/api/players/route.ts b/dashboard/src/app/api/players/route.ts new file mode 100644 index 0000000..9604bb6 --- /dev/null +++ b/dashboard/src/app/api/players/route.ts @@ -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, + })), + }); + }); +} diff --git a/dashboard/src/app/api/schedules/route.ts b/dashboard/src/app/api/schedules/route.ts new file mode 100644 index 0000000..4442cdb --- /dev/null +++ b/dashboard/src/app/api/schedules/route.ts @@ -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(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"); + } + }); +} diff --git a/dashboard/src/app/api/server/power/route.ts b/dashboard/src/app/api/server/power/route.ts new file mode 100644 index 0000000..10958ba --- /dev/null +++ b/dashboard/src/app/api/server/power/route.ts @@ -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 }); + }); +} diff --git a/dashboard/src/app/api/server/route.ts b/dashboard/src/app/api/server/route.ts new file mode 100644 index 0000000..9f432e1 --- /dev/null +++ b/dashboard/src/app/api/server/route.ts @@ -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 | 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, + }); + }); +} diff --git a/dashboard/src/app/api/settings/route.ts b/dashboard/src/app/api/settings/route.ts new file mode 100644 index 0000000..6e1d75d --- /dev/null +++ b/dashboard/src/app/api/settings/route.ts @@ -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>(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 }); + }); +} diff --git a/dashboard/src/app/api/settings/test-discord/route.ts b/dashboard/src/app/api/settings/test-discord/route.ts new file mode 100644 index 0000000..915903a --- /dev/null +++ b/dashboard/src/app/api/settings/test-discord/route.ts @@ -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 }); + }); +} diff --git a/dashboard/src/app/api/stream/events/route.ts b/dashboard/src/app/api/stream/events/route.ts new file mode 100644 index 0000000..0ae4334 --- /dev/null +++ b/dashboard/src/app/api/stream/events/route.ts @@ -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", + }, + }); +} diff --git a/dashboard/src/app/api/stream/logs/route.ts b/dashboard/src/app/api/stream/logs/route.ts new file mode 100644 index 0000000..38b29ea --- /dev/null +++ b/dashboard/src/app/api/stream/logs/route.ts @@ -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", + }, + }); +} diff --git a/dashboard/src/app/api/world/route.ts b/dashboard/src/app/api/world/route.ts new file mode 100644 index 0000000..29da043 --- /dev/null +++ b/dashboard/src/app/api/world/route.ts @@ -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 }); + }); +} diff --git a/dashboard/src/app/globals.css b/dashboard/src/app/globals.css new file mode 100644 index 0000000..fe9442e --- /dev/null +++ b/dashboard/src/app/globals.css @@ -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; +} diff --git a/dashboard/src/app/icon.svg b/dashboard/src/app/icon.svg new file mode 100644 index 0000000..874ddbe --- /dev/null +++ b/dashboard/src/app/icon.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/dashboard/src/app/layout.tsx b/dashboard/src/app/layout.tsx new file mode 100644 index 0000000..1aa5ca7 --- /dev/null +++ b/dashboard/src/app/layout.tsx @@ -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 ( + + {children} + + ); +} diff --git a/dashboard/src/app/login/page.tsx b/dashboard/src/app/login/page.tsx new file mode 100644 index 0000000..2f4178a --- /dev/null +++ b/dashboard/src/app/login/page.tsx @@ -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(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 ( +
+
+
+ + + + + + + +

CraftDeck

+

Sign in to manage your Minecraft server

+
+
+
+ + setUsername(e.target.value)} + autoComplete="username" + className="w-full" + /> +
+
+ + setPassword(e.target.value)} + autoComplete="current-password" + autoFocus + className="w-full" + /> +
+ {error &&
{error}
} + +
+

+ First run? The initial password is in your .env (PANEL_ADMIN_PASSWORD) +
+ or printed in the dashboard container logs. +

+
+
+ ); +} diff --git a/dashboard/src/components/charts.tsx b/dashboard/src/components/charts.tsx new file mode 100644 index 0000000..7310ed9 --- /dev/null +++ b/dashboard/src/components/charts.tsx @@ -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
collecting data…
; + } + 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 ( + + + + + ); +} + +/* ── 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(null); + const [hover, setHover] = useState(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 ( +
+ Not enough data yet — the sampler records a point every 10 seconds. +
+ ); + } + + 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 ( +
setHover(null)}> + + {/* recessive grid */} + {gridYs.map((v) => ( + + + + {formatValue(v)} + + + ))} + {timeTicks.map((t) => ( + + {fmtTime(t)} + + ))} + + {/* 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 ( + + + {series.length > 1 && ( + + {s.name} + + )} + + ); + })} + + {/* hover crosshair + markers */} + {hover !== null && hoverData && ( + + + {hoverData.map((h) => ( + + ))} + + )} + + + {/* tooltip */} + {hover !== null && hoverData && hoverData.length > 0 && ( +
+
{fmtTime(hoverData[0].point!.ts)}
+ {hoverData.map((h) => ( +
+ + {h.name} + {formatValue(h.point!.v as number)} +
+ ))} +
+ )} + + {/* legend (≥2 series) */} + {series.length > 1 && ( +
+ {series.map((s) => ( + + + {s.name} + + ))} +
+ )} +
+ ); +} + +/* ── 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 ( +
+ {label && ( +
+ {label} + + {format(used)} / {format(total)} + +
+ )} +
+
+
+
+ ); +} diff --git a/dashboard/src/components/shell.tsx b/dashboard/src/components/shell.tsx new file mode 100644 index 0000000..6b24467 --- /dev/null +++ b/dashboard/src/components/shell.tsx @@ -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("/api/server", 15000); + const { data: me } = useApi<{ username: string; mustChange: boolean }>("/api/auth/me"); + const [running, setRunning] = useState(null); + const [players, setPlayers] = useState(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 ( +
+ {/* sidebar */} + + + {/* main */} +
+
+
+ + + {isRunning ? "Online" : "Offline"} + + {isRunning && ( + + {playerCount} player{playerCount === 1 ? "" : "s"} online + + )} +
+
+ + +
+
+ + {me?.mustChange && pathname !== "/settings" && ( +
+ You are using the initial admin password —{" "} + + change it now + + . +
+ )} + +
{children}
+
+ + {/* refresh server summary when the tab regains focus */} + +
+ ); +} + +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([]); + const [unread, setUnread] = useState(0); + const panelRef = useRef(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 ( +
+ + {open && ( +
+
+ Notifications + +
+
+ {items.length === 0 &&
Nothing yet
} + {items.map((n) => ( +
+
+ {n.level} + {n.title} + {timeAgo(n.ts)} +
+ {n.body &&
{n.body}
} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/dashboard/src/components/ui.tsx b/dashboard/src/components/ui.tsx new file mode 100644 index 0000000..a4798b4 --- /dev/null +++ b/dashboard/src/components/ui.tsx @@ -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 ( +
+ {(title || actions) && ( +
+

{title}

+
{actions}
+
+ )} +
{children}
+
+ ); +} + +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 ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} + {children &&
{children}
} +
+ ); +} + +/* ── controls ──────────────────────────────────────────────────────── */ + +type ButtonProps = ButtonHTMLAttributes & { + 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 ( + + ); +} + +export function Input(props: InputHTMLAttributes) { + const { className = "", ...rest } = props; + return ( + + ); +} + +export function Select({ + value, + onChange, + options, + className = "", +}: { + value: string; + onChange: (v: string) => void; + options: Array<{ value: string; label: string }>; + className?: string; +}) { + return ( + + ); +} + +export function Toggle({ + checked, + onChange, + label, +}: { + checked: boolean; + onChange: (v: boolean) => void; + label?: string; +}) { + return ( + + ); +} + +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 ( + + {children} + + ); +} + +export function EmptyState({ icon, title, hint }: { icon?: ReactNode; title: string; hint?: string }) { + return ( +
+ {icon &&
{icon}
} +
{title}
+ {hint &&
{hint}
} +
+ ); +} + +/* ── 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 ( +
+
e.stopPropagation()} + > +
+

{title}

+ +
+
{children}
+
+
+ ); +} + +/* ── tabs ──────────────────────────────────────────────────────────── */ + +export function Tabs({ + tabs, + active, + onChange, +}: { + tabs: Array<{ id: string; label: string }>; + active: string; + onChange: (id: string) => void; +}) { + return ( +
+ {tabs.map((t) => ( + + ))} +
+ ); +} + +/* ── toaster ───────────────────────────────────────────────────────── */ + +type ToastItem = { id: number; message: string; level: ToastLevel }; + +export function Toaster() { + const [items, setItems] = useState([]); + 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 = { + info: "border-info/40", + success: "border-good/40", + warn: "border-warn/40", + error: "border-crit/40", + }; + + return ( +
+ {items.map((t) => ( +
+ {t.message} +
+ ))} +
+ ); +} + +export { toast }; diff --git a/dashboard/src/instrumentation.ts b/dashboard/src/instrumentation.ts new file mode 100644 index 0000000..fc845ae --- /dev/null +++ b/dashboard/src/instrumentation.ts @@ -0,0 +1,6 @@ +export async function register() { + if (process.env.NEXT_RUNTIME === "nodejs") { + const { boot } = await import("./server/boot"); + boot(); + } +} diff --git a/dashboard/src/lib/api.ts b/dashboard/src/lib/api.ts new file mode 100644 index 0000000..a059dd1 --- /dev/null +++ b/dashboard/src/lib/api.ts @@ -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( + path: string, + opts: { method?: string; body?: unknown } = {} +): Promise { + 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(path: string, intervalMs = 0) { + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const alive = useRef(true); + + const refresh = useCallback(async () => { + try { + const d = await api(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 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 } })); +} diff --git a/dashboard/src/lib/format.ts b/dashboard/src/lib/format.ts new file mode 100644 index 0000000..4df431b --- /dev/null +++ b/dashboard/src/lib/format.ts @@ -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")}`; +} diff --git a/dashboard/src/proxy.ts b/dashboard/src/proxy.ts new file mode 100644 index 0000000..43f670f --- /dev/null +++ b/dashboard/src/proxy.ts @@ -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).*)"], +}; diff --git a/dashboard/src/server/api.ts b/dashboard/src/server/api.ts new file mode 100644 index 0000000..649470a --- /dev/null +++ b/dashboard/src/server/api.ts @@ -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) { + 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(req: Request): Promise { + try { + return (await req.json()) as T; + } catch { + throw new Error("Invalid JSON body"); + } +} diff --git a/dashboard/src/server/authflow.ts b/dashboard/src/server/authflow.ts new file mode 100644 index 0000000..4990fb4 --- /dev/null +++ b/dashboard/src/server/authflow.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 | 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(() => {}); + }); +} diff --git a/dashboard/src/server/backups.ts b/dashboard/src/server/backups.ts new file mode 100644 index 0000000..8569021 --- /dev/null +++ b/dashboard/src/server/backups.ts @@ -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 { + 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 { + 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((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 { + try { + await triggerBackup(); + } catch (err) { + db(); + notify("warn", "Safety backup failed", `Proceeding with ${reason} anyway: ${(err as Error).message}`); + } +} diff --git a/dashboard/src/server/boot.ts b/dashboard/src/server/boot.ts new file mode 100644 index 0000000..5ce4430 --- /dev/null +++ b/dashboard/src/server/boot.ts @@ -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)"); + } +} diff --git a/dashboard/src/server/bus.ts b/dashboard/src/server/bus.ts new file mode 100644 index 0000000..1a63733 --- /dev/null +++ b/dashboard/src/server/bus.ts @@ -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) fresh sampler tick + * "notification" (n: Record) 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); +} diff --git a/dashboard/src/server/compose.ts b/dashboard/src/server/compose.ts new file mode 100644 index 0000000..27b020d --- /dev/null +++ b/dashboard/src/server/compose.ts @@ -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"]); +} diff --git a/dashboard/src/server/db.ts b/dashboard/src/server/db.ts new file mode 100644 index 0000000..f58e40e --- /dev/null +++ b/dashboard/src/server/db.ts @@ -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); +} diff --git a/dashboard/src/server/docker.ts b/dashboard/src/server/docker.ts new file mode 100644 index 0000000..a83477f --- /dev/null +++ b/dashboard/src/server/docker.ts @@ -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(path: string, opts: ReqOptions = {}): Promise { + 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 { + try { + const data = await json(`/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> { + const data = await json(`/containers/${name}/json`); + const out: Record = {}; + 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 { + 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; +}; + +export async function containerStats(name: string): Promise { + // 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(`/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((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 }; +} diff --git a/dashboard/src/server/env.ts b/dashboard/src/server/env.ts new file mode 100644 index 0000000..e7558f1 --- /dev/null +++ b/dashboard/src/server/env.ts @@ -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", ""), +}; diff --git a/dashboard/src/server/envfile.ts b/dashboard/src/server/envfile.ts new file mode 100644 index 0000000..a172165 --- /dev/null +++ b/dashboard/src/server/envfile.ts @@ -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 { + const out: Record = {}; + 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): 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; diff --git a/dashboard/src/server/files.ts b/dashboard/src/server/files.ts new file mode 100644 index 0000000..18ee0f3 --- /dev/null +++ b/dashboard/src/server/files.ts @@ -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. + } +} diff --git a/dashboard/src/server/logwatch.ts b/dashboard/src/server/logwatch.ts new file mode 100644 index 0000000..979387c --- /dev/null +++ b/dashboard/src/server/logwatch.ts @@ -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); +} diff --git a/dashboard/src/server/mc.ts b/dashboard/src/server/mc.ts new file mode 100644 index 0000000..2abac72 --- /dev/null +++ b/dashboard/src/server/mc.ts @@ -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 { + assertValidUsername(name); + return stripColors(await rcon().exec(`kick ${name} ${reason}`)); +} + +export async function say(message: string): Promise { + await rcon().exec(`say ${message.replace(/\n/g, " ")}`); +} + +// ── whitelist ─────────────────────────────────────────────────────────── + +export async function whitelistAdd(name: string): Promise { + assertValidUsername(name); + return stripColors(await rcon().exec(`whitelist add ${name}`)); +} + +export async function whitelistRemove(name: string): Promise { + assertValidUsername(name); + return stripColors(await rcon().exec(`whitelist remove ${name}`)); +} + +export async function whitelistList(): Promise { + 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 { + 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 { + assertValidUsername(name); + return stripColors(await rcon().exec(`auth remove ${name}`)); +} + +export async function easyauthSetPassword(name: string, password: string): Promise { + 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 { + 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> { + try { + const out = stripColors(await rcon().exec("spark ping")); + // Lines like "Alice: 23ms" / "Average Ping: 25.3ms" + const result: Record = {}; + 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 { + return stripColors(await rcon().exec("spark healthreport")); +} diff --git a/dashboard/src/server/modrinth.ts b/dashboard/src/server/modrinth.ts new file mode 100644 index 0000000..154a545 --- /dev/null +++ b/dashboard/src/server/modrinth.ts @@ -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 { + 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 { + 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)); +} diff --git a/dashboard/src/server/notify.ts b/dashboard/src/server/notify.ts new file mode 100644 index 0000000..9edecb8 --- /dev/null +++ b/dashboard/src/server/notify.ts @@ -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 = { + 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. + } +} diff --git a/dashboard/src/server/passwords.ts b/dashboard/src/server/passwords.ts new file mode 100644 index 0000000..9188150 --- /dev/null +++ b/dashboard/src/server/passwords.ts @@ -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; +} diff --git a/dashboard/src/server/playerstats.ts b/dashboard/src/server/playerstats.ts new file mode 100644 index 0000000..ab24006 --- /dev/null +++ b/dashboard/src/server/playerstats.ts @@ -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 | 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> }; + 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 }; +} diff --git a/dashboard/src/server/rcon.ts b/dashboard/src/server/rcon.ts new file mode 100644 index 0000000..7d77e1b --- /dev/null +++ b/dashboard/src/server/rcon.ts @@ -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 { + 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 { + 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, ""); +} diff --git a/dashboard/src/server/sampler.ts b/dashboard/src/server/sampler.ts new file mode 100644 index 0000000..fa8f49e --- /dev/null +++ b/dashboard/src/server/sampler.ts @@ -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; +} diff --git a/dashboard/src/server/scheduler.ts b/dashboard/src/server/scheduler.ts new file mode 100644 index 0000000..a0cc28f --- /dev/null +++ b/dashboard/src/server/scheduler.ts @@ -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 }; + +function jobs(): Map { + 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 { + 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(); +} diff --git a/dashboard/src/server/session.ts b/dashboard/src/server/session.ts new file mode 100644 index 0000000..3228ede --- /dev/null +++ b/dashboard/src/server/session.ts @@ -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 { + 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 { + 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 { + 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)}`; +} diff --git a/dashboard/tsconfig.json b/dashboard/tsconfig.json new file mode 100644 index 0000000..9702d5f --- /dev/null +++ b/dashboard/tsconfig.json @@ -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" + ] +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..389924d --- /dev/null +++ b/docker-compose.yml @@ -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 diff --git a/docs/auth-flow.md b/docs/auth-flow.md new file mode 100644 index 0000000..32eb7e7 --- /dev/null +++ b/docs/auth-flow.md @@ -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 ` | Create your password (first join only) | +| `/login ` | 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 diff --git a/docs/backups-and-restore.md b/docs/backups-and-restore.md new file mode 100644 index 0000000..50cb433 --- /dev/null +++ b/docs/backups-and-restore.md @@ -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-.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 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 .tgz data/backups/ +docker compose up -d # let it initialize once +./scripts/restore.sh data/backups/.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. diff --git a/docs/mods-and-modpacks.md b/docs/mods-and-modpacks.md new file mode 100644 index 0000000..fc44145 --- /dev/null +++ b/docs/mods-and-modpacks.md @@ -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. diff --git a/docs/networking.md b/docs/networking.md new file mode 100644 index 0000000..6266a1e --- /dev/null +++ b/docs/networking.md @@ -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` → `: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://: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. diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md new file mode 100644 index 0000000..b5fc0ec --- /dev/null +++ b/docs/troubleshooting.md @@ -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 +``` diff --git a/docs/updating.md b/docs/updating.md new file mode 100644 index 0000000..0cbd043 --- /dev/null +++ b/docs/updating.md @@ -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. diff --git a/scripts/restore.sh b/scripts/restore.sh new file mode 100755 index 0000000..7dcb7a6 --- /dev/null +++ b/scripts/restore.sh @@ -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 " + 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." diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 0000000..c7ea1bd --- /dev/null +++ b/scripts/setup.sh @@ -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://:8080 Minecraft: :25565"