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,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); }
|
||||
}
|
||||
Reference in New Issue
Block a user