Initial commit: Brass & Sigil monorepo

Self-hosted Minecraft modpack distribution + administration system.

- launcher/  Avalonia 12 desktop client; single-file win-x64 publish.
             Microsoft auth via XboxAuthNet, manifest+SHA-1 mod sync,
             portable install path, sidecar settings.
- server/    brass-sigil-server daemon (.NET 8, linux-x64). Wraps the
             MC subprocess, embedded Kestrel admin panel with cookie
             auth + rate limiting, RCON bridge, scheduled backups,
             BlueMap CLI integration with player markers + skin proxy,
             friend-side whitelist request flow, world wipe with seed
             selection (keep current / random / custom).
- pack/      pack.lock.json (Modrinth + manual CurseForge entries),
             data-only tweak source under tweaks/, build outputs in
             overrides/ (gitignored).
- scripts/   Build-Pack / Build-Tweaks / Update-Pack / Check-Updates
             plus Deploy-Brass.ps1 unified one-shot deploy with
             version-bump pre-flight and daemon-state detection.
This commit is contained in:
Matt Sijbers
2026-05-05 00:19:05 +01:00
commit a1331212cb
99 changed files with 12640 additions and 0 deletions
+42
View File
@@ -0,0 +1,42 @@
"use strict";
import { tickStatus, tickPlayers, tickWhitelist, refreshWhitelistSoon } from "./modules/panels.js";
import { setupConsole } from "./modules/console.js";
import { setupAutocomplete } from "./modules/autocomplete.js";
import { setupWhitelistActions } from "./modules/whitelist.js";
import { setupServerControls } from "./modules/serverControls.js";
import { setupPregen } from "./modules/pregen.js";
import { setupAuth } from "./modules/auth.js";
import { setupUpdate } from "./modules/update.js";
import { setupDanger } from "./modules/danger.js";
import { setupBackup } from "./modules/backup.js";
import { setupModalTriggers } from "./modules/modal.js";
import { setupSettings } from "./modules/settings.js";
import { setupMap } from "./modules/map.js";
setupModalTriggers();
setupAuth();
setupConsole();
setupAutocomplete();
setupWhitelistActions(refreshWhitelistSoon);
setupServerControls();
setupPregen();
setupUpdate();
setupBackup();
setupDanger();
setupSettings();
setupMap();
// First paint
tickStatus();
tickPlayers();
tickWhitelist();
// Polling cadence:
// status 3 s -- pid/uptime/pack version (cheap, doesn't change much)
// players 10 s -- RCON `list` call; players join/leave infrequently
// whitelist 30 s -- file read; mostly relies on refresh-on-action via add/remove
// (Logs are NOT polled -- they stream live via Server-Sent Events from /api/logs/stream.)
setInterval(tickStatus, 3000);
setInterval(tickPlayers, 10000);
setInterval(tickWhitelist, 30000);
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

