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:
@@ -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 |
@@ -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 & 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 & 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 & 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 & 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>
|
||||
@@ -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 =>
|
||||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[c]));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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 [];
|
||||
}
|
||||
@@ -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 =>
|
||||
({"&":"&","<":"<",">":">",'"':""","'":"'"}[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);
|
||||
}
|
||||
@@ -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.
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"));
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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); }
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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()));
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
@@ -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); }
|
||||
Reference in New Issue
Block a user