+379
View File
@@ -0,0 +1,379 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Brass &amp; Sigil -- Server Panel</title>
<link rel="icon" type="image/png" href="/favicon.png" />
<link rel="stylesheet" href="/styles.css" />
</head>
<body>
<div class="topbar">
<div class="topbar-left">
<img class="topbar-icon" src="/favicon.png" alt="" />
<h1>BRASS &amp; SIGIL -- SERVER</h1>
</div>
<div id="statusPill" class="status-pill"><span class="dot"></span><span id="statusText">Connecting…</span></div>
</div>
<div class="layout">
<aside>
<div class="card">
<h2>Status</h2>
<div class="stat-row"><span class="key">PID</span><span id="pid" class="val">--</span></div>
<div class="stat-row"><span class="key">Uptime</span><span id="uptime" class="val">--</span></div>
<div class="stat-row"><span class="key">Pack</span><span id="packVersion" class="val">--</span></div>
<div class="stat-row"><span class="key">Players</span><span id="playerCount" class="val">--</span></div>
<div class="stat-row"><span class="key">World</span><span id="worldSize" class="val">--</span></div>
<div class="actions" style="margin-top: 14px;">
<button id="btnStart" class="ghost-btn">Start</button>
<button id="btnStop" class="danger">Stop</button>
</div>
</div>
<div class="card">
<h2>Resources</h2>
<div class="res-block">
<div class="res-label"><span>Memory</span><span id="memUsage" class="res-val">--</span></div>
<div class="res-bar"><div id="memBar"></div></div>
</div>
<div class="res-block">
<div class="res-label"><span>CPU</span><span id="cpuCurrent" class="res-val">--</span></div>
<div class="res-bar"><div id="cpuBar"></div></div>
<div class="res-sub">
<span>Peak (60s) <strong id="cpuMax">--</strong></span>
<span>Avg (60s) <strong id="cpuAvg">--</strong></span>
</div>
</div>
</div>
<div class="card">
<h2>Players online</h2>
<ul id="players" class="name-list">
<li class="empty-state">No-one online</li>
</ul>
</div>
<div class="card">
<h2>Whitelist <span id="wlReqBadge" class="badge" hidden></span></h2>
<div id="wlRequestsBlock" hidden>
<div class="wl-req-label">Pending requests</div>
<ul id="wlRequests" class="name-list"></ul>
</div>
<ul id="whitelist" class="name-list">
<li class="empty-state">No players whitelisted yet</li>
</ul>
<div class="input-row" style="margin-top: 8px;">
<div class="input-wrap">
<input id="wlInput" type="text" placeholder="Add player by username…" autocomplete="off" maxlength="16" />
</div>
<button id="wlAdd">Add</button>
</div>
</div>
</aside>
<main>
<div class="card">
<h2>Console</h2>
<div id="console" class="console-pane">Connecting to server log…</div>
<div class="input-row">
<div class="input-wrap">
<div id="cmdGhost" class="ghost"></div>
<input id="cmdInput" type="text"
placeholder="Type a server command (e.g. say hello, op alice, whitelist add bob)…"
autocomplete="off" />
<div id="cmdSuggest" class="suggest-list"></div>
</div>
<button id="cmdSend">Send</button>
</div>
<div id="cmdHint" class="hint">
<kbd>Tab</kbd> autocomplete · <kbd></kbd>/<kbd></kbd> history · <kbd>Esc</kbd> dismiss
</div>
</div>
</main>
<aside class="aside-right">
<div class="card">
<h2>Account</h2>
<div class="actions">
<button id="acctChangePw" class="ghost-btn">Change password</button>
<button id="acctLogout" class="ghost-btn">Log out</button>
</div>
<div id="acctChangeForm" class="acct-form" hidden>
<div class="input-wrap" style="margin-top: 10px;">
<input id="acctCurrent" type="password" placeholder="Current password" autocomplete="current-password" />
</div>
<div class="input-wrap" style="margin-top: 8px;">
<input id="acctNew" type="password" placeholder="New password (min 8)" autocomplete="new-password" />
</div>
<div class="input-wrap" style="margin-top: 8px;">
<input id="acctConfirm" type="password" placeholder="Confirm new password" autocomplete="new-password" />
</div>
<div class="actions" style="margin-top: 10px;">
<button id="acctSubmit">Update</button>
<button id="acctCancel" class="ghost-btn">Cancel</button>
</div>
<div id="acctMsg" class="acct-msg"></div>
</div>
</div>
<div class="card" id="updateCard" hidden>
<h2>Modpack update</h2>
<div id="updateInfo" class="update-info">
<div class="stat-row"><span class="key">Current</span><span id="updCurrent" class="val">--</span></div>
<div class="stat-row"><span class="key">Available</span><span id="updAvailable" class="val">--</span></div>
</div>
<p class="update-note">Updating restarts Minecraft. Players see a countdown banner then the server stops, syncs new mods, and starts again.</p>
<div class="input-row" style="margin-top: 8px;">
<div class="input-wrap">
<input id="updDelay" type="number" min="0" max="3600" step="30" value="300" placeholder="Warning seconds" />
</div>
<button id="updStart">Update</button>
</div>
<div id="updProgress" class="update-progress" hidden>
<div id="updPhaseLabel" class="update-phase">Idle</div>
<div class="pg-progress-bar"><div id="updProgressFill"></div></div>
<div id="updStatusText" class="update-status">--</div>
<button id="updCancel" class="ghost-btn" hidden>Cancel countdown</button>
</div>
</div>
<div class="card">
<h2>Server settings</h2>
<div class="stat-row"><span class="key">MOTD</span><span id="ssMotd" class="val">--</span></div>
<div class="stat-row"><span class="key">Difficulty</span><span id="ssDifficulty" class="val">--</span></div>
<div class="stat-row"><span class="key">View / Sim</span><span id="ssDistances" class="val">--</span></div>
<div class="stat-row"><span class="key">Max players</span><span id="ssMaxPlayers" class="val">--</span></div>
<div class="stat-row"><span class="key">Whitelist</span><span id="ssWhitelist" class="val">--</span></div>
<button class="ghost-btn" style="width: 100%; margin-top: 10px;" data-open-modal="modalSettings">Edit settings</button>
</div>
<div class="card">
<h2>World</h2>
<div class="trigger-list">
<button class="ghost-btn" data-open-modal="modalPregen">
<span>Pre-generate</span>
<span id="pgBadge" class="badge" hidden></span>
</button>
<button class="ghost-btn" data-open-modal="modalBackup">
<span>Backups</span>
<span id="bkpBadge" class="badge"></span>
</button>
<button class="ghost-btn" data-open-modal="modalMap">
<span>Map</span>
<span id="mapBadge" class="badge" hidden></span>
</button>
<button class="ghost-btn" data-open-modal="modalWipe">
<span>Wipe world</span>
<span class="badge" style="color: var(--danger); border-color: #6a2814;">danger</span>
</button>
</div>
</div>
</aside>
</div>
<!-- ── Modals ─────────────────────────────────────────────────────── -->
<div class="modal" id="modalPregen" hidden>
<div class="modal-backdrop"></div>
<div class="modal-dialog">
<div class="modal-header">
<h2>Pre-generate world</h2>
<button class="modal-close" aria-label="Close">×</button>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin: 0 0 12px; line-height: 1.45;">
Smooths Distant Horizons by generating chunks ahead of time.
Run once after first start; takes a while (be patient -- server keeps running).
</p>
<div class="input-row" style="margin-top: 4px;">
<div class="input-wrap">
<input id="pgRadius" type="number" min="100" max="20000" step="100" value="1000" placeholder="Radius (blocks)" />
</div>
<button id="pgStart">Start</button>
</div>
<div class="actions" style="margin-top: 8px;">
<button id="pgPause" class="ghost-btn">Pause</button>
<button id="pgContinue" class="ghost-btn">Resume</button>
<button id="pgCancel" class="danger">Cancel</button>
</div>
<div class="pg-status">
<div class="stat-row"><span class="key">State</span><span id="pgState" class="val">Idle</span></div>
<div class="pg-progress-bar"><div id="pgProgressFill"></div></div>
<div class="stat-row"><span class="key">Progress</span><span id="pgProgressText" class="val">--</span></div>
<div class="stat-row"><span class="key">Chunks</span><span id="pgChunks" class="val">--</span></div>
<div class="stat-row"><span class="key">Rate</span><span id="pgRate" class="val">--</span></div>
<div class="stat-row"><span class="key">ETA</span><span id="pgEta" class="val">--</span></div>
</div>
</div>
</div>
<div class="modal" id="modalBackup" hidden>
<div class="modal-backdrop"></div>
<div class="modal-dialog">
<div class="modal-header">
<h2>Backups</h2>
<button class="modal-close" aria-label="Close">×</button>
</div>
<div class="stat-row" style="font-size: 11px;">
<span class="key">Stored at</span><span id="backupDir" class="val" style="font-size: 11px;">--</span>
</div>
<div class="stat-row" style="font-size: 11px;">
<span class="key">Schedule</span><span id="backupSchedule" class="val" style="font-size: 11px;">--</span>
</div>
<div class="stat-row" style="font-size: 11px;">
<span class="key">Next run</span><span id="backupNext" class="val" style="font-size: 11px;">--</span>
</div>
<div class="stat-row" style="font-size: 11px;">
<span class="key">Keep</span><span id="backupKeep" class="val" style="font-size: 11px;">--</span>
</div>
<div class="actions" style="margin-top: 12px;">
<button id="bkpEditSchedule" class="ghost-btn" style="flex: 1;">Edit schedule</button>
<button id="bkpCreate">Create now</button>
</div>
<div id="bkpScheduleForm" class="acct-form" hidden style="margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--card-edge);">
<div class="input-row">
<div class="input-wrap" style="flex: 1;">
<input id="bkpScheduleInput" type="text" placeholder="04:00 | 04:00,16:00 | every 6h | every 30m" />
</div>
<div class="input-wrap" style="width: 80px; flex: 0 0 80px;">
<input id="bkpKeepInput" type="number" min="1" max="365" placeholder="Keep" />
</div>
</div>
<p style="font-size: 11px; color: var(--text-muted); margin: 8px 0 0; line-height: 1.45;">
Hourly = ~24 backups/day. Each backup pauses world saves for a few seconds.
For hourly retention, raise <em>keep</em> to 48+. Empty schedule disables auto-backups.
</p>
<div class="actions" style="margin-top: 8px;">
<button id="bkpScheduleSave">Save</button>
<button id="bkpScheduleCancel" class="ghost-btn">Cancel</button>
</div>
</div>
<ul id="bkpList" class="name-list" style="margin-top: 12px;">
<li class="empty-state">No backups yet</li>
</ul>
<div id="bkpMsg" class="acct-msg"></div>
</div>
</div>
<div class="modal" id="modalSettings" hidden>
<div class="modal-backdrop"></div>
<div class="modal-dialog" style="max-width: 560px;">
<div class="modal-header">
<h2>Server settings</h2>
<button class="modal-close" aria-label="Close">×</button>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin: 0 0 14px;">
These map to <code>server.properties</code>. MC reads them at startup, so changes need a server restart to take effect.
</p>
<div class="settings-grid">
<label>MOTD<input id="ssfMotd" type="text" /></label>
<label>Gamemode<select id="ssfGamemode">
<option>survival</option><option>creative</option><option>adventure</option><option>spectator</option>
</select></label>
<label>Difficulty<select id="ssfDifficulty">
<option>peaceful</option><option>easy</option><option>normal</option><option>hard</option>
</select></label>
<label>View distance<input id="ssfViewDistance" type="number" min="3" max="32" step="1" /></label>
<label>Sim distance<input id="ssfSimulationDistance" type="number" min="3" max="32" step="1" /></label>
<label>Max players<input id="ssfMaxPlayers" type="number" min="1" max="200" step="1" /></label>
<label>Spawn protection<input id="ssfSpawnProtection" type="number" min="0" max="64" step="1" /></label>
</div>
<div class="settings-checks">
<label class="danger-row"><input id="ssfPvp" type="checkbox" /><span>PvP</span></label>
<label class="danger-row"><input id="ssfHardcore" type="checkbox" /><span>Hardcore</span></label>
<label class="danger-row"><input id="ssfAllowFlight" type="checkbox" /><span>Allow flight</span></label>
<label class="danger-row"><input id="ssfWhiteList" type="checkbox" /><span>Whitelist enabled</span></label>
<label class="danger-row"><input id="ssfEnforceWhitelist" type="checkbox" /><span>Enforce whitelist</span></label>
<label class="danger-row"><input id="ssfEnableCommandBlock" type="checkbox" /><span>Enable command blocks</span></label>
</div>
<div class="actions" style="margin-top: 14px;">
<button id="ssSave" style="flex: 1;">Save</button>
<button id="ssRestart" class="ghost-btn">Save &amp; restart</button>
</div>
<div id="ssMsg" class="acct-msg"></div>
</div>
</div>
<div class="modal" id="modalMap" hidden>
<div class="modal-backdrop"></div>
<div class="modal-dialog">
<div class="modal-header">
<h2>World map</h2>
<button class="modal-close" aria-label="Close">×</button>
</div>
<p style="font-size: 12px; color: var(--text-muted); margin: 0 0 14px; line-height: 1.45;">
Renders the world to a browsable 3D map (BlueMap). Runs as a separate process -- no impact on the live MC server.
First render of a 5000-block area takes 2-6 hours; subsequent renders are incremental and much faster.
</p>
<div class="stat-row"><span class="key">Phase</span><span id="mapPhase" class="val">Idle</span></div>
<div class="stat-row"><span class="key">Last log</span><span id="mapLastLog" class="val" style="font-size: 10px; max-width: 300px; overflow: hidden; text-overflow: ellipsis;">--</span></div>
<div class="actions" style="margin-top: 14px;">
<button id="mapRender" style="flex: 1;">Render now</button>
<button id="mapCancel" class="danger" hidden>Cancel</button>
<button id="mapOpen" class="ghost-btn">Open map ↗</button>
</div>
<div id="mapMsg" class="acct-msg"></div>
</div>
</div>
<div class="modal" id="modalWipe" hidden>
<div class="modal-backdrop"></div>
<div class="modal-dialog danger">
<div class="modal-header">
<h2>Wipe world</h2>
<button class="modal-close" aria-label="Close">×</button>
</div>
<p class="danger-note">
Wipes the world directory and restarts the server with a fresh world.
With "Back up first" ticked, the old world is archived to your backup directory before deletion.
Players see a 30-second urgent warning before the wipe begins.
</p>
<label class="danger-row">
<input id="wipeBackup" type="checkbox" checked />
<span>Back up current world before wiping</span>
</label>
<div class="danger-section">
<div class="danger-section-title">World seed</div>
<div class="danger-row" style="margin-bottom: 6px;">
<span>Current:</span>
<code id="wipeCurrentSeed" style="margin-left: 8px;">loading...</code>
</div>
<label class="danger-row">
<input type="radio" name="wipeSeedMode" value="random" checked />
<span>Random new seed (Minecraft picks one)</span>
</label>
<label class="danger-row">
<input type="radio" name="wipeSeedMode" value="keep" />
<span>Keep current seed (regenerate identical world)</span>
</label>
<label class="danger-row">
<input type="radio" name="wipeSeedMode" value="custom" />
<span>Custom seed:</span>
<input id="wipeCustomSeed" type="text" placeholder="e.g. 12345 or 'a phrase'"
style="margin-left: 8px; flex: 1;" disabled />
</label>
</div>
<button id="wipeBtn" class="danger" style="margin-top: 14px; width: 100%;">Wipe world</button>
<div id="wipeMsg" class="acct-msg"></div>
</div>
</div>
<div class="footer">brass-sigil-server v0.1 -- embedded panel</div>
<div id="loginOverlay" class="login-overlay" hidden>
<div class="login-box">
<h2>Brass &amp; Sigil</h2>
<p>Sign in to manage the server.</p>
<div class="input-wrap">
<input id="loginPassword" type="password" autocomplete="current-password" placeholder="Password" />
</div>
<button id="loginSubmit">Sign in</button>
<div id="loginError" class="login-error"></div>
</div>
</div>
<script type="module" src="/app.js"></script>
</body>
</html>
+28
View File
@@ -0,0 +1,28 @@
// Tiny JSON API helper used by every module.
"use strict";
export async function api(path, opts) {
const res = await fetch(path, opts);
if (res.status === 401) {
// Auth cookie missing or wrong. Surface to the auth module which
// shows the login overlay; the caller still gets an error so any
// calling code stops cleanly.
document.dispatchEvent(new CustomEvent("authrequired"));
throw new Error("Unauthorized");
}
if (!res.ok) throw new Error(`${path} → HTTP ${res.status}`);
return await res.json();
}
export async function apiJson(path, body, method = "POST") {
return api(path, {
method,
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body)
});
}
export function escapeHtml(s) {
return s.replace(/[&<>"']/g, c =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
+115
View File
@@ -0,0 +1,115 @@
// Login overlay + Account panel (logout / change password).
//
// Cookie is set server-side as HttpOnly so JS never sees it -- that defeats
// XSS-based exfiltration. We only briefly hold the password during input.
"use strict";
// We deliberately don't import apiJson here -- change-password returns its
// own error messages and we want to surface them verbatim to the user.
let overlayShown = false;
function showOverlay() {
if (overlayShown) return;
overlayShown = true;
const overlay = document.getElementById("loginOverlay");
if (overlay) {
overlay.hidden = false;
document.getElementById("loginPassword")?.focus();
}
}
export function setupAuth() {
document.addEventListener("authrequired", showOverlay);
setupLoginForm();
setupAccountPanel();
}
function setupLoginForm() {
const overlay = document.getElementById("loginOverlay");
const input = document.getElementById("loginPassword");
const button = document.getElementById("loginSubmit");
const errorEl = document.getElementById("loginError");
if (!overlay || !input || !button || !errorEl) return;
async function tryLogin() {
const pw = input.value;
if (!pw) return;
errorEl.textContent = "";
button.disabled = true;
try {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ password: pw }),
});
if (res.status === 401) { errorEl.textContent = "Wrong password."; input.select(); return; }
if (res.status === 429) { errorEl.textContent = "Too many attempts. Wait a minute."; return; }
if (!res.ok) { errorEl.textContent = `Error ${res.status}`; return; }
// Server set the cookie. Reload so SSE / pollers pick it up.
location.reload();
} catch (e) {
errorEl.textContent = e.message;
} finally {
button.disabled = false;
input.value = "";
}
}
button.addEventListener("click", tryLogin);
input.addEventListener("keydown", e => { if (e.key === "Enter") tryLogin(); });
}
function setupAccountPanel() {
const logoutBtn = document.getElementById("acctLogout");
const changeBtn = document.getElementById("acctChangePw");
const form = document.getElementById("acctChangeForm");
const cur = document.getElementById("acctCurrent");
const nxt = document.getElementById("acctNew");
const cnf = document.getElementById("acctConfirm");
const submit = document.getElementById("acctSubmit");
const cancel = document.getElementById("acctCancel");
const msg = document.getElementById("acctMsg");
if (!logoutBtn || !changeBtn) return;
logoutBtn.addEventListener("click", async () => {
if (!confirm("Log out of the panel?")) return;
try { await fetch("/api/auth/logout", { method: "POST" }); }
finally { location.reload(); }
});
changeBtn.addEventListener("click", () => {
form.hidden = !form.hidden;
msg.textContent = "";
if (!form.hidden) cur.focus();
});
cancel.addEventListener("click", () => {
form.hidden = true;
cur.value = nxt.value = cnf.value = "";
msg.textContent = "";
});
submit.addEventListener("click", async () => {
msg.className = "acct-msg";
msg.textContent = "";
if (nxt.value.length < 8) { msg.textContent = "New password must be at least 8 characters."; return; }
if (nxt.value !== cnf.value) { msg.textContent = "New password and confirmation don't match."; return; }
submit.disabled = true;
try {
const res = await fetch("/api/auth/change-password", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ current: cur.value, next: nxt.value }),
});
const body = await res.json().catch(() => ({}));
if (!res.ok) { msg.textContent = body.error || `Error ${res.status}`; return; }
msg.className = "acct-msg ok";
msg.textContent = "Password changed.";
cur.value = nxt.value = cnf.value = "";
setTimeout(() => { form.hidden = true; msg.textContent = ""; }, 1500);
} catch (e) {
msg.textContent = e.message;
} finally {
submit.disabled = false;
}
});
}
+263
View File
@@ -0,0 +1,263 @@
// Tab-completion + suggestion dropdown for the console command input.
// - Ghost text shows the top match inline (Tab to accept)
// - A dropdown list shows up to N matches with their argument signature
// (MC-style: <required> [optional] <a|b|c> for choices)
// - Click a list item to insert it; arrow keys + Enter also navigate the list
// when it's open
"use strict";
import { state } from "./state.js";
import { escapeHtml } from "./api.js";
const COMMANDS = [
"help", "list", "say", "tell", "msg", "me", "w",
"op", "deop",
"whitelist", "ban", "ban-ip", "pardon", "pardon-ip", "banlist",
"kick",
"tp", "teleport",
"give", "clear", "kill",
"gamemode", "gamerule", "difficulty",
"weather", "time", "seed", "spawnpoint", "setworldspawn",
"save-all", "save-on", "save-off", "stop", "reload",
"xp", "experience", "effect", "enchant",
"summon", "data", "execute", "fill", "setblock", "locate", "tag",
"ftbchunks", "ftbteams",
"chunky",
"kubejs", "kjs",
];
const SUBCOMMANDS = {
whitelist: ["add", "remove", "list", "reload", "on", "off"],
gamemode: ["survival", "creative", "adventure", "spectator"],
weather: ["clear", "rain", "thunder"],
difficulty: ["peaceful", "easy", "normal", "hard"],
time: ["set", "add", "query"],
chunky: ["start", "cancel", "pause", "continue", "world", "shape", "center", "radius", "force_load", "force_unload", "trim", "help"],
ftbchunks: ["claim", "unclaim", "load", "unload", "admin"],
};
const TAKES_PLAYER_AT = {
"op": 1, "deop": 1, "tp": 1, "teleport": 1, "kick": 1,
"ban": 1, "pardon": 1, "kill": 1,
"tell": 1, "msg": 1, "w": 1,
"give": 1, "clear": 1, "effect": 1, "enchant": 1, "xp": 1, "experience": 1,
"whitelist add": 2, "whitelist remove": 2,
"gamemode survival": 2, "gamemode creative": 2, "gamemode adventure": 2, "gamemode spectator": 2,
};
// MC-style argument signatures for each command. Shown as a hint after the name
// in the suggestion list. <required> [optional] <a|b|c> for enum choices.
const SIGNATURES = {
help: "[command]",
list: "",
say: "<message>",
tell: "<player> <message>",
msg: "<player> <message>",
me: "<action>",
w: "<player> <message>",
op: "<player>",
deop: "<player>",
whitelist: "<add|remove|list|reload|on|off>",
"whitelist add": "<player>",
"whitelist remove": "<player>",
"whitelist list": "",
"whitelist on": "",
"whitelist off": "",
"whitelist reload": "",
ban: "<player> [reason…]",
"ban-ip": "<ip|player> [reason…]",
pardon: "<player>",
"pardon-ip": "<ip>",
banlist: "[ips|players]",
kick: "<player> [reason…]",
tp: "<target> [destination]",
teleport: "<target> [destination]",
give: "<player> <item> [count]",
clear: "[player] [item]",
kill: "[target]",
gamemode: "<mode> [player]",
"gamemode survival": "[player]",
"gamemode creative": "[player]",
"gamemode adventure": "[player]",
"gamemode spectator": "[player]",
gamerule: "<rule> [value]",
difficulty: "<peaceful|easy|normal|hard>",
weather: "<clear|rain|thunder> [duration]",
time: "<set|add|query> <value>",
seed: "",
spawnpoint: "[player] [pos]",
setworldspawn: "[pos]",
"save-all": "[flush]",
"save-on": "",
"save-off": "",
stop: "",
reload: "",
xp: "<amount> [player]",
experience: "<add|set|query> <player> <amount>",
effect: "<give|clear> <player> <effect>",
enchant: "<player> <enchantment> [level]",
summon: "<entity> [pos]",
fill: "<from> <to> <block>",
setblock: "<pos> <block>",
locate: "<biome|structure> <id>",
tag: "<target> <add|remove|list> [tag]",
chunky: "<start|cancel|pause|continue|world|shape|center|radius|trim|...>",
"chunky start": "[world] [shape] [center_x] [center_z] [radius]",
"chunky cancel": "",
"chunky pause": "",
"chunky continue": "",
"chunky world": "<world>",
"chunky shape": "<square|circle|...>",
"chunky center": "<x> <z>",
"chunky radius": "<radius>",
"chunky trim": "[world] [radius] [trim_radius]",
ftbchunks: "<claim|unclaim|load|unload|admin>",
ftbteams: "<list|info|invite|...>",
kubejs: "<reload|hand|stages|...>",
kjs: "<reload|hand|stages|...>",
};
const MAX_SUGGESTIONS = 8;
let activeIndex = 0;
let currentSuggestions = [];
export function setupAutocomplete() {
const cmdInput = document.getElementById("cmdInput");
const cmdGhost = document.getElementById("cmdGhost");
const cmdSuggest = document.getElementById("cmdSuggest");
function refresh() {
const v = cmdInput.value;
currentSuggestions = computeAllSuggestions(v).slice(0, MAX_SUGGESTIONS);
activeIndex = 0;
// Inline ghost = top suggestion (only if it extends what they typed)
const top = currentSuggestions[0];
if (top && top.text.startsWith(v) && top.text !== v) {
const suffix = top.text.substring(v.length);
cmdGhost.innerHTML = `<span class="typed">${escapeHtml(v)}</span>${escapeHtml(suffix)}`;
cmdInput.dataset.suggestion = top.text;
} else {
cmdGhost.innerHTML = "";
cmdInput.dataset.suggestion = "";
}
renderList(cmdSuggest, currentSuggestions);
}
cmdInput.addEventListener("input", refresh);
cmdInput.addEventListener("focus", refresh);
cmdInput.addEventListener("blur", () => {
// Delay so a click on a list item registers before we hide
setTimeout(() => cmdSuggest.classList.remove("show"), 150);
});
cmdInput.addEventListener("keydown", e => {
if (e.key === "Tab") {
const sug = currentSuggestions[activeIndex];
if (sug) {
e.preventDefault();
cmdInput.value = sug.text + " ";
refresh();
}
} else if (e.key === "Escape") {
cmdGhost.innerHTML = "";
cmdInput.dataset.suggestion = "";
cmdSuggest.classList.remove("show");
} else if (e.key === "ArrowDown" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) {
e.preventDefault();
activeIndex = (activeIndex + 1) % currentSuggestions.length;
highlightActive(cmdSuggest);
} else if (e.key === "ArrowUp" && cmdSuggest.classList.contains("show") && currentSuggestions.length > 0) {
e.preventDefault();
activeIndex = (activeIndex - 1 + currentSuggestions.length) % currentSuggestions.length;
highlightActive(cmdSuggest);
}
// Note: Enter is handled by console.js (sends the command)
});
cmdSuggest.addEventListener("mousedown", e => {
// mousedown (not click) so we beat the input blur handler
const item = e.target.closest(".suggest-item");
if (!item) return;
e.preventDefault();
const idx = parseInt(item.dataset.idx, 10);
const sug = currentSuggestions[idx];
if (sug) {
cmdInput.value = sug.text + " ";
cmdInput.focus();
refresh();
}
});
}
function highlightActive(listEl) {
[...listEl.querySelectorAll(".suggest-item")].forEach((el, i) => {
el.classList.toggle("active", i === activeIndex);
if (i === activeIndex) el.scrollIntoView({ block: "nearest" });
});
}
function renderList(listEl, suggestions) {
if (suggestions.length === 0) {
listEl.classList.remove("show");
listEl.innerHTML = "";
return;
}
listEl.innerHTML = suggestions.map((s, i) => {
const args = s.args ? `<span class="args">${escapeHtml(s.args)}</span>` : "";
return `<div class="suggest-item${i === activeIndex ? " active" : ""}" data-idx="${i}">` +
`<span>${escapeHtml(s.text)}</span>${args}</div>`;
}).join("");
listEl.classList.add("show");
}
// Returns an array of {text, args} suggestions ordered by relevance.
// args is the MC-style hint shown next to the name.
function computeAllSuggestions(input) {
if (!input) return [];
const stripped = input.startsWith("/") ? input.substring(1) : input;
const tokens = stripped.split(" ");
const partial = tokens[tokens.length - 1].toLowerCase();
const completed = tokens.slice(0, -1);
const prefix = input.startsWith("/") ? "/" : "";
// First token: command name
if (completed.length === 0) {
const matches = COMMANDS.filter(c => c.startsWith(partial)).sort();
return matches.map(name => ({
text: prefix + name,
args: SIGNATURES[name] ?? "",
}));
}
// Subcommands
const headLower = completed[0].toLowerCase();
if (completed.length === 1 && SUBCOMMANDS[headLower]) {
const subs = SUBCOMMANDS[headLower].filter(s => s.startsWith(partial));
return subs.map(sub => ({
text: prefix + [...completed, sub].join(" "),
args: SIGNATURES[`${headLower} ${sub}`] ?? "",
}));
}
// Player-name positions
const cmdKey = completed.join(" ").toLowerCase();
const playerSlot = TAKES_PLAYER_AT[cmdKey];
if (playerSlot !== undefined && tokens.length === playerSlot + 1) {
const matches = state.knownPlayers.filter(p => p.toLowerCase().startsWith(partial));
return matches.map(p => ({
text: prefix + [...completed, p].join(" "),
args: "",
}));
}
// No structured suggestion at this position -- but still show the current
// command's signature as a contextual hint
const sig = SIGNATURES[cmdKey];
if (sig) {
return [{ text: input, args: sig }];
}
return [];
}
+183
View File
@@ -0,0 +1,183 @@
// World backup management -- list, create, restore, delete.
//
// Backups are server-online (no downtime) -- the daemon issues `save-all flush`
// + `save-off`, archives the world, then `save-on`. Restore *does* stop the
// server (it has to), and snapshots the current world to a `-prerestore-*` dir
// before extracting so a wrong restore is recoverable.
"use strict";
import { api } from "./api.js";
const els = {};
let lastSchedule = null;
let lastKeep = null;
function fmtSize(bytes) {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
function fmtDate(iso) {
try { return new Date(iso).toLocaleString(); } catch { return iso; }
}
function fmtRelativeFuture(iso) {
if (!iso) return "--";
const target = new Date(iso).getTime();
const ms = target - Date.now();
if (ms <= 0) return "imminent";
const sec = Math.round(ms / 1000);
if (sec < 60) return `in ${sec}s`;
const min = Math.round(sec / 60);
if (min < 60) return `in ${min}m`;
const hr = Math.floor(min / 60);
const rem = min % 60;
if (hr < 24) return rem ? `in ${hr}h ${rem}m` : `in ${hr}h`;
const days = Math.floor(hr / 24);
return `in ${days}d ${hr % 24}h`;
}
async function refresh() {
let data;
try { data = await api("/api/backup/list"); }
catch { return; }
els.dir.textContent = data.dir || "--";
// Server returns a human description ("Daily at 04:00", "Every 6 hours", "Disabled").
els.schedule.textContent = data.description || (data.schedule ? `Daily at ${data.schedule}` : "Disabled");
els.next.textContent = data.nextRun ? fmtRelativeFuture(data.nextRun) : "--";
els.keep.textContent = data.keep != null ? `${data.keep} latest` : "--";
lastSchedule = data.schedule || "";
lastKeep = data.keep ?? 14;
// Right-sidebar badge: count of backups
const badge = document.getElementById("bkpBadge");
if (badge) badge.textContent = data.backups?.length ? `${data.backups.length}` : "0";
if (!data.backups || data.backups.length === 0) {
els.list.innerHTML = '<li class="empty-state">No backups yet</li>';
return;
}
els.list.innerHTML = data.backups.map(b => `
<li class="backup-item">
<div class="backup-meta">
<div class="backup-name">${escape(b.name)}</div>
<div class="backup-sub">${fmtSize(b.sizeBytes)} · ${fmtDate(b.createdAt)}</div>
</div>
<div class="backup-actions">
<button class="ghost-btn bkp-restore" data-name="${escape(b.name)}">Restore</button>
<button class="ghost-btn bkp-delete" data-name="${escape(b.name)}">Delete</button>
</div>
</li>
`).join("");
}
function escape(s) {
return String(s).replace(/[&<>"']/g, c =>
({"&":"&amp;","<":"&lt;",">":"&gt;",'"':"&quot;","'":"&#39;"}[c]));
}
function showMsg(text, ok = false) {
els.msg.className = ok ? "acct-msg ok" : "acct-msg";
els.msg.textContent = text;
}
async function postJson(path, body) {
const res = await fetch(path, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return { ok: res.ok, status: res.status, body: await res.json().catch(() => ({})) };
}
export function setupBackup() {
els.dir = document.getElementById("backupDir");
els.list = document.getElementById("bkpList");
els.create = document.getElementById("bkpCreate");
els.msg = document.getElementById("bkpMsg");
els.schedule = document.getElementById("backupSchedule");
els.next = document.getElementById("backupNext");
els.keep = document.getElementById("backupKeep");
els.editBtn = document.getElementById("bkpEditSchedule");
els.form = document.getElementById("bkpScheduleForm");
els.input = document.getElementById("bkpScheduleInput");
els.keepInput = document.getElementById("bkpKeepInput");
els.saveBtn = document.getElementById("bkpScheduleSave");
els.cancelBtn = document.getElementById("bkpScheduleCancel");
if (!els.create) return;
els.editBtn?.addEventListener("click", () => {
els.form.hidden = !els.form.hidden;
if (!els.form.hidden) {
els.input.value = lastSchedule || "";
els.keepInput.value = lastKeep ?? 14;
els.input.focus();
}
});
els.cancelBtn?.addEventListener("click", () => {
els.form.hidden = true;
showMsg("");
});
els.saveBtn?.addEventListener("click", async () => {
const sched = els.input.value.trim();
const keep = parseInt(els.keepInput.value, 10);
const r = await postJson("/api/backup/schedule", {
schedule: sched,
keep: Number.isFinite(keep) ? keep : undefined,
});
if (!r.ok || r.body.ok === false) {
showMsg(r.body.error || `Error ${r.status}`);
} else {
showMsg(sched ? `Schedule saved: daily at ${sched}` : "Schedule disabled.", true);
els.form.hidden = true;
refresh();
}
});
els.create.addEventListener("click", async () => {
const reason = prompt("Optional reason / label for this backup (e.g. 'pre-update'). Leave blank for none:");
if (reason === null) return; // user cancelled
showMsg("Creating backup -- this may take a minute on a large world...");
els.create.disabled = true;
const r = await postJson("/api/backup/create", { reason: reason.trim() || null });
els.create.disabled = false;
if (!r.ok || r.body.ok === false) {
showMsg(r.body.error || `Error ${r.status}`);
} else {
showMsg(`Backup created: ${r.body.name} (${fmtSize(r.body.sizeBytes)})`, true);
refresh();
}
});
els.list.addEventListener("click", async e => {
const restore = e.target.closest(".bkp-restore");
const del = e.target.closest(".bkp-delete");
if (restore) {
const name = restore.dataset.name;
if (!confirm(`Restore from ${name}?\n\nServer will stop, current world is moved to a "-prerestore" folder for safety, then the backup is extracted and server starts again.`))
return;
showMsg("Restoring -- this stops the server...");
const r = await postJson("/api/backup/restore", { name });
if (!r.ok || r.body.ok === false) showMsg(r.body.error || `Error ${r.status}`);
else showMsg("Restore complete. Server is starting.", true);
}
if (del) {
const name = del.dataset.name;
if (!confirm(`Delete backup ${name}? This cannot be undone.`)) return;
const r = await postJson("/api/backup/delete", { name });
if (!r.ok || r.body.ok === false) showMsg(r.body.error || `Error ${r.status}`);
else { showMsg("Deleted.", true); refresh(); }
}
});
refresh();
// Backups don't change often; light poll to pick up new ones if scheduled
// backups are added later, or just to refresh after an external mv/rm.
setInterval(refresh, 30000);
}
+76
View File
@@ -0,0 +1,76 @@
// Live log streaming via Server-Sent Events + command-input wiring.
// EventSource gives us instant push (no 1-second polling lag) and reconnects
// automatically if the connection drops.
"use strict";
import { api, apiJson, escapeHtml } from "./api.js";
import { state } from "./state.js";
const consoleEl = () => document.getElementById("console");
export function setupConsole() {
consoleEl().textContent = "Connecting to server log…";
const es = new EventSource("/api/logs/stream");
let firstEvent = true;
es.addEventListener("log", e => {
if (firstEvent) { consoleEl().textContent = ""; firstEvent = false; }
try {
const d = JSON.parse(e.data);
const ts = new Date(d.t).toLocaleTimeString();
const div = document.createElement("div");
if (d.e) div.className = "err";
div.textContent = `[${ts}] ${d.m}`;
consoleEl().appendChild(div);
consoleEl().scrollTop = consoleEl().scrollHeight;
// Trim very old lines so the DOM doesn't grow unbounded
while (consoleEl().childNodes.length > 5000) {
consoleEl().removeChild(consoleEl().firstChild);
}
// Re-broadcast so other modules (e.g. pregen) can react to log lines
// without opening a second SSE connection.
document.dispatchEvent(new CustomEvent("serverlog", { detail: d }));
} catch {}
});
es.onerror = () => {
// EventSource will retry automatically.
};
// Command input
const cmdInput = document.getElementById("cmdInput");
document.getElementById("cmdSend").addEventListener("click", sendCommand);
cmdInput.addEventListener("keydown", onCmdKeyDown);
}
async function sendCommand() {
const cmdInput = document.getElementById("cmdInput");
const v = cmdInput.value.trim();
if (!v) return;
try {
await apiJson("/api/command", { command: v });
state.cmdHistory.push(v);
state.cmdHistoryIdx = state.cmdHistory.length;
cmdInput.value = "";
cmdInput.dispatchEvent(new Event("input")); // refresh ghost text
} catch (e) { alert(e.message); }
}
function onCmdKeyDown(e) {
const cmdInput = document.getElementById("cmdInput");
if (e.key === "Enter") {
sendCommand();
} else if (e.key === "ArrowUp") {
if (state.cmdHistory.length === 0) return;
e.preventDefault();
state.cmdHistoryIdx = Math.max(0, state.cmdHistoryIdx - 1);
cmdInput.value = state.cmdHistory[state.cmdHistoryIdx] || "";
cmdInput.dispatchEvent(new Event("input"));
} else if (e.key === "ArrowDown") {
if (state.cmdHistory.length === 0) return;
e.preventDefault();
state.cmdHistoryIdx = Math.min(state.cmdHistory.length, state.cmdHistoryIdx + 1);
cmdInput.value = state.cmdHistory[state.cmdHistoryIdx] || "";
cmdInput.dispatchEvent(new Event("input"));
}
// Note: Tab is handled by the autocomplete module's keydown listener.
}
+111
View File
@@ -0,0 +1,111 @@
// Danger zone -- destructive operations.
// Currently: world wipe. Always type-to-confirm to prevent accidental clicks.
"use strict";
export function setupDanger() {
const btn = document.getElementById("wipeBtn");
const cb = document.getElementById("wipeBackup");
const msg = document.getElementById("wipeMsg");
const seedDisplay = document.getElementById("wipeCurrentSeed");
const customInput = document.getElementById("wipeCustomSeed");
if (!btn) return;
// Enable the custom-seed text field only when its radio is selected.
document.querySelectorAll('input[name="wipeSeedMode"]').forEach(radio => {
radio.addEventListener("change", () => {
const mode = document.querySelector('input[name="wipeSeedMode"]:checked')?.value;
customInput.disabled = (mode !== "custom");
if (mode === "custom") customInput.focus();
});
});
// Fetch current seed each time the wipe modal becomes visible. Watching
// the wipe section's ancestor modal works without coupling to the modal
// module's open/close API.
const refreshSeed = async () => {
seedDisplay.textContent = "loading...";
try {
const res = await fetch("/api/world/seed");
const body = await res.json();
seedDisplay.textContent = body.seed
? body.seed
: "(unknown -- server stopped or seed not set)";
} catch (e) {
seedDisplay.textContent = "(failed to read)";
}
};
// Refresh on first load + whenever the modal becomes visible. Modal markup
// uses a wrapping div with "[hidden]" attr, so we observe attribute changes.
refreshSeed();
const modal = btn.closest(".modal");
if (modal) {
new MutationObserver(muts => {
for (const m of muts) {
if (m.attributeName === "hidden" && !modal.hasAttribute("hidden")) {
refreshSeed();
}
}
}).observe(modal, { attributes: true });
}
btn.addEventListener("click", async () => {
msg.className = "acct-msg";
msg.textContent = "";
const mode = document.querySelector('input[name="wipeSeedMode"]:checked')?.value || "random";
const customSeed = (customInput.value || "").trim();
if (mode === "custom" && !customSeed) {
msg.textContent = "Custom seed selected but the field is empty.";
return;
}
// Build a confirmation prompt that reflects the chosen seed strategy
// so the user sees exactly what's about to happen.
let seedNote = "";
if (mode === "keep") seedNote = `Same seed (${seedDisplay.textContent}) will be reused.\n`;
else if (mode === "custom") seedNote = `Seed will be set to: ${customSeed}\n`;
else seedNote = "A new random seed will be generated.\n";
const typed = prompt(
"Type WIPE (uppercase, exactly) to confirm world wipe.\n" +
"Server will stop, world will be replaced, server will restart.\n\n" +
seedNote
);
if (typed !== "WIPE") {
if (typed != null) msg.textContent = "Confirmation didn't match -- nothing wiped.";
return;
}
btn.disabled = true;
msg.textContent = "Wiping...";
try {
const res = await fetch("/api/world/wipe", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
confirm: typed,
backup: cb.checked,
seedMode: mode,
customSeed: mode === "custom" ? customSeed : null,
}),
});
const body = await res.json().catch(() => ({}));
if (!res.ok || body.ok === false) {
msg.textContent = body.error || `Error ${res.status}`;
return;
}
msg.className = "acct-msg ok";
const parts = ["World wiped."];
if (body.seedUsed) parts.push(`Seed: ${body.seedUsed}.`);
if (body.backupName) parts.push(`Backup: ${body.backupName}.`);
parts.push("Server restarting...");
msg.textContent = parts.join(" ");
// Refresh the seed display so user sees the new value once MC is back.
setTimeout(refreshSeed, 5000);
} catch (e) {
msg.textContent = e.message;
} finally {
btn.disabled = false;
}
});
}
+114
View File
@@ -0,0 +1,114 @@
// World map (BlueMap) controls.
//
// Render runs out-of-process via BlueMap CLI. Status polled every 3 s while
// the modal is open OR while a render is in progress. The "Open map" button
// only opens the new tab if there's actually rendered output -- otherwise we
// pop a friendly message saying "render first".
"use strict";
import { api } from "./api.js";
const els = {};
let pollTimer = null;
let modalOpen = false;
let lastHasOutput = false;
function setPolling(intervalMs) {
if (pollTimer) clearInterval(pollTimer);
pollTimer = setInterval(tick, intervalMs);
}
async function tick() {
let s;
try { s = await api("/api/map/status"); }
catch { return; }
lastHasOutput = !!s.hasOutput;
document.getElementById("mapBadge").hidden = !s.inProgress;
if (s.inProgress) document.getElementById("mapBadge").textContent = "rendering";
if (!modalOpen) return; // don't bother updating modal DOM if hidden
els.phase.textContent = phaseLabel(s.phase);
els.lastLog.textContent = s.lastLogLine ?? "--";
els.render.disabled = s.inProgress;
els.render.textContent = s.inProgress ? "Rendering…" : "Render now";
els.cancel.hidden = !s.inProgress;
if (s.phase === "complete" || s.phase === "failed" || s.phase === "cancelled") {
if (s.phase === "failed" && s.error) showMsg("Failed: " + s.error);
else if (s.phase === "cancelled") showMsg("Cancelled. Next render resumes from this point.");
else if (s.phase === "complete") showMsg("Render complete.", true);
}
}
function phaseLabel(phase) {
switch (phase) {
case "downloading": return "Downloading CLI";
case "extracting": return "Extracting CLI";
case "configuring": return "Configuring";
case "rendering": return "Rendering";
case "complete": return "Complete";
case "failed": return "Failed";
case "cancelled": return "Cancelled";
default: return "Idle";
}
}
function showMsg(text, ok = false) {
els.msg.className = ok ? "acct-msg ok" : "acct-msg";
els.msg.textContent = text;
}
export function setupMap() {
els.phase = document.getElementById("mapPhase");
els.lastLog = document.getElementById("mapLastLog");
els.render = document.getElementById("mapRender");
els.cancel = document.getElementById("mapCancel");
els.open = document.getElementById("mapOpen");
els.msg = document.getElementById("mapMsg");
if (!els.render) return;
els.cancel.addEventListener("click", async () => {
if (!confirm("Cancel the render? It's resumable -- next time you click Render, BlueMap continues from where it stopped.")) return;
try {
const res = await fetch("/api/map/cancel", { method: "POST" });
const body = await res.json().catch(() => ({}));
if (!res.ok || body.ok === false) showMsg(body.error || `Error ${res.status}`);
else { showMsg("Cancelling…"); tick(); }
} catch (e) { showMsg(e.message); }
});
// Track modal open/close so we can poll faster when the user is watching.
const modal = document.getElementById("modalMap");
new MutationObserver(() => {
modalOpen = !modal.hidden;
if (modalOpen) tick();
}).observe(modal, { attributes: true, attributeFilter: ["hidden"] });
els.render.addEventListener("click", async () => {
showMsg("Starting render…");
els.render.disabled = true;
try {
const res = await fetch("/api/map/render", { method: "POST" });
const body = await res.json().catch(() => ({}));
if (!res.ok || body.ok === false) {
showMsg(body.error || `Error ${res.status}`);
els.render.disabled = false;
return;
}
tick();
} catch (e) { showMsg(e.message); els.render.disabled = false; }
});
els.open.addEventListener("click", () => {
if (!lastHasOutput) {
showMsg("No map output yet -- click Render now first.");
return;
}
window.open("/map/", "_blank", "noopener");
});
tick();
setPolling(3000); // light poll keeps the badge fresh + catches background renders
}
+57
View File
@@ -0,0 +1,57 @@
// Tiny modal helper. Registers a single document-level Esc + backdrop-click
// handler so individual modals don't have to. Public API: openModal(id) /
// closeModal(id) / closeAllModals().
"use strict";
let bound = false;
function bindGlobal() {
if (bound) return;
bound = true;
document.addEventListener("keydown", e => {
if (e.key === "Escape") closeAllModals();
});
document.addEventListener("click", e => {
// Backdrop click closes the topmost open modal.
const backdrop = e.target.closest(".modal-backdrop");
if (backdrop) closeModal(backdrop.parentElement.id);
const closeBtn = e.target.closest(".modal-close");
if (closeBtn) closeModal(closeBtn.closest(".modal").id);
});
}
export function openModal(id) {
bindGlobal();
const m = document.getElementById(id);
if (!m) return;
m.hidden = false;
document.body.classList.add("modal-open");
// Focus first input/button for keyboard users.
setTimeout(() => {
const focusable = m.querySelector("input, button:not(.modal-close), select, textarea");
focusable?.focus();
}, 50);
}
export function closeModal(id) {
const m = document.getElementById(id);
if (!m) return;
m.hidden = true;
if (!document.querySelector(".modal:not([hidden])")) {
document.body.classList.remove("modal-open");
}
}
export function closeAllModals() {
document.querySelectorAll(".modal:not([hidden])").forEach(m => m.hidden = true);
document.body.classList.remove("modal-open");
}
/// Wires `data-open-modal="someId"` on any element to opening the modal.
export function setupModalTriggers() {
bindGlobal();
document.addEventListener("click", e => {
const trigger = e.target.closest("[data-open-modal]");
if (trigger) openModal(trigger.getAttribute("data-open-modal"));
});
}
+117
View File
@@ -0,0 +1,117 @@
// Status / players / whitelist sidebar panels. Polled (not streamed) because the
// data they show changes infrequently. Logs use SSE -- see console.js.
"use strict";
import { api, escapeHtml } from "./api.js";
import { state, rebuildKnownPlayers } from "./state.js";
export async function tickStatus() {
const pill = document.getElementById("statusPill");
const text = document.getElementById("statusText");
const memEl = document.getElementById("memUsage");
const memBar = document.getElementById("memBar");
const cpuCur = document.getElementById("cpuCurrent");
const cpuBar = document.getElementById("cpuBar");
const cpuMax = document.getElementById("cpuMax");
const cpuAvg = document.getElementById("cpuAvg");
function renderResources(s) {
if (s.memoryBytes != null) {
const usedGB = s.memoryBytes / (1024 ** 3);
const maxGB = s.memoryMaxMB ? s.memoryMaxMB / 1024 : null;
memEl.textContent = maxGB
? `${usedGB.toFixed(2)} / ${maxGB.toFixed(1)} GB`
: `${usedGB.toFixed(2)} GB`;
memBar.style.width = maxGB ? `${Math.min(100, (usedGB / maxGB) * 100)}%` : "0%";
} else {
memEl.textContent = "--";
memBar.style.width = "0%";
}
if (s.cpu) {
cpuCur.textContent = `${s.cpu.current.toFixed(1)} %`;
cpuBar.style.width = `${Math.min(100, s.cpu.current)}%`;
cpuMax.textContent = `${s.cpu.max.toFixed(1)}%`;
cpuAvg.textContent = `${s.cpu.avg.toFixed(1)}%`;
} else {
cpuCur.textContent = "--";
cpuBar.style.width = "0%";
cpuMax.textContent = "--";
cpuAvg.textContent = "--";
}
}
try {
const s = await api("/api/status");
if (s.running) {
pill.className = "status-pill online";
text.textContent = "Online";
document.getElementById("pid").textContent = s.pid ?? "--";
const secs = Math.floor(s.uptime ?? 0);
const h = Math.floor(secs / 3600), m = Math.floor((secs % 3600) / 60);
document.getElementById("uptime").textContent = `${h}h ${m}m`;
renderResources(s);
} else {
pill.className = "status-pill offline";
text.textContent = "Offline";
document.getElementById("pid").textContent = "--";
document.getElementById("uptime").textContent = "--";
renderResources({ memoryBytes: null, cpu: null, memoryMaxMB: null });
}
const pv = s.packVersion;
document.getElementById("packVersion").textContent = pv?.name ? `${pv.name} v${pv.version}` : "--";
// World size -- even when server is offline we can still report disk usage.
const worldEl = document.getElementById("worldSize");
if (worldEl) {
const b = s.worldSizeBytes;
if (b == null || b === 0) worldEl.textContent = "--";
else if (b < 1024 * 1024) worldEl.textContent = `${(b / 1024).toFixed(0)} KB`;
else if (b < 1024 * 1024 * 1024) worldEl.textContent = `${(b / (1024 * 1024)).toFixed(1)} MB`;
else worldEl.textContent = `${(b / (1024 * 1024 * 1024)).toFixed(2)} GB`;
}
} catch {
pill.className = "status-pill offline";
text.textContent = "Disconnected";
}
}
export async function tickPlayers() {
try {
const p = await api("/api/players");
state.onlinePlayers = (p.players || []).slice();
document.getElementById("playerCount").textContent = p.online >= 0 ? p.online : "?";
const list = document.getElementById("players");
if (state.onlinePlayers.length === 0) {
list.innerHTML = '<li class="empty-state">No-one online</li>';
} else {
list.innerHTML = state.onlinePlayers.map(n => `<li>${escapeHtml(n)}<span></span></li>`).join("");
}
} catch {}
rebuildKnownPlayers();
}
export async function tickWhitelist() {
try {
const w = await api("/api/whitelist");
state.whitelistedPlayers = (w.players || []).slice();
const list = document.getElementById("whitelist");
if (state.whitelistedPlayers.length === 0) {
list.innerHTML = '<li class="empty-state">No players whitelisted yet</li>';
} else {
list.innerHTML = state.whitelistedPlayers.map(n =>
`<li>${escapeHtml(n)}<button data-name="${escapeHtml(n)}" class="wl-remove">Remove</button></li>`
).join("");
}
} catch {}
rebuildKnownPlayers();
}
// MC takes ~1-2 s to look up a UUID via Mojang and write whitelist.json.
// Refresh shortly after a user-triggered add/remove instead of waiting for the
// 30-second polling tick.
let pendingRefresh;
export function refreshWhitelistSoon() {
clearTimeout(pendingRefresh);
pendingRefresh = setTimeout(tickWhitelist, 1500);
setTimeout(tickWhitelist, 4000); // belt-and-braces if Mojang is slow
}
+222
View File
@@ -0,0 +1,222 @@
// World pre-generation controls + live status display.
//
// We use the canonical config-then-start sequence rather than the all-in-one
// `chunky start <world> <shape> <cx> <cz> <r>` form because the all-in-one
// form's argument order varies between Chunky versions, and Brigadier silently
// prints the usage hint instead of erroring when it doesn't match.
//
// Status is parsed from Chunky's own log lines (re-broadcast by console.js as
// the `serverlog` custom event) -- no separate polling endpoint is needed.
//
// Chunky is intentionally only invoked from this panel -- it can punch holes
// in chunks if it crashes mid-run, so we don't want it ticking on its own.
"use strict";
import { apiJson } from "./api.js";
async function send(cmd) {
await apiJson("/api/command", { command: cmd });
}
async function startPregen(radius) {
await send("chunky world minecraft:overworld");
await send("chunky shape square");
await send("chunky center 0 0");
await send(`chunky radius ${radius}`);
await send("chunky start");
}
// ─────────── status display ───────────
const els = {};
function setState(label, cssClass) {
if (!els.state) return;
els.state.textContent = label;
els.state.className = "val " + cssClass;
applyButtonStates(cssClass);
}
/// Enable/disable the Start/Pause/Resume/Cancel buttons based on the current
/// pregen state. Called whenever setState changes the displayed status.
function applyButtonStates(cssClass) {
if (!els.btnStart) return;
// Map the state CSS class to a logical state name. Default = idle.
let s = "idle";
if (cssClass === "pg-state-running") s = "running";
else if (cssClass === "pg-state-paused") s = "paused";
else if (cssClass === "pg-state-cancelling") s = "cancelling";
els.btnStart.disabled = s !== "idle";
els.btnPause.disabled = s !== "running";
els.btnContinue.disabled = s !== "paused";
els.btnCancel.disabled = !(s === "running" || s === "paused");
}
function resetMetrics() {
if (!els.progressFill) return;
els.progressFill.style.width = "0%";
els.progressText.textContent = "--";
els.chunks.textContent = "--";
els.rate.textContent = "--";
els.eta.textContent = "--";
}
// Parse a Chunky log line. Returns an object describing what changed, or null
// if this line isn't a Chunky message we recognise (or is for a different world).
//
// Chunky supports one task per world running concurrently, so we narrow our
// display to the overworld -- that's the only world the Start button targets,
// and it keeps the UI sane if someone kicks off other worlds via raw command.
//
// Real Chunky lines look roughly like:
// "[Chunky] Task running for minecraft:overworld at 0,0. Progress: 12.50% (1234/9876 chunks), 45.20 cps, ETA: 0h 1m 30s"
// "[Chunky] Task started for minecraft:overworld."
// "[Chunky] Task stopped for minecraft:overworld."
// "[Chunky] Task paused for minecraft:overworld."
// "[Chunky] No task running."
const TARGET_WORLD = "minecraft:overworld";
function parseChunky(text) {
if (!/Chunky|chunky/.test(text)) return null;
// If a world is named, only react when it's the one we're tracking.
// Lines without a world (e.g. "No task running.") fall through.
const worldMatch = text.match(/(minecraft:[a-z_]+|the_nether|the_end|overworld)/i);
if (worldMatch) {
const w = worldMatch[1].toLowerCase();
const normalised = w.startsWith("minecraft:") ? w : `minecraft:${w}`;
if (normalised !== TARGET_WORLD) return null;
}
// State transitions
if (/Task started/i.test(text)) return { state: "running" };
if (/Task paused/i.test(text)) return { state: "paused" };
if (/Task (stopped|cancelled|canceled|completed|finished)/i.test(text))
return { state: "idle", clear: true };
if (/No task running/i.test(text)) return { state: "idle", clear: true };
// Progress line: try to extract whatever pieces are present
const out = {};
let matched = false;
const pct = text.match(/(\d+(?:\.\d+)?)\s*%/);
if (pct) { out.percent = parseFloat(pct[1]); matched = true; }
const chunks = text.match(/(\d[\d,]*)\s*\/\s*(\d[\d,]*)\s*chunks?/i);
if (chunks) {
out.done = chunks[1].replace(/,/g, "");
out.total = chunks[2].replace(/,/g, "");
matched = true;
}
const cps = text.match(/(\d+(?:\.\d+)?)\s*cps/i);
if (cps) { out.cps = parseFloat(cps[1]); matched = true; }
const eta = text.match(/ETA[:\s]+([^,)\n]+?)(?=[,)\n]|$)/i);
if (eta) { out.eta = eta[1].trim(); matched = true; }
if (matched) { out.state = "running"; return out; }
return null;
}
function applyParsed(p) {
if (!els.state) return;
if (p.state === "running") setState("Running", "pg-state-running");
if (p.state === "paused") setState("Paused", "pg-state-paused");
if (p.state === "idle") setState("Idle", "pg-state-idle");
if (p.clear) resetMetrics();
if (p.percent != null) {
els.progressFill.style.width = `${Math.min(100, p.percent)}%`;
els.progressText.textContent = `${p.percent.toFixed(2)}%`;
}
if (p.done && p.total) {
const fmt = n => Number(n).toLocaleString();
els.chunks.textContent = `${fmt(p.done)} / ${fmt(p.total)}`;
}
if (p.cps != null) els.rate.textContent = `${p.cps.toFixed(1)} chunks/s`;
if (p.eta) els.eta.textContent = p.eta;
}
function setupCollapsible() {
// Persist collapsed state per card across reloads via localStorage.
document.querySelectorAll(".card.collapsible").forEach(card => {
const id = card.id || "";
const storageKey = id ? `bs-collapsed:${id}` : null;
const startCollapsed = storageKey && localStorage.getItem(storageKey) === "1";
if (!startCollapsed) card.classList.add("expanded");
const toggle = card.querySelector(".collapsible-toggle");
if (!toggle) return;
toggle.addEventListener("click", () => {
card.classList.toggle("expanded");
if (storageKey) {
localStorage.setItem(storageKey,
card.classList.contains("expanded") ? "0" : "1");
}
});
});
}
export function setupPregen() {
setupCollapsible();
els.state = document.getElementById("pgState");
els.progressFill = document.getElementById("pgProgressFill");
els.progressText = document.getElementById("pgProgressText");
els.chunks = document.getElementById("pgChunks");
els.rate = document.getElementById("pgRate");
els.eta = document.getElementById("pgEta");
els.btnStart = document.getElementById("pgStart");
els.btnPause = document.getElementById("pgPause");
els.btnContinue = document.getElementById("pgContinue");
els.btnCancel = document.getElementById("pgCancel");
// Idle by default -- disable everything except Start.
applyButtonStates("pg-state-idle");
const radiusInput = document.getElementById("pgRadius");
document.getElementById("pgStart").addEventListener("click", async () => {
const r = parseInt(radiusInput.value, 10);
if (!Number.isFinite(r) || r < 100) {
alert("Enter a radius of at least 100 blocks.");
return;
}
if (r > 20000 && !confirm(`Radius ${r} is large and may take hours. Continue?`)) {
return;
}
try {
setState("Starting…", "pg-state-running");
resetMetrics();
await startPregen(r);
} catch (e) {
setState("Idle", "pg-state-idle");
alert(e.message);
}
});
document.getElementById("pgPause").addEventListener("click", async () => {
try { await send("chunky pause"); } catch (e) { alert(e.message); }
});
document.getElementById("pgContinue").addEventListener("click", async () => {
try {
await send("chunky continue");
setState("Running", "pg-state-running");
} catch (e) { alert(e.message); }
});
document.getElementById("pgCancel").addEventListener("click", async () => {
if (!confirm("Cancel the current pre-generation run?")) return;
try {
setState("Cancelling…", "pg-state-cancelling");
await send("chunky cancel");
} catch (e) { alert(e.message); }
});
// Subscribe to the shared SSE re-broadcast from console.js
document.addEventListener("serverlog", e => {
const msg = e.detail?.m;
if (typeof msg !== "string") return;
const parsed = parseChunky(msg);
if (parsed) applyParsed(parsed);
});
}
+16
View File
@@ -0,0 +1,16 @@
// Start / stop buttons.
"use strict";
import { api } from "./api.js";
export function setupServerControls() {
document.getElementById("btnStart").addEventListener("click", async () => {
try { await api("/api/server/start", { method: "POST" }); }
catch (e) { alert(e.message); }
});
document.getElementById("btnStop").addEventListener("click", async () => {
if (!confirm("Stop the server?")) return;
try { await api("/api/server/stop", { method: "POST" }); }
catch (e) { alert(e.message); }
});
}
+135
View File
@@ -0,0 +1,135 @@
// Server settings: read/write a curated subset of server.properties.
// Changes require an MC restart -- Save writes only, Save & restart bounces MC.
"use strict";
import { api } from "./api.js";
const els = {};
// Map of input element ID -> server.properties key. Keeps the form ↔ file
// translation in one place; new fields can be added by adding a row here +
// matching elements in index.html.
const FIELDS = [
{ id: "ssfMotd", key: "motd", type: "string" },
{ id: "ssfGamemode", key: "gamemode", type: "string" },
{ id: "ssfDifficulty", key: "difficulty", type: "string" },
{ id: "ssfViewDistance", key: "view-distance", type: "int" },
{ id: "ssfSimulationDistance", key: "simulation-distance", type: "int" },
{ id: "ssfMaxPlayers", key: "max-players", type: "int" },
{ id: "ssfSpawnProtection", key: "spawn-protection", type: "int" },
{ id: "ssfPvp", key: "pvp", type: "bool" },
{ id: "ssfHardcore", key: "hardcore", type: "bool" },
{ id: "ssfAllowFlight", key: "allow-flight", type: "bool" },
{ id: "ssfWhiteList", key: "white-list", type: "bool" },
{ id: "ssfEnforceWhitelist", key: "enforce-whitelist", type: "bool" },
{ id: "ssfEnableCommandBlock", key: "enable-command-block", type: "bool" },
];
function readForm() {
const out = {};
for (const f of FIELDS) {
const el = document.getElementById(f.id);
if (!el) continue;
if (f.type === "bool") out[f.key] = el.checked ? "true" : "false";
else if (f.type === "int") {
const v = parseInt(el.value, 10);
if (Number.isFinite(v)) out[f.key] = String(v);
} else {
out[f.key] = el.value;
}
}
return out;
}
function writeForm(values) {
for (const f of FIELDS) {
const el = document.getElementById(f.id);
if (!el) continue;
const v = values[f.key];
if (v === undefined) continue;
if (f.type === "bool") el.checked = (v === "true");
else el.value = v;
}
}
function renderSummary(values) {
document.getElementById("ssMotd").textContent = values["motd"] ?? "--";
document.getElementById("ssDifficulty").textContent = values["difficulty"] ?? "--";
document.getElementById("ssDistances").textContent =
`${values["view-distance"] ?? "--"} / ${values["simulation-distance"] ?? "--"}`;
document.getElementById("ssMaxPlayers").textContent = values["max-players"] ?? "--";
const wl = values["white-list"] === "true";
const enf = values["enforce-whitelist"] === "true";
document.getElementById("ssWhitelist").textContent =
wl ? (enf ? "enforced" : "enabled") : "off";
}
async function refresh() {
try {
const data = await api("/api/server/settings");
renderSummary(data.values || {});
writeForm(data.values || {});
} catch { /* ignore -- panel just shows last-known */ }
}
async function postSettings() {
const payload = readForm();
const res = await fetch("/api/server/settings", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
return { ok: res.ok, body: await res.json().catch(() => ({})) };
}
function showMsg(text, ok = false) {
els.msg.className = ok ? "acct-msg ok" : "acct-msg";
els.msg.textContent = text;
}
export function setupSettings() {
els.msg = document.getElementById("ssMsg");
els.save = document.getElementById("ssSave");
els.restart = document.getElementById("ssRestart");
if (!els.save) return;
els.save.addEventListener("click", async () => {
showMsg("Saving...");
els.save.disabled = true;
try {
const r = await postSettings();
if (!r.ok || r.body.ok === false) {
showMsg(r.body.error || `Error ${r.body.status ?? ""}`);
return;
}
showMsg(r.body.restartRequired
? "Saved. Restart for changes to take effect."
: "Saved.", true);
refresh();
} catch (e) { showMsg(e.message); }
finally { els.save.disabled = false; }
});
els.restart.addEventListener("click", async () => {
if (!confirm("Save changes and restart the server now? Players will be disconnected briefly.")) return;
showMsg("Saving + restarting...");
els.save.disabled = true; els.restart.disabled = true;
try {
const r = await postSettings();
if (!r.ok || r.body.ok === false) {
showMsg(r.body.error || `Save failed: ${r.body.status ?? ""}`);
return;
}
const rr = await fetch("/api/server/restart", { method: "POST" });
const rb = await rr.json().catch(() => ({}));
if (!rr.ok || rb.ok === false) showMsg("Saved, but restart failed: " + (rb.error || rr.status));
else showMsg("Saved + restarting. New settings live in ~30s.", true);
refresh();
} catch (e) { showMsg(e.message); }
finally { els.save.disabled = false; els.restart.disabled = false; }
});
refresh();
// Light poll: pick up out-of-band edits to server.properties.
setInterval(refresh, 30000);
}
+18
View File
@@ -0,0 +1,18 @@
// Shared in-memory state -- the union of online + whitelisted players is what
// tab-completion matches against, so we keep it centralised here.
"use strict";
export const state = {
onlinePlayers: [],
whitelistedPlayers: [],
knownPlayers: [], // sorted union, for autocomplete
cmdHistory: [],
cmdHistoryIdx: -1,
};
export function rebuildKnownPlayers() {
const set = new Set();
state.onlinePlayers.forEach(n => set.add(n));
state.whitelistedPlayers.forEach(n => set.add(n));
state.knownPlayers = [...set].sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()));
}
+138
View File
@@ -0,0 +1,138 @@
// Modpack update controls.
//
// The card hides itself when there's no update available and reveals when the
// manifest reports a newer pack version. Polls /api/update/status (every 5 s
// when idle, every 1 s when an update is in-flight) to keep state fresh.
"use strict";
import { api, apiJson } from "./api.js";
const els = {};
let pollTimer = null;
let pollInterval = 5000;
function setPolling(intervalMs) {
if (intervalMs === pollInterval && pollTimer) return;
if (pollTimer) clearInterval(pollTimer);
pollInterval = intervalMs;
pollTimer = setInterval(tick, intervalMs);
}
async function tick() {
let s;
try { s = await api("/api/update/status"); }
catch { return; }
els.current.textContent = s.current ?? "--";
els.available.textContent = s.available ?? "--";
const card = els.card;
if (s.needsUpdate || s.inProgress) {
card.hidden = false;
card.classList.toggle("has-update", s.needsUpdate && !s.inProgress);
} else {
card.hidden = true;
}
if (s.inProgress) {
els.progress.hidden = false;
els.start.disabled = true;
els.delay.disabled = true;
els.phase.textContent = phaseLabel(s.phase);
const showCancel = s.phase === "countdown";
els.cancel.hidden = !showCancel;
if (s.phase === "countdown" && s.countdownTotal > 0) {
const elapsed = s.countdownTotal - s.countdownRemaining;
const pct = (elapsed / s.countdownTotal) * 100;
els.fill.style.width = `${pct}%`;
els.status.textContent = `Restarting in ${formatSeconds(s.countdownRemaining)}`;
} else {
// Indeterminate during sync / loader install / start phases --
// just show 100% and a phase-specific status string.
els.fill.style.width = "100%";
els.status.textContent = phaseStatus(s.phase);
}
setPolling(1000);
} else {
els.progress.hidden = true;
els.start.disabled = !s.needsUpdate;
els.delay.disabled = false;
if (s.phase === "failed" && s.error) {
els.progress.hidden = false;
els.phase.textContent = "FAILED";
els.status.textContent = s.error;
els.fill.style.width = "0%";
}
setPolling(5000);
}
}
function phaseLabel(phase) {
switch (phase) {
case "countdown": return "COUNTDOWN";
case "stopping": return "STOPPING";
case "syncing": return "SYNCING MODS";
case "installing_loader": return "INSTALLING LOADER";
case "starting": return "STARTING";
case "complete": return "COMPLETE";
case "failed": return "FAILED";
case "cancelled": return "CANCELLED";
default: return "WORKING";
}
}
function phaseStatus(phase) {
switch (phase) {
case "stopping": return "Stopping Minecraft cleanly...";
case "syncing": return "Syncing mods from manifest...";
case "installing_loader": return "Re-running NeoForge installer...";
case "starting": return "Starting Minecraft...";
case "complete": return "Update complete.";
default: return "";
}
}
function formatSeconds(s) {
if (s < 60) return `${s}s`;
const m = Math.floor(s / 60), r = s % 60;
return `${m}m ${String(r).padStart(2, "0")}s`;
}
export function setupUpdate() {
els.card = document.getElementById("updateCard");
els.current = document.getElementById("updCurrent");
els.available = document.getElementById("updAvailable");
els.delay = document.getElementById("updDelay");
els.start = document.getElementById("updStart");
els.progress = document.getElementById("updProgress");
els.phase = document.getElementById("updPhaseLabel");
els.fill = document.getElementById("updProgressFill");
els.status = document.getElementById("updStatusText");
els.cancel = document.getElementById("updCancel");
els.start.addEventListener("click", async () => {
const delay = parseInt(els.delay.value, 10);
if (!Number.isFinite(delay) || delay < 0) {
alert("Enter a non-negative warning duration.");
return;
}
if (!confirm(`Update modpack? Players get a ${delay}s warning, then the server restarts.`)) return;
try {
await apiJson("/api/update/start", { delaySeconds: delay });
await tick();
} catch (e) { alert(e.message); }
});
els.cancel.addEventListener("click", async () => {
if (!confirm("Cancel the countdown? Update will be aborted; server stays running.")) return;
try {
await apiJson("/api/update/cancel", {});
await tick();
} catch (e) { alert(e.message); }
});
tick();
setPolling(5000);
}
+86
View File
@@ -0,0 +1,86 @@
// Whitelist add / remove via the API; refreshes the panel display shortly after
// each action (server takes ~1-2 s to look up UUID via Mojang and write whitelist.json).
"use strict";
import { api, apiJson, escapeHtml } from "./api.js";
export function setupWhitelistActions(refreshSoon) {
const wlInput = document.getElementById("wlInput");
document.getElementById("wlAdd").addEventListener("click", () => addWhitelisted(refreshSoon));
wlInput.addEventListener("keydown", e => { if (e.key === "Enter") addWhitelisted(refreshSoon); });
// Delegated removal -- list items are re-rendered each tick, no static binding.
document.getElementById("whitelist").addEventListener("click", async e => {
const btn = e.target.closest(".wl-remove");
if (!btn) return;
const name = btn.dataset.name;
if (!name) return;
if (!confirm(`Remove ${name} from whitelist?`)) return;
try {
await apiJson("/api/whitelist/remove", { name });
refreshSoon();
} catch (err) { alert(err.message); }
});
// Pending whitelist requests from friends. Approve adds to whitelist + clears
// the request; Deny just marks denied so the friend's launcher knows.
const reqsList = document.getElementById("wlRequests");
const reqsBlock = document.getElementById("wlRequestsBlock");
const reqsBadge = document.getElementById("wlReqBadge");
reqsList?.addEventListener("click", async e => {
const btn = e.target.closest("button[data-req-action]");
if (!btn) return;
const name = btn.dataset.name;
const action = btn.dataset.reqAction; // "approve" | "deny"
if (!name || !action) return;
if (action === "deny" && !confirm(`Deny ${name}'s request?`)) return;
try {
await apiJson(`/api/whitelist/requests/${action}`, { name });
await refreshRequests();
// Approving fires /whitelist add via stdin -- let the server-side write
// ~1-2 s of grace before re-reading whitelist.json.
if (action === "approve") refreshSoon();
} catch (err) { alert(err.message); }
});
async function refreshRequests() {
if (!reqsList || !reqsBlock || !reqsBadge) return;
let data;
try { data = await api("/api/whitelist/requests"); }
catch { return; }
const reqs = data.requests || [];
if (reqs.length === 0) {
reqsBlock.hidden = true;
reqsBadge.hidden = true;
return;
}
reqsBlock.hidden = false;
reqsBadge.hidden = false;
reqsBadge.textContent = String(reqs.length);
reqsList.innerHTML = reqs.map(r => `
<li>
<div class="wl-req-meta">${escapeHtml(r.username)}</div>
${r.message ? `<div class="wl-req-msg">"${escapeHtml(r.message)}"</div>` : ""}
<div class="wl-req-actions">
<button data-req-action="approve" data-name="${escapeHtml(r.username)}">Approve</button>
<button class="ghost-btn" data-req-action="deny" data-name="${escapeHtml(r.username)}">Deny</button>
</div>
</li>
`).join("");
}
refreshRequests();
setInterval(refreshRequests, 15000);
}
async function addWhitelisted(refreshSoon) {
const inp = document.getElementById("wlInput");
const name = inp.value.trim();
if (!name) return;
try {
await apiJson("/api/whitelist/add", { name });
inp.value = "";
refreshSoon();
} catch (e) { alert(e.message); }
}
+521
View File
@@ -0,0 +1,521 @@
:root {
--bg-deep: #070b16;
--bg: #0b1220;
--card: #13192a;
--card-edge: #2a3552;
--text: #e8dfc8;
--text-muted: #7a8497;
--brass: #d4a24c;
--brass-hi: #e8b95c;
--brass-lo: #5c4519;
--magic: #5dd4e8;
--danger: #b94228;
--ok: #4ade80;
}
html, body {
margin: 0; padding: 0;
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, system-ui, sans-serif;
min-height: 100vh;
}
.topbar {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 22px;
border-bottom: 1px solid var(--brass-lo);
background: linear-gradient(180deg, #0f1626 0%, var(--bg-deep) 100%);
}
.topbar h1 {
font-size: 16px; margin: 0;
color: var(--brass-hi);
font-weight: 600; letter-spacing: 0.04em;
}
.status-pill {
display: inline-flex; align-items: center; gap: 8px;
padding: 6px 12px; border-radius: 99px;
background: var(--bg-deep); border: 1px solid var(--card-edge);
font-size: 13px;
}
.status-pill .dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text-muted); }
.status-pill.online .dot { background: var(--ok); box-shadow: 0 0 8px var(--ok); }
.status-pill.offline .dot { background: var(--danger); }
.layout {
max-width: 1400px; margin: 22px auto; padding: 0 22px;
display: grid; grid-template-columns: 280px 1fr 280px; gap: 18px;
}
/* Below the 3-column breakpoint: drop the right sidebar to a new full-width row
under the main + left sidebar so cards still get reasonable horizontal space. */
@media (max-width: 1100px) {
.layout { grid-template-columns: 280px 1fr; }
.aside-right { grid-column: 1 / -1; }
}
@media (max-width: 800px) {
.layout { grid-template-columns: 1fr; }
.aside-right { grid-column: 1 / -1; }
}
.card {
background: linear-gradient(180deg, #13192a 0%, #0a0f1a 100%);
border: 1px solid var(--brass-lo);
border-radius: 8px;
padding: 18px;
}
.card + .card { margin-top: 14px; }
.card h2 {
margin: 0 0 14px;
font-size: 12px; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--brass-hi); font-weight: 600;
}
.stat-row { display: flex; justify-content: space-between; margin: 6px 0; font-size: 13px; }
.stat-row .key { color: var(--text-muted); }
.stat-row .val { color: var(--text); font-family: "SF Mono", Consolas, monospace; }
.name-list { list-style: none; padding: 0; margin: 0; }
.name-list li {
padding: 6px 8px; background: var(--bg-deep); border-radius: 4px;
margin-bottom: 4px; font-size: 13px;
display: flex; align-items: center; justify-content: space-between;
}
.name-list li button {
background: transparent; border: 1px solid var(--card-edge);
color: var(--text-muted); padding: 2px 8px; font-size: 11px;
border-radius: 3px; cursor: pointer;
}
.name-list li button:hover { color: var(--danger); border-color: var(--danger); }
.empty-state { color: var(--text-muted); font-size: 13px; padding: 8px 0; font-style: italic; }
.console-pane {
background: #050810;
border: 1px solid var(--card-edge);
border-radius: 4px;
padding: 12px;
font-family: "SF Mono", Consolas, "Cascadia Mono", monospace;
font-size: 12px; line-height: 1.4;
color: #b7c0d6;
height: 480px; overflow-y: auto;
white-space: pre-wrap; word-break: break-word;
}
.console-pane .err { color: #ff8a72; }
.input-row { display: flex; gap: 8px; margin-top: 10px; }
.input-wrap {
flex: 1; position: relative;
background: var(--bg-deep);
border: 1px solid var(--card-edge);
border-radius: 4px;
}
.input-wrap:focus-within { border-color: var(--brass); }
.ghost {
position: absolute; inset: 0;
padding: 8px 12px;
font-family: "SF Mono", Consolas, monospace; font-size: 13px;
line-height: normal;
color: var(--text-muted);
pointer-events: none;
white-space: pre;
overflow: hidden;
opacity: 0.55;
}
.ghost .typed { color: transparent; }
.input-wrap input {
width: 100%; box-sizing: border-box;
background: transparent;
border: none;
color: var(--text);
padding: 8px 12px;
font-family: "SF Mono", Consolas, monospace; font-size: 13px;
position: relative;
}
.input-wrap input:focus { outline: none; }
/* Hide the browser's built-in number-input spinner -- looks out of place against the dark theme */
.input-wrap input[type="number"]::-webkit-inner-spin-button,
.input-wrap input[type="number"]::-webkit-outer-spin-button {
-webkit-appearance: none; margin: 0;
}
.input-wrap input[type="number"] { -moz-appearance: textfield; }
/* Suggestion dropdown -- shown below the command input with multiple matches */
.suggest-list {
position: absolute;
top: calc(100% + 4px);
left: 0; right: 0;
background: var(--bg-deep);
border: 1px solid var(--brass-lo);
border-radius: 4px;
max-height: 220px;
overflow-y: auto;
z-index: 10;
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.4);
display: none;
}
.suggest-list.show { display: block; }
.suggest-item {
padding: 7px 12px;
cursor: pointer;
font-family: "SF Mono", Consolas, monospace;
font-size: 13px;
color: var(--text);
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.suggest-item + .suggest-item {
border-top: 1px solid #1a2436;
}
.suggest-item:hover, .suggest-item.active {
background: var(--card);
color: var(--brass-hi);
}
.suggest-item .args {
color: var(--text-muted);
font-size: 11px;
font-style: italic;
}
.suggest-item .args em { font-style: normal; color: var(--brass); }
.suggest-empty { padding: 8px 12px; color: var(--text-muted); font-size: 12px; font-style: italic; }
.hint {
font-size: 11px; color: var(--text-muted);
padding: 6px 0 0 4px; min-height: 16px;
}
.hint kbd {
background: var(--bg-deep); border: 1px solid var(--card-edge);
padding: 1px 4px; border-radius: 2px; font-size: 10px;
}
button {
background: linear-gradient(180deg, var(--brass-hi) 0%, var(--brass) 50%, var(--brass-lo) 100%);
color: #1a140f;
border: 1px solid var(--brass-lo);
padding: 8px 16px; border-radius: 4px;
font-weight: 600; cursor: pointer; font-size: 13px;
}
button:hover { filter: brightness(1.1); }
button.danger {
background: linear-gradient(180deg, #d65a3e 0%, var(--danger) 50%, #6a2814 100%);
color: #fff;
border-color: #6a2814;
}
button.ghost-btn {
background: var(--bg-deep);
color: var(--text);
border-color: var(--card-edge);
}
.actions { display: flex; gap: 8px; flex-wrap: wrap; }
.footer { color: var(--text-muted); font-size: 11px; text-align: center; padding: 22px; }
/* Modal dialogs (Pregen / Backups / Wipe / etc.) */
.modal {
position: fixed; inset: 0; z-index: 50;
display: flex; align-items: center; justify-content: center;
padding: 20px;
}
.modal[hidden] { display: none; }
.modal-backdrop {
position: absolute; inset: 0;
background: rgba(7, 11, 22, 0.85);
backdrop-filter: blur(2px);
}
.modal-dialog {
position: relative;
background: linear-gradient(180deg, #13192a 0%, #0a0f1a 100%);
border: 1px solid var(--brass-lo);
border-radius: 8px;
padding: 24px;
width: 100%; max-width: 520px;
max-height: 85vh; overflow-y: auto;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.6);
}
.modal-dialog.danger { border-color: #6a2814; }
.modal-header {
display: flex; justify-content: space-between; align-items: center;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--card-edge);
}
.modal-header h2 {
margin: 0;
font-size: 13px; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--brass-hi); font-weight: 600;
}
.modal-dialog.danger .modal-header h2 { color: #d65a3e; }
.modal-close {
background: transparent; border: none;
color: var(--text-muted); font-size: 22px; line-height: 1;
cursor: pointer; padding: 0 4px;
}
.modal-close:hover { color: var(--text); }
/* Trigger button list (the "World" card, etc.) */
.trigger-list {
display: flex; flex-direction: column; gap: 6px;
}
.trigger-list button {
width: 100%; text-align: left;
display: flex; align-items: center; justify-content: space-between;
gap: 8px;
}
.trigger-list .badge {
background: var(--bg-deep); border: 1px solid var(--card-edge);
color: var(--text-muted);
padding: 1px 6px; border-radius: 99px;
font-size: 10px; font-weight: 500;
}
.trigger-list .badge.ok { color: var(--ok); border-color: var(--ok); }
.trigger-list .badge.warn { color: var(--brass-hi); border-color: var(--brass-lo); }
/* Topbar server icon */
.topbar-icon {
height: 28px; width: 28px;
margin-right: 10px;
image-rendering: -webkit-optimize-contrast;
}
.topbar-left {
display: flex; align-items: center;
}
/* Login overlay */
.login-overlay {
position: fixed; inset: 0; z-index: 100;
background: rgba(7, 11, 22, 0.92);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(2px);
}
.login-overlay[hidden] { display: none; }
.login-box {
background: linear-gradient(180deg, #13192a 0%, #0a0f1a 100%);
border: 1px solid var(--brass-lo);
border-radius: 8px;
padding: 28px 32px;
width: 320px;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.6);
}
.login-box h2 {
margin: 0 0 4px;
font-size: 16px; letter-spacing: 0.04em;
color: var(--brass-hi); font-weight: 600;
}
.login-box p {
margin: 0 0 16px; color: var(--text-muted); font-size: 13px;
}
.login-box .input-wrap { margin-bottom: 12px; }
.login-box button { width: 100%; }
.login-error { color: var(--danger); font-size: 12px; min-height: 14px; padding-top: 8px; }
/* Account card */
.acct-form { font-size: 12px; }
.acct-msg { font-size: 12px; min-height: 14px; margin-top: 8px; color: var(--danger); }
.acct-msg.ok { color: var(--ok); }
/* Modpack update card */
#updateCard .update-note {
font-size: 12px; color: var(--text-muted);
margin: 10px 0 0; line-height: 1.4;
}
#updateCard .update-progress { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--card-edge); }
#updateCard .update-phase {
font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--brass-hi); font-weight: 600; margin-bottom: 6px;
}
#updateCard .update-status { font-size: 12px; color: var(--text); margin-top: 6px; }
#updateCard .update-progress button { margin-top: 10px; }
/* Pulsing border highlight when an update is available */
#updateCard.has-update {
border-color: var(--brass);
box-shadow: 0 0 0 1px var(--brass-lo), 0 0 18px rgba(212, 162, 76, 0.18);
}
/* Resources card -- Memory + CPU with progress bars */
.res-block { margin-bottom: 14px; }
.res-block:last-child { margin-bottom: 0; }
.res-label {
display: flex; justify-content: space-between; align-items: baseline;
font-size: 12px; color: var(--text-muted);
margin-bottom: 6px;
}
.res-label .res-val { color: var(--text); font-family: "SF Mono", Consolas, monospace; }
.res-bar {
height: 6px;
background: var(--bg-deep);
border: 1px solid var(--card-edge);
border-radius: 3px;
overflow: hidden;
}
.res-bar > div {
height: 100%; width: 0%;
background: linear-gradient(90deg, var(--brass-lo), var(--brass-hi));
transition: width 0.4s ease;
}
.res-sub {
display: flex; justify-content: space-between;
margin-top: 6px;
font-size: 11px; color: var(--text-muted);
}
.res-sub strong {
color: var(--text); font-family: "SF Mono", Consolas, monospace;
font-weight: 500; margin-left: 4px;
}
/* Collapsible cards (h2 click toggles) */
.card.collapsible .collapsible-toggle {
cursor: pointer;
user-select: none;
display: flex; align-items: center; gap: 8px;
}
.card.collapsible .caret {
display: inline-block;
transition: transform 0.2s ease;
color: var(--brass);
font-size: 12px;
}
.card.collapsible:not(.expanded) .caret { transform: rotate(-90deg); }
.card.collapsible:not(.expanded) .card-body { display: none; }
.card.collapsible:not(.expanded) h2 { margin: 0; }
/* Server settings modal */
.settings-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
margin-bottom: 14px;
}
.settings-grid label {
display: flex; flex-direction: column; gap: 4px;
font-size: 11px; color: var(--text-muted);
}
.settings-grid input, .settings-grid select {
background: var(--bg-deep);
border: 1px solid var(--card-edge);
border-radius: 4px;
color: var(--text);
padding: 6px 8px;
font-family: "SF Mono", Consolas, monospace;
font-size: 12px;
}
.settings-grid input:focus, .settings-grid select:focus {
outline: none; border-color: var(--brass);
}
.settings-grid label:nth-child(1) { grid-column: 1 / -1; } /* MOTD spans both cols */
.settings-checks {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px 14px;
padding-top: 10px;
border-top: 1px solid var(--card-edge);
}
.settings-checks label { font-size: 12px; }
/* Whitelist requests */
.wl-req-label {
font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase;
color: var(--brass-hi); font-weight: 600;
margin: 6px 0 6px 2px;
}
#wlRequests li {
flex-direction: column; align-items: stretch; gap: 6px;
}
.wl-req-meta { font-size: 11px; color: var(--text); }
.wl-req-msg { font-size: 11px; color: var(--text-muted); font-style: italic; }
.wl-req-actions { display: flex; gap: 4px; }
.wl-req-actions button { padding: 3px 8px; font-size: 10px; }
.card h2 .badge { vertical-align: middle; margin-left: 6px; }
/* Backups card */
.backup-item {
display: flex; justify-content: space-between; align-items: center;
gap: 8px; padding: 8px 10px;
flex-wrap: wrap;
}
.backup-meta { flex: 1; min-width: 0; }
.backup-name {
font-family: "SF Mono", Consolas, monospace; font-size: 11px;
color: var(--text); word-break: break-all;
}
.backup-sub { font-size: 10px; color: var(--text-muted); margin-top: 2px; }
.backup-actions { display: flex; gap: 4px; flex-shrink: 0; }
.backup-actions button { font-size: 10px; padding: 3px 8px; }
/* Danger zone card */
.danger-card { border-color: #6a2814; }
.danger-card .collapsible-toggle .caret { color: var(--danger); }
.danger-card h2 { color: #d65a3e; }
.danger-note {
font-size: 12px; color: var(--text-muted);
margin: 0 0 12px; line-height: 1.45;
}
.danger-note code {
background: var(--bg-deep); border: 1px solid var(--card-edge);
padding: 1px 5px; border-radius: 3px; font-size: 11px;
}
.danger-row {
display: flex; align-items: center; gap: 8px;
font-size: 13px; color: var(--text);
cursor: pointer;
}
.danger-row input[type="checkbox"] { margin: 0; }
.danger-section {
margin-top: 12px;
padding: 10px 12px;
border: 1px solid var(--card-edge);
border-radius: 6px;
background: rgba(255, 255, 255, 0.02);
}
.danger-section-title {
font-size: 12px; font-weight: 600;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 8px;
letter-spacing: 0.4px;
}
.danger-section .danger-row { margin: 4px 0; }
.danger-section code {
font-family: var(--mono, monospace);
font-size: 12px;
color: var(--accent, #d4a24c);
}
.danger-section input[type="text"] {
background: var(--input-bg, #0b1220);
color: var(--text);
border: 1px solid var(--card-edge);
border-radius: 4px;
padding: 4px 6px;
font-size: 12px;
font-family: var(--mono, monospace);
}
.danger-section input[type="text"]:disabled { opacity: 0.4; }
/* Pre-generation status panel */
.pg-status {
margin-top: 14px;
padding-top: 12px;
border-top: 1px solid var(--card-edge);
font-size: 12px;
}
.pg-status .stat-row { font-size: 12px; margin: 4px 0; }
.pg-progress-bar {
height: 6px;
background: var(--bg-deep);
border: 1px solid var(--card-edge);
border-radius: 3px;
margin: 8px 0 10px;
overflow: hidden;
}
.pg-progress-bar > div {
height: 100%;
background: linear-gradient(90deg, var(--brass-lo), var(--brass-hi));
width: 0%;
transition: width 0.4s ease;
}
.pg-state-running { color: var(--ok); }
.pg-state-paused { color: var(--brass-hi); }
.pg-state-idle { color: var(--text-muted); }
.pg-state-cancelling { color: var(--danger); }