- ModStore: mods in data/mods (id, name, version, SHA-256), console "mods [reload]" - ModList is sent before Welcome, files on request in 60 KB parts (once per connection) - ModMessage relayed between players (max 64 KB) - Heartbeat reports the mods to the keymaster (automatic "modded" tag) - Web panel page "Mods": upload, remove, reload (DE/EN) - Tests for list, download, checksum, relay and limits Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1116 lines
60 KiB
JavaScript
1116 lines
60 KiB
JavaScript
"use strict";
|
||
// MSCO Server Panel. Alle Texte (Spielernamen, Log ...) werden nur als Text eingefügt, nie als HTML.
|
||
|
||
const $ = (id) => document.getElementById(id);
|
||
const ROLES = { Moderator: 1, Admin: 2, Owner: 3 };
|
||
const state = { user: null, page: "dashboard", status: null, timers: [], consoleLast: 0, history: [], historyIndex: -1 };
|
||
|
||
// ------------------------------------------------------------------ Hilfen
|
||
|
||
function h(tag, attrs, ...children) {
|
||
const el = document.createElement(tag);
|
||
for (const [k, v] of Object.entries(attrs || {})) {
|
||
if (v === null || v === undefined || v === false) continue;
|
||
if (k === "class") el.className = v;
|
||
else if (k.startsWith("on")) el.addEventListener(k.slice(2), v);
|
||
else el.setAttribute(k, v === true ? "" : v);
|
||
}
|
||
for (const c of children.flat()) {
|
||
if (c === null || c === undefined || c === false) continue;
|
||
el.append(c instanceof Node ? c : document.createTextNode(String(c)));
|
||
}
|
||
return el;
|
||
}
|
||
|
||
async function api(method, path, body) {
|
||
const res = await fetch("api/" + path, {
|
||
method,
|
||
credentials: "same-origin",
|
||
headers: { "Content-Type": "application/json", "X-MSCO-Panel": "1" },
|
||
body: body === undefined ? undefined : JSON.stringify(body),
|
||
});
|
||
let data = null;
|
||
try { data = await res.json(); } catch { /* leer */ }
|
||
if (res.status === 401 && state.user) { state.user = null; showAuth(); }
|
||
if (!res.ok) throw new Error((data && data.error) || "HTTP " + res.status);
|
||
return data;
|
||
}
|
||
|
||
function toast(text, error) {
|
||
const el = h("div", { class: "toast" + (error ? " err" : "") }, text);
|
||
$("toasts").append(el);
|
||
setTimeout(() => el.remove(), error ? 6000 : 3500);
|
||
}
|
||
|
||
async function act(promise) {
|
||
try {
|
||
const r = await promise;
|
||
if (r && r.message) toast(r.message);
|
||
return r;
|
||
} catch (e) {
|
||
toast(e.message, true);
|
||
return null;
|
||
}
|
||
}
|
||
|
||
function can(role) { return state.user && ROLES[state.user.role] >= ROLES[role]; }
|
||
|
||
function duration(seconds) {
|
||
const d = Math.floor(seconds / 86400), hr = Math.floor(seconds % 86400 / 3600), m = Math.floor(seconds % 3600 / 60);
|
||
return d > 0 ? `${d}d ${hr}h` : hr > 0 ? `${hr}h ${m}m` : `${m}m`;
|
||
}
|
||
|
||
function since(iso) {
|
||
if (!iso) return "–";
|
||
const s = Math.max(0, (Date.now() - new Date(iso).getTime()) / 1000);
|
||
return s < 60 ? "just now" : duration(s);
|
||
}
|
||
|
||
function backupDate(name) {
|
||
const m = /^(\d{4})(\d{2})(\d{2})-(\d{2})(\d{2})(\d{2})$/.exec(name);
|
||
return m ? `${m[3]}.${m[2]}.${m[1]} ${m[4]}:${m[5]}:${m[6]}` : name;
|
||
}
|
||
|
||
function every(ms, fn) {
|
||
fn();
|
||
state.timers.push(setInterval(fn, ms));
|
||
}
|
||
|
||
function clearTimers() {
|
||
state.timers.forEach(clearInterval);
|
||
state.timers = [];
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Dialoge
|
||
|
||
function modal({ title, text, danger, fields = [], confirm = "OK", cancel = "Cancel" }) {
|
||
return new Promise((resolve) => {
|
||
const inputs = fields.map((f) => {
|
||
const input = f.options
|
||
? h("select", {}, f.options.map((o) => h("option", { value: o, selected: o === f.value }, o)))
|
||
: h("input", { type: f.type || "text", placeholder: f.placeholder || "", value: f.value || "", maxlength: f.max || 200, autocomplete: "off" });
|
||
return { f, input, row: h("label", {}, f.label, input) };
|
||
});
|
||
const close = (value) => { back.remove(); resolve(value); };
|
||
const form = h("form", { class: "modal" + (danger ? " danger" : "") },
|
||
h("h2", {}, title),
|
||
text ? h("p", {}, text) : null,
|
||
inputs.map((i) => i.row),
|
||
h("div", { class: "buttons" },
|
||
h("button", { class: "btn ghost", type: "button", onclick: () => close(null) }, cancel),
|
||
h("button", { class: "btn " + (danger ? "danger" : "primary"), type: "submit" }, confirm)));
|
||
form.addEventListener("submit", (e) => {
|
||
e.preventDefault();
|
||
const values = {};
|
||
inputs.forEach((i) => { values[i.f.name] = i.input.value; });
|
||
close(values);
|
||
});
|
||
const back = h("div", { class: "modal-back", onclick: (e) => { if (e.target === back) close(null); } }, form);
|
||
back.addEventListener("keydown", (e) => { if (e.key === "Escape") close(null); });
|
||
$("modalRoot").append(back);
|
||
(inputs[0] ? inputs[0].input : form.querySelector("button[type=submit]")).focus();
|
||
});
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Anmeldung
|
||
|
||
let setupMode = false;
|
||
|
||
function showAuth(needsSetup, serverName) {
|
||
clearTimers();
|
||
setupMode = !!needsSetup;
|
||
$("app").classList.add("hidden");
|
||
$("auth").classList.remove("hidden");
|
||
if (serverName) $("authServer").textContent = serverName;
|
||
$("authTitle").textContent = setupMode ? "Set up the panel" : "Sign in";
|
||
$("authHint").textContent = setupMode
|
||
? "Create the owner account. The setup code is shown in the server console (docker logs msco-server)."
|
||
: "";
|
||
$("codeRow").classList.toggle("hidden", !setupMode);
|
||
$("password2Row").classList.toggle("hidden", !setupMode);
|
||
$("password").setAttribute("autocomplete", setupMode ? "new-password" : "current-password");
|
||
$("authButton").textContent = setupMode ? "Create account" : "Sign in";
|
||
$("authError").classList.add("hidden");
|
||
(setupMode ? $("code") : $("username")).focus();
|
||
}
|
||
|
||
$("authForm").addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
const err = $("authError");
|
||
err.classList.add("hidden");
|
||
const username = $("username").value.trim(), password = $("password").value;
|
||
try {
|
||
if (setupMode) {
|
||
if (password !== $("password2").value) throw new Error("The passwords do not match");
|
||
await api("POST", "setup", { code: $("code").value, username, password });
|
||
} else {
|
||
await api("POST", "login", { username, password });
|
||
}
|
||
$("password").value = $("password2").value = "";
|
||
await boot();
|
||
} catch (ex) {
|
||
err.textContent = ex.message;
|
||
err.classList.remove("hidden");
|
||
}
|
||
});
|
||
|
||
$("logout").addEventListener("click", async () => {
|
||
await act(api("POST", "logout"));
|
||
state.user = null;
|
||
boot();
|
||
});
|
||
|
||
$("menuToggle").addEventListener("click", () => document.querySelector(".sidebar").classList.toggle("open"));
|
||
|
||
// ------------------------------------------------------------------ Navigation
|
||
|
||
const PAGES = [
|
||
{ id: "dashboard", label: "Dashboard", icon: "◧", role: "Moderator", group: "Server", render: renderDashboard },
|
||
{ id: "players", label: "Players", icon: "☺", role: "Moderator", group: "Server", render: renderPlayers },
|
||
{ id: "console", label: "Console", icon: "›_", role: "Moderator", group: "Server", render: renderConsole },
|
||
{ id: "admins", label: "Admins", icon: "★", role: "Moderator", group: "Moderation", render: renderAdmins },
|
||
{ id: "bans", label: "Bans", icon: "⊘", role: "Moderator", group: "Moderation", render: renderBans },
|
||
{ id: "world", label: "World & Backups", icon: "⟲", role: "Admin", group: "Management", render: renderWorld },
|
||
{ id: "mods", label: "Mods", icon: "⧉", role: "Admin", group: "Management", render: renderMods },
|
||
{ id: "settings", label: "Settings", icon: "⚙", role: "Admin", group: "Management", render: renderSettings },
|
||
{ id: "update", label: "Update", icon: "⇪", role: "Admin", group: "Management", render: renderUpdate },
|
||
{ id: "users", label: "Panel Users", icon: "⚿", role: "Owner", group: "Management", render: renderUsers },
|
||
{ id: "account", label: "My Account", icon: "☻", role: "Moderator", group: null, render: renderAccount },
|
||
];
|
||
|
||
function renderNav() {
|
||
const nav = $("nav");
|
||
const items = [];
|
||
let group;
|
||
for (const p of PAGES.filter((x) => can(x.role) && x.group)) {
|
||
if (p.group !== group) { group = p.group; items.push(h("div", { class: "nav-group" }, group)); }
|
||
items.push(h("button", { type: "button", class: p.id === state.page ? "active" : "", onclick: () => go(p.id) },
|
||
h("span", { class: "ico" }, p.icon), h("span", { class: "nav-label" }, p.label),
|
||
p.id === "update" && state.updateAvailable ? h("span", { class: "nav-dot", title: "Update available", "aria-label": "Update available" }) : null));
|
||
}
|
||
nav.replaceChildren(...items);
|
||
$("meButton").classList.toggle("active", state.page === "account");
|
||
}
|
||
|
||
$("meButton").addEventListener("click", () => go("account"));
|
||
|
||
function go(pageId) {
|
||
const p = PAGES.find((x) => x.id === pageId && can(x.role)) || PAGES[0];
|
||
state.page = p.id;
|
||
try { localStorage.setItem("msco-panel-page", p.id); } catch { /* egal */ }
|
||
document.querySelector(".sidebar").classList.remove("open");
|
||
clearTimers();
|
||
every(5000, refreshTopbar);
|
||
every(30000, refreshUpdateState);
|
||
renderNav();
|
||
const page = $("page");
|
||
page.replaceChildren();
|
||
p.render(page);
|
||
}
|
||
|
||
async function refreshTopbar() {
|
||
try {
|
||
const s = await api("GET", "status");
|
||
state.status = s;
|
||
$("serverName").textContent = s.name;
|
||
const running = s.state === "running";
|
||
$("serverMeta").textContent = running
|
||
? `${s.players}/${s.maxPlayers} players · v${s.version} · up ${duration(s.uptimeSeconds)}`
|
||
: `server ${s.state} · v${s.version}`;
|
||
$("statusDot").className = "status-dot " + (running ? "ok" : s.state === "error" ? "bad" : "");
|
||
$("scName").textContent = s.name;
|
||
$("scState").textContent = running ? `${s.players} / ${s.maxPlayers} players online` : "Server " + s.state;
|
||
$("scDot").className = "sc-dot " + (running ? "ok" : s.state === "error" ? "bad" : "");
|
||
$("statusDot").title = running ? "Online" : "Server " + s.state + (s.lastError ? ": " + s.lastError : "");
|
||
document.title = `${s.name} · MSCO Panel`;
|
||
} catch {
|
||
$("statusDot").className = "status-dot bad";
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Dashboard
|
||
|
||
function renderDashboard(page) {
|
||
const cards = h("div", { class: "cards" });
|
||
const control = h("div", { class: "section-body control" });
|
||
if (can("Admin")) page.append(h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Server"), h("span", { class: "state-label", id: "gameState" }, "")),
|
||
control));
|
||
const recent = h("div", { class: "console small" });
|
||
page.append(
|
||
cards,
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Recent activity"),
|
||
h("button", { class: "btn small", type: "button", onclick: () => go("console") }, "Open console")),
|
||
recent));
|
||
|
||
const card = (label, value, sub) => h("div", { class: "card" },
|
||
h("div", { class: "stat-label" }, label), h("div", { class: "stat-value" }, value), sub ? h("div", { class: "stat-sub" }, sub) : null);
|
||
|
||
every(4000, async () => {
|
||
try {
|
||
const s = await api("GET", "status");
|
||
if (can("Admin")) renderControl(control, s);
|
||
cards.replaceChildren(
|
||
card("Players", `${s.players} / ${s.maxPlayers}`, s.isPublic ? "listed in the server browser" : "not listed (private)"),
|
||
card("Uptime", duration(s.uptimeSeconds), "version " + s.version),
|
||
card("World", s.objects.toLocaleString(), `moved objects · ${s.actions} part actions · ${s.boughtItems} bought items`),
|
||
card("Save game", s.saveSyncMinutes > 0 ? `every ${s.saveSyncMinutes} min` : "on join",
|
||
s.resetPoint ? "reset point from " + new Date(s.resetPoint).toLocaleString(window.MSCO_LANG === "de" ? "de-DE" : "en-GB") : "no reset point yet"),
|
||
card("Backups", s.backups, "save game backups"),
|
||
card("Admins / Bans", `${s.admins} / ${s.bans}`, s.steamVerification ? "Steam verification on" : "Steam verification OFF (no Web API key)"));
|
||
} catch (e) { cards.replaceChildren(h("div", { class: "card" }, e.message)); }
|
||
});
|
||
every(3000, async () => {
|
||
try {
|
||
const r = await api("GET", "console?after=0");
|
||
recent.replaceChildren(...r.lines.slice(-40).map(consoleLine));
|
||
recent.scrollTop = recent.scrollHeight;
|
||
} catch { /* Topbar zeigt den Fehler */ }
|
||
});
|
||
}
|
||
|
||
async function serverAction(action, confirmText) {
|
||
if (confirmText) {
|
||
const ok = await modal(confirmText);
|
||
if (!ok) return;
|
||
}
|
||
await act(api("POST", "server/" + action));
|
||
setTimeout(refreshTopbar, 800);
|
||
setTimeout(refreshTopbar, 2500);
|
||
}
|
||
|
||
const STATE_TEXT = {
|
||
running: "Running - players can join",
|
||
starting: "Starting …",
|
||
stopped: "Stopped - players cannot join. The panel keeps running.",
|
||
error: "Not running",
|
||
};
|
||
|
||
function renderControl(el, s) {
|
||
const label = $("gameState");
|
||
const running = s.state === "running", starting = s.state === "starting";
|
||
if (label) {
|
||
label.textContent = STATE_TEXT[s.state] || s.state;
|
||
label.className = "state-label " + s.state;
|
||
}
|
||
const buttons = h("div", { class: "row" },
|
||
running || starting
|
||
? h("button", { class: "btn", type: "button", disabled: starting, onclick: () => serverAction("stop", { title: "Stop the server?",
|
||
text: "All players are disconnected and nobody can join until you start it again. The panel keeps running.", danger: true, confirm: "Stop" }) }, "■ Stop")
|
||
: h("button", { class: "btn primary", type: "button", onclick: () => serverAction("start") }, "▶ Start"),
|
||
h("button", { class: "btn", type: "button", disabled: starting, onclick: () => serverAction("restart", { title: "Restart the server?",
|
||
text: "Settings (server.json), license and save game are loaded again. Players reconnect automatically.", confirm: "Restart" }) }, "⟳ Restart"));
|
||
|
||
// Warum läuft er nicht? Mit direktem Weg zur Lösung
|
||
let problem = null;
|
||
if (!running && s.lastError) {
|
||
const fix = s.hasLicenseKey === false ? h("button", { class: "btn small", type: "button", onclick: () => go("settings") }, "Enter license key")
|
||
: s.hasSave === false ? h("button", { class: "btn small", type: "button", onclick: () => go("world") }, "Upload save game")
|
||
: h("button", { class: "btn small ghost", type: "button", onclick: () => go("console") }, "Open console");
|
||
problem = h("div", { class: "notice " + (s.state === "error" ? "bad" : "") }, h("div", {}, h("b", {}, s.state === "error" ? "The server could not start" : "Note"), h("div", {}, s.lastError)), fix);
|
||
}
|
||
// Lizenz gerade nicht bestätigt (Master aus o.ä.) - der Server läuft weiter, aber nur noch eine Weile
|
||
const license = running && s.licenseWarning
|
||
? h("div", { class: "notice" }, h("div", {}, h("b", {}, "License not confirmed"), h("div", {}, s.licenseWarning))) : null;
|
||
el.replaceChildren(...[buttons, problem, license].filter(Boolean)); // null würde sonst als Text "null" erscheinen
|
||
}
|
||
|
||
/** Panel neu starten (Port, Unterpfad, Proxy übernehmen) und danach die (ggf. neue) Adresse laden. */
|
||
async function restartPanel() {
|
||
const ok = await modal({ title: "Restart the panel?",
|
||
text: "Applies the saved port and panel settings. If the game port changed, the game server restarts too. You stay logged in.",
|
||
confirm: "Restart panel" });
|
||
if (!ok) return;
|
||
const r = await act(api("POST", "server/restart-panel"));
|
||
if (!r) return;
|
||
if (r.disabled) {
|
||
// Panel ausgeschaltet: nicht auf ein Neuladen warten
|
||
document.body.replaceChildren(h("div", { class: "empty" }, r.message));
|
||
return;
|
||
}
|
||
const target = r.url && r.url !== location.origin + location.pathname ? r.url : location.href;
|
||
// Warten, bis das Panel wieder antwortet, dann neu laden
|
||
const started = Date.now();
|
||
const tryReload = async () => {
|
||
try {
|
||
const res = await fetch(new URL("api/session", target).href, { credentials: "include", mode: target.startsWith(location.origin) ? "same-origin" : "no-cors" });
|
||
if (res.ok || res.type === "opaque") { location.href = target; return; }
|
||
} catch { /* noch nicht da */ }
|
||
if (Date.now() - started < 30000) setTimeout(tryReload, 1000);
|
||
else location.href = target;
|
||
};
|
||
setTimeout(tryReload, 2500);
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Einstellungen
|
||
|
||
function renderSettings(page) {
|
||
const box = h("div", { class: "grid-2" });
|
||
page.append(box);
|
||
load();
|
||
|
||
async function load() {
|
||
let c;
|
||
try { c = await api("GET", "config"); } catch (e) { toast(e.message, true); return; }
|
||
const env = c.environmentOverrides || [];
|
||
const fields = {};
|
||
const envNote = (name) => env.some((e) => e.replace(/_/g, "").includes(name.toUpperCase().replace("ISPUBLIC", "PUBLIC")))
|
||
? h("span", { class: "pill gray", title: "Also set by an environment variable (.env) - that value wins on restart" }, "ENV") : null;
|
||
const text = (name, label, value, opts = {}) => {
|
||
const input = h("input", { value: value ?? "", maxlength: opts.max || 100, placeholder: opts.placeholder || "", type: opts.type || "text", autocomplete: "off" });
|
||
fields[name] = () => input.value;
|
||
return h("label", {}, h("span", { class: "label-row" }, label, envNote(name), opts.restart ? h("span", { class: "pill gray" }, "restart") : null), input,
|
||
opts.hint ? h("small", { class: "hint" }, opts.hint) : null);
|
||
};
|
||
const number = (name, label, value, opts = {}) => {
|
||
const input = h("input", { type: "number", value, min: opts.min, max: opts.max });
|
||
fields[name] = () => parseInt(input.value, 10);
|
||
return h("label", {}, h("span", { class: "label-row" }, label, envNote(name), opts.restart ? h("span", { class: "pill gray" }, "restart") : null), input,
|
||
opts.hint ? h("small", { class: "hint" }, opts.hint) : null);
|
||
};
|
||
const toggle = (name, label, value, hint, restart) => {
|
||
const input = h("input", { type: "checkbox", checked: value });
|
||
fields[name] = () => input.checked;
|
||
return h("label", { class: "toggle" }, input, h("span", {},
|
||
h("span", { class: "label-row" }, label, envNote(name), restart ? h("span", { class: "pill gray" }, "restart") : null),
|
||
hint ? h("small", { class: "hint" }, hint) : null));
|
||
};
|
||
const secret = (name, label, isSet, link) => {
|
||
const input = h("input", { type: "password", placeholder: isSet ? "•••••••• (set - leave empty to keep)" : "not set", autocomplete: "new-password", disabled: !can("Owner") });
|
||
fields[name] = () => input.value;
|
||
return h("label", {}, h("span", { class: "label-row" }, label, envNote(name), h("span", { class: "pill gray" }, "restart")), input,
|
||
link ? h("small", { class: "hint" }, link) : null);
|
||
};
|
||
|
||
const save = async () => {
|
||
const body = {};
|
||
for (const [k, get] of Object.entries(fields)) {
|
||
const v = get();
|
||
if (typeof v === "number" && isNaN(v)) continue;
|
||
body[k] = v;
|
||
}
|
||
// Port geändert? Vorher warnen - sonst sperrt man sich aus (vor allem in Docker, wo der Port nicht freigegeben ist)
|
||
const warning = portChangeWarning(c, body);
|
||
if (warning && !(await modal({ title: warning.title, text: warning.text, confirm: warning.confirm }))) return;
|
||
if (await act(api("POST", "config", body))) load();
|
||
};
|
||
|
||
box.replaceChildren(
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "General"), h("span", { class: "muted" }, "applies immediately")),
|
||
h("div", { class: "section-body form-grid wide" },
|
||
text("name", "Server name", c.name, { max: 60 }),
|
||
text("description", "Description", c.description, { max: 300 }),
|
||
text("tags", "Tags", c.tags, { max: 140, placeholder: "e.g. german, roleplay, vanilla",
|
||
hint: "Shown in the server list, comma separated (max. 6). The tag official is only given by the MSCO admins." }),
|
||
number("maxPlayers", "Max players", c.maxPlayers, { min: 1, max: 64 }),
|
||
text("publicAddress", "Public address", c.publicAddress, { max: 200, placeholder: "automatic", hint: "IP or domain players connect to (empty = automatic)" }),
|
||
toggle("isPublic", "Listed in the server browser", c.isPublic),
|
||
toggle("requireSteam", "Steam required", c.requireSteam, "Off = players without Steam can join too"))),
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Saving")),
|
||
h("div", { class: "section-body form-grid wide" },
|
||
number("saveSyncMinutes", "Take the save game from a player every … minutes", c.saveSyncMinutes, { min: 0, max: 240, hint: "0 = only when someone joins" }),
|
||
number("autosaveSeconds", "Write the world state every … seconds", c.autosaveSeconds, { min: 10, max: 3600 }))),
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Network & panel"), h("span", { class: "muted" }, "needs a restart")),
|
||
h("div", { class: "section-body form-grid wide" },
|
||
number("port", "Game port", c.port, { min: 1, max: 65535, restart: true }),
|
||
number("panelPort", "Panel port", c.panelPort, { min: 0, max: 65535, restart: true,
|
||
hint: c.inDocker ? "0 = same port as the game (recommended). Docker: another port must also be published in docker-compose.yml" : "0 = same port as the game (recommended)" }),
|
||
text("panelBindAddress", "Panel bind address", c.panelBindAddress, { max: 60, restart: true, hint: "only with an own panel port - 127.0.0.1 = only via a local reverse proxy" }),
|
||
text("panelBasePath", "Panel sub path", c.panelBasePath, { max: 60, restart: true, placeholder: "/", hint: "e.g. /panel when the proxy serves it there" }),
|
||
toggle("panelTrustProxy", "Behind a reverse proxy", c.panelTrustProxy, "Trust X-Forwarded-For / -Proto / -Prefix", true),
|
||
toggle("panelEnabled", "Web panel enabled", c.panelEnabled, "Turning it off locks you out of this panel after the next program start", true))),
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Keys"), h("span", { class: "muted" }, can("Owner") ? "only owners can change keys" : "owner only")),
|
||
h("div", { class: "section-body form-grid wide" },
|
||
secret("licenseKey", "License key", c.licenseKeySet),
|
||
secret("steamWebApiKey", "Steam Web API key", c.steamWebApiKeySet,
|
||
h("span", {}, "Needed for admins (verifies Steam accounts). ",
|
||
h("a", { href: "https://steamcommunity.com/dev/apikey", target: "_blank", rel: "noopener noreferrer" }, "Get your free key on Steam ↗"))))),
|
||
h("div", { class: "row" }, h("button", { class: "btn primary", type: "button", onclick: save }, "Save settings"),
|
||
h("button", { class: "btn", type: "button", onclick: () => serverAction("restart", { title: "Restart / start the server now?",
|
||
text: "Loads the saved settings. Players reconnect automatically.", confirm: "Restart" }) }, "Restart to apply"),
|
||
h("button", { class: "btn", type: "button", onclick: restartPanel }, "⟳ Restart panel")));
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Warnung, wenn Spiel- oder Panel-Port geändert werden: Ports übernimmt "Panel neu starten" (oder ein Programmneustart),
|
||
* danach ist das Panel nur noch am neuen Port - in Docker nur,
|
||
* wenn der Port in docker-compose.yml freigegeben ist. Text direkt in beiden Sprachen (enthält Zahlen).
|
||
*/
|
||
function portChangeWarning(c, body) {
|
||
const panelAt = (port, panelPort) => (panelPort > 0 ? panelPort : port);
|
||
const port = body.port ?? c.port, panelPort = body.panelPort ?? c.panelPort;
|
||
const oldPanel = panelAt(c.port, c.panelPort), newPanel = panelAt(port, panelPort);
|
||
if (port === c.port && newPanel === oldPanel) return null;
|
||
const de = window.MSCO_LANG === "de";
|
||
const lines = [];
|
||
if (newPanel !== oldPanel)
|
||
lines.push(de
|
||
? `Das Panel ist danach nur noch unter Port ${newPanel} erreichbar (http://<server>:${newPanel}/), nicht mehr unter ${oldPanel}.`
|
||
: `Afterwards the panel is only reachable on port ${newPanel} (http://<server>:${newPanel}/), no longer on ${oldPanel}.`);
|
||
if (port !== c.port)
|
||
lines.push(de ? `Spieler verbinden sich dann über Port ${port}.` : `Players then connect on port ${port}.`);
|
||
lines.push(de
|
||
? "Das gilt, sobald du unten „Panel neu starten“ drückst (oder das Programm neu startest)."
|
||
: "It applies as soon as you press “Restart panel” at the bottom (or restart the program).");
|
||
const ports = [...new Set([port, newPanel])].map((p) => `"${p}:${p}/tcp"`).join(", ");
|
||
lines.push(c.inDocker
|
||
? (de ? `Docker: trag vorher unter ports in docker-compose.yml ${ports} ein, sonst sperrst du dich aus.`
|
||
: `Docker: first add ${ports} under ports in docker-compose.yml, otherwise you lock yourself out.`)
|
||
: (de ? "Gib den Port in der Firewall frei." : "Open the port in your firewall."));
|
||
lines.push(de
|
||
? 'Rückgängig: in data/server.json "PanelPort": 0 setzen und neu starten.'
|
||
: 'To undo: set "PanelPort": 0 in data/server.json and restart.');
|
||
return { title: de ? "Port ändern?" : "Change the port?", text: lines.join(" "), confirm: de ? "Trotzdem speichern" : "Save anyway" };
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Spieler
|
||
|
||
function renderPlayers(page) {
|
||
const tbody = h("tbody");
|
||
const count = h("span", { class: "pill gray" }, "…");
|
||
const say = h("input", { placeholder: "Message to all players…", maxlength: 200 });
|
||
const sendSay = async () => {
|
||
if (!say.value.trim()) return;
|
||
if (await act(api("POST", "say", { text: say.value }))) say.value = "";
|
||
};
|
||
say.addEventListener("keydown", (e) => { if (e.key === "Enter") sendSay(); });
|
||
|
||
page.append(
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Players"), count),
|
||
h("div", { class: "table-wrap" },
|
||
h("table", {},
|
||
h("thead", {}, h("tr", {},
|
||
h("th", {}, "ID"), h("th", {}, "Name"), h("th", {}, "SteamID"),
|
||
can("Admin") ? h("th", {}, "Address") : null, h("th", {}, "Online"), h("th", {}, ""))),
|
||
tbody))),
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Server message")),
|
||
h("div", { class: "section-body row" }, say, h("button", { class: "btn primary", type: "button", onclick: sendSay }, "Send"))));
|
||
|
||
every(3000, async () => {
|
||
try {
|
||
const players = await api("GET", "players");
|
||
count.textContent = players.length + (players.length === 1 ? " online" : " online");
|
||
if (players.length === 0) {
|
||
tbody.replaceChildren(h("tr", {}, h("td", { colspan: 6, class: "empty" }, "Nobody is online right now.")));
|
||
return;
|
||
}
|
||
tbody.replaceChildren(...players.map(playerRow));
|
||
} catch (e) { toast(e.message, true); }
|
||
});
|
||
}
|
||
|
||
function playerRow(p) {
|
||
const steam = /^\d{17}$/.test(p.steamId)
|
||
? h("a", { href: "https://steamcommunity.com/profiles/" + p.steamId, target: "_blank", rel: "noopener noreferrer", class: "mono" }, p.steamId)
|
||
: h("span", { class: "mono muted" }, p.steamId);
|
||
const actions = h("div", { class: "actions" },
|
||
h("button", { class: "btn small", type: "button", onclick: () => kick(p, false) }, "Kick"),
|
||
can("Admin") ? h("button", { class: "btn small danger", type: "button", onclick: () => kick(p, true) }, "Ban") : null,
|
||
can("Admin") && p.verified
|
||
? h("button", { class: "btn small ghost", type: "button", onclick: () => setAdmin(p, !p.admin) }, p.admin ? "Remove admin" : "Make admin")
|
||
: null);
|
||
return h("tr", {},
|
||
h("td", { class: "id" }, "#" + p.id),
|
||
h("td", {}, h("span", { class: "player-name" }, p.name,
|
||
p.admin ? h("span", { class: "pill" }, "ADMIN") : null,
|
||
p.verified ? null : h("span", { class: "pill gray", title: "SteamID not verified by Steam" }, "unverified"))),
|
||
h("td", {}, steam),
|
||
can("Admin") ? h("td", { class: "mono muted" }, p.address || "") : null,
|
||
h("td", { class: "muted" }, since(p.joinedAt)),
|
||
h("td", {}, actions));
|
||
}
|
||
|
||
async function kick(p, ban) {
|
||
const v = await modal({
|
||
title: (ban ? "Ban " : "Kick ") + p.name,
|
||
text: ban ? "The player is kicked and cannot join again until you unban them." : "The player sees the reason and can join again.",
|
||
danger: true,
|
||
fields: [{ name: "reason", label: "Reason (shown to the player)", placeholder: "optional", max: 100 }],
|
||
confirm: ban ? "Ban" : "Kick",
|
||
});
|
||
if (v) await act(api("POST", `players/${p.id}/${ban ? "ban" : "kick"}`, { reason: v.reason }));
|
||
}
|
||
|
||
async function setAdmin(p, admin) {
|
||
const ok = await modal({ title: (admin ? "Make " : "Remove admin: ") + p.name + (admin ? " an admin?" : ""),
|
||
text: admin ? "In-game admins can kick, ban, spawn items and reset the world." : "", confirm: admin ? "Make admin" : "Remove" });
|
||
if (ok) await act(api("POST", `players/${p.id}/${admin ? "op" : "deop"}`));
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Konsole
|
||
|
||
function consoleLine(l) {
|
||
const t = l.text;
|
||
const cls = t.startsWith("[chat]") ? "l-chat" : t.startsWith("[panel]") ? "l-panel" : t.startsWith(">") ? "l-cmd"
|
||
: /error|failed|could not|cannot|warning|not reachable/i.test(t) ? "l-warn" : "";
|
||
return h("div", { class: cls }, h("span", { class: "t" }, l.time), t);
|
||
}
|
||
|
||
function renderConsole(page) {
|
||
const out = h("div", { class: "console" });
|
||
const input = h("input", { placeholder: can("Admin") ? "Command… (help for a list)" : "Only admins can enter commands", disabled: !can("Admin"), autocomplete: "off", spellcheck: "false" });
|
||
const send = async () => {
|
||
const command = input.value.trim();
|
||
if (!command) return;
|
||
state.history.push(command);
|
||
state.historyIndex = state.history.length;
|
||
input.value = "";
|
||
await act(api("POST", "console", { command }).then(() => null)); // Ausgabe erscheint im Log
|
||
poll();
|
||
};
|
||
input.addEventListener("keydown", (e) => {
|
||
if (e.key === "Enter") send();
|
||
else if (e.key === "ArrowUp" && state.history.length) {
|
||
state.historyIndex = Math.max(0, state.historyIndex - 1);
|
||
input.value = state.history[state.historyIndex] || "";
|
||
e.preventDefault();
|
||
} else if (e.key === "ArrowDown" && state.history.length) {
|
||
state.historyIndex = Math.min(state.history.length, state.historyIndex + 1);
|
||
input.value = state.history[state.historyIndex] || "";
|
||
e.preventDefault();
|
||
}
|
||
});
|
||
|
||
page.append(h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Live console"), h("span", { class: "muted" }, "chat, joins, admin actions and command output")),
|
||
out,
|
||
h("div", { class: "console-input" }, input, h("button", { class: "btn primary", type: "button", onclick: send, disabled: !can("Admin") }, "Run"))));
|
||
|
||
let last = 0;
|
||
async function poll() {
|
||
try {
|
||
const r = await api("GET", "console?after=" + last);
|
||
if (!r.lines.length) return;
|
||
const stick = out.scrollTop + out.clientHeight >= out.scrollHeight - 30;
|
||
out.append(...r.lines.map(consoleLine));
|
||
while (out.childElementCount > 2000) out.firstChild.remove();
|
||
last = r.last;
|
||
if (stick) out.scrollTop = out.scrollHeight;
|
||
} catch { /* Topbar zeigt den Fehler */ }
|
||
}
|
||
every(1000, poll);
|
||
if (can("Admin")) input.focus();
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Ingame-Admins
|
||
|
||
function renderAdmins(page) {
|
||
const list = h("tbody");
|
||
const warning = h("div");
|
||
const id = h("input", { placeholder: "SteamID64 (17 digits)", maxlength: 17, inputmode: "numeric" });
|
||
const add = async () => {
|
||
if (!/^\d{17}$/.test(id.value.trim())) { toast("Enter a SteamID64 (17 digits)", true); return; }
|
||
if (await act(api("POST", "admins/add", { id: id.value.trim() }))) { id.value = ""; load(); }
|
||
};
|
||
id.addEventListener("keydown", (e) => { if (e.key === "Enter") add(); });
|
||
page.append(
|
||
warning,
|
||
can("Admin") ? h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Add an admin"), h("span", { class: "muted" }, "also works when the player is offline")),
|
||
h("div", { class: "section-body row" }, id, h("button", { class: "btn primary", type: "button", onclick: add }, "Add admin")),
|
||
h("div", { class: "section-body muted small-text" }, "Find a SteamID64 on the player's Steam profile or with ",
|
||
h("a", { href: "https://steamid.io", target: "_blank", rel: "noopener noreferrer" }, "steamid.io ↗"),
|
||
". Online players can also be made admin on the Players page.")) : "",
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "In-game admins"), h("span", { class: "muted" }, "can use /admin in the game: kick, ban, teleport, items, world")),
|
||
h("div", { class: "table-wrap" }, h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, "SteamID"), h("th", {}, "Player"), h("th", {}, ""))), list))));
|
||
|
||
async function load() {
|
||
try {
|
||
const r = await api("GET", "admins");
|
||
warning.replaceChildren(r.steamVerification ? "" : h("div", { class: "notice bad" },
|
||
h("div", {}, h("b", {}, "Admin rights are disabled"),
|
||
h("div", {}, "Without a Steam Web API key the server cannot verify Steam accounts - anyone could pretend to be an admin.")),
|
||
h("div", { class: "row" },
|
||
h("a", { class: "btn small primary", href: "https://steamcommunity.com/dev/apikey", target: "_blank", rel: "noopener noreferrer" }, "Get a Steam key ↗"),
|
||
can("Owner") ? h("button", { class: "btn small", type: "button", onclick: () => go("settings") }, "Enter it in Settings") : null)));
|
||
if (!r.admins.length) { list.replaceChildren(h("tr", {}, h("td", { colspan: 3, class: "empty" }, "No admins yet."))); return; }
|
||
list.replaceChildren(...r.admins.map((a) => h("tr", {},
|
||
h("td", {}, h("a", { href: "https://steamcommunity.com/profiles/" + a.steamId, target: "_blank", rel: "noopener noreferrer", class: "mono" }, a.steamId)),
|
||
h("td", {}, a.online ? h("span", { class: "player-name" }, a.name, h("span", { class: "pill green" }, "online")) : h("span", { class: "muted" }, "offline")),
|
||
h("td", {}, can("Admin") ? h("div", { class: "actions" }, h("button", { class: "btn small danger", type: "button", onclick: async () => {
|
||
const ok = await modal({ title: "Remove admin " + (a.name || a.steamId) + "?", danger: true, confirm: "Remove" });
|
||
if (ok && await act(api("POST", "admins/remove", { id: a.steamId }))) load();
|
||
} }, "Remove")) : null))));
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
every(10000, load);
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Banns
|
||
|
||
function renderBans(page) {
|
||
const list = h("tbody");
|
||
const id = h("input", { placeholder: "SteamID64 (17 digits) or name:playername", maxlength: 60 });
|
||
page.append(
|
||
can("Admin") ? h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Ban a SteamID"), h("span", { class: "muted" }, "also works when the player is offline")),
|
||
h("div", { class: "section-body row" }, id,
|
||
h("button", { class: "btn danger", type: "button", onclick: async () => {
|
||
if (!id.value.trim()) return;
|
||
if (await act(api("POST", "bans/add", { id: id.value.trim() }))) { id.value = ""; load(); }
|
||
} }, "Ban"))) : "",
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Banned players")),
|
||
h("div", { class: "table-wrap" }, h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, "Identity"), h("th", {}, ""))), list))));
|
||
|
||
async function load() {
|
||
try {
|
||
const bans = await api("GET", "bans");
|
||
if (!bans.length) { list.replaceChildren(h("tr", {}, h("td", { colspan: 2, class: "empty" }, "Nobody is banned."))); return; }
|
||
list.replaceChildren(...bans.map((b) => h("tr", {},
|
||
h("td", {}, /^\d{17}$/.test(b)
|
||
? h("a", { href: "https://steamcommunity.com/profiles/" + b, target: "_blank", rel: "noopener noreferrer", class: "mono" }, b)
|
||
: h("span", { class: "mono" }, b.replace(/^name:/, "") + " (no Steam)")),
|
||
h("td", {}, can("Admin") ? h("div", { class: "actions" }, h("button", { class: "btn small", type: "button", onclick: async () => {
|
||
if (await act(api("POST", "bans/remove", { id: b }))) load();
|
||
} }, "Unban")) : null))));
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
every(10000, load);
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Welt & Backups
|
||
|
||
function renderWorld(page) {
|
||
const backups = h("tbody");
|
||
const zipInput = h("input", { type: "file", accept: ".zip,.rar,.7z,application/zip,application/x-rar-compressed,application/vnd.rar,application/x-7z-compressed" });
|
||
const uploadZip = async () => {
|
||
const file = zipInput.files && zipInput.files[0];
|
||
if (!file) { toast("Choose a ZIP, RAR or 7z file first", true); return; }
|
||
const ok = await modal({ title: "Upload " + file.name + "?", danger: true, confirm: "Upload",
|
||
text: "The current save game is backed up, then replaced. All changes in the world are reset and everyone reconnects." });
|
||
if (!ok) return;
|
||
try {
|
||
const res = await fetch("api/save/upload", { method: "POST", credentials: "same-origin",
|
||
headers: { "Content-Type": "application/octet-stream", "X-MSCO-Panel": "1" }, body: file });
|
||
const data = await res.json().catch(() => null);
|
||
if (!res.ok) throw new Error((data && data.error) || "HTTP " + res.status);
|
||
toast(data.message);
|
||
zipInput.value = "";
|
||
loadBackups();
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
const worldAction = async (action, confirmText, name) => {
|
||
if (confirmText) {
|
||
const ok = await modal({ title: confirmText.title, text: confirmText.text, danger: confirmText.danger, confirm: confirmText.button });
|
||
if (!ok) return;
|
||
}
|
||
await act(api("POST", "world/" + action, name ? { name } : {}));
|
||
loadBackups();
|
||
};
|
||
|
||
page.append(
|
||
h("div", { class: "cards wide" },
|
||
h("div", { class: "card" },
|
||
h("div", { class: "stat-label" }, "Reset world"),
|
||
h("p", { class: "muted" }, "Everything goes back to the reset point (the save game as it was uploaded to the server). The current state is backed up first. Everyone reconnects."),
|
||
h("button", { class: "btn danger", type: "button", onclick: () => worldAction("reset", { title: "Reset the whole world?", text: "Moved objects, bought items, doors, vehicle values and player positions are reset to the reset point. The current state is kept as a backup.", danger: true, button: "Reset world" }) }, "Reset world")),
|
||
h("div", { class: "card" },
|
||
h("div", { class: "stat-label" }, "New game"),
|
||
h("p", { class: "muted" }, "Start a completely fresh My Summer Car game instead of an uploaded save. The current save game is backed up first. The first player to join creates the new world."),
|
||
h("button", { class: "btn danger", type: "button", onclick: () => worldAction("newgame", { title: "Start a new game?", text: "The current save game and all changes in the world are replaced by a fresh new game (the current state is kept as a backup). “Reset world” then also goes back to a new game. Everyone reconnects.", danger: true, button: "Start new game" }) }, "Start new game")),
|
||
h("div", { class: "card" },
|
||
h("div", { class: "stat-label" }, "Backup"),
|
||
h("p", { class: "muted" }, "Save a copy of the current server save game. The last 5 backups are kept."),
|
||
h("button", { class: "btn primary", type: "button", onclick: () => worldAction("backup") }, "Backup now")),
|
||
h("div", { class: "card" },
|
||
h("div", { class: "stat-label" }, "Reset point"),
|
||
h("p", { class: "muted" }, "Use the current save game as the new reset point for “Reset world”."),
|
||
h("button", { class: "btn", type: "button", onclick: () => worldAction("resetpoint", { title: "Set a new reset point?", text: "“Reset world” will go back to the save game as it is right now.", button: "Set reset point" }) }, "Set reset point")),
|
||
h("div", { class: "card" },
|
||
h("div", { class: "stat-label" }, "Upload save game"),
|
||
h("p", { class: "muted" }, "Pack the files from AppData\\LocalLow\\Amistech\\My Summer Car as ZIP, RAR or 7z. They replace the server save and become the reset point. Everyone reconnects."),
|
||
h("div", { class: "row" }, zipInput,
|
||
h("button", { class: "btn primary", type: "button", onclick: uploadZip }, "Upload"),
|
||
h("a", { class: "btn ghost", href: "api/save/download" }, "Download current"))),
|
||
h("div", { class: "card" },
|
||
h("div", { class: "stat-label" }, "Save game files"),
|
||
h("p", { class: "muted" }, "Copied new save files into data/save? Load them (they also become the reset point)."),
|
||
h("div", { class: "row" },
|
||
h("button", { class: "btn", type: "button", onclick: () => worldAction("reloadsave", { title: "Load the save game from data/save?", text: "Everyone reconnects and gets this save game.", button: "Load" }) }, "Reload save"),
|
||
h("button", { class: "btn ghost", type: "button", onclick: () => worldAction("save") }, "Save world state")))),
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Save game backups"), h("span", { class: "muted" }, "newest first")),
|
||
h("div", { class: "table-wrap" }, h("table", {}, h("thead", {}, h("tr", {}, h("th", {}, "Created"), h("th", {}, "Folder"), h("th", {}, ""))), backups))));
|
||
|
||
async function loadBackups() {
|
||
try {
|
||
const list = await api("GET", "backups");
|
||
if (!list.length) { backups.replaceChildren(h("tr", {}, h("td", { colspan: 3, class: "empty" }, "No backups yet."))); return; }
|
||
backups.replaceChildren(...list.map((name) => h("tr", {},
|
||
h("td", {}, backupDate(name)), h("td", { class: "mono muted" }, name),
|
||
h("td", {}, h("div", { class: "actions" }, h("button", { class: "btn small", type: "button", onclick: () =>
|
||
worldAction("restore", { title: "Restore " + backupDate(name) + "?", text: "The server goes back to this save game (the current one is backed up first). Everyone reconnects.", danger: true, button: "Restore" }, name) }, "Restore"))))));
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
every(15000, loadBackups);
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Mods
|
||
|
||
function renderMods(page) {
|
||
const rows = h("tbody");
|
||
const fileInput = h("input", { type: "file", accept: ".dll" });
|
||
const kb = (n) => (n / 1024).toFixed(n < 102400 ? 1 : 0) + " KB";
|
||
const upload = async () => {
|
||
const file = fileInput.files && fileInput.files[0];
|
||
if (!file) { toast("Choose a mod (.dll) first", true); return; }
|
||
try {
|
||
const res = await fetch("api/mods/upload?name=" + encodeURIComponent(file.name), { method: "POST", credentials: "same-origin",
|
||
headers: { "Content-Type": "application/octet-stream", "X-MSCO-Panel": "1" }, body: file });
|
||
const data = await res.json().catch(() => null);
|
||
if (!res.ok) throw new Error((data && data.error) || "HTTP " + res.status);
|
||
toast(data.message);
|
||
fileInput.value = "";
|
||
load();
|
||
} catch (e) { toast(e.message, true); }
|
||
};
|
||
const remove = async (m) => {
|
||
const ok = await modal({ title: "Remove " + m.name + "?", danger: true, confirm: "Remove",
|
||
text: "New players no longer get this mod. Players who already have it keep it on their PC." });
|
||
if (!ok) return;
|
||
await act(api("POST", "mods/" + encodeURIComponent(m.id) + "/remove"));
|
||
load();
|
||
};
|
||
|
||
page.append(
|
||
h("div", { class: "cards wide" },
|
||
h("div", { class: "card" },
|
||
h("div", { class: "stat-label" }, "Add a mod"),
|
||
h("p", { class: "muted" }, "MSCO mods (.dll) from this list are offered to every player when they join. The player sees name, version, size and checksum and decides whether to install them. Only add mods you trust - they run on the players' PCs."),
|
||
h("div", { class: "row" }, fileInput,
|
||
h("button", { class: "btn primary", type: "button", onclick: upload }, "Upload"),
|
||
h("button", { class: "btn ghost", type: "button", onclick: async () => { await act(api("POST", "mods/reload")); load(); } }, "Reload"))),
|
||
h("div", { class: "card" },
|
||
h("div", { class: "stat-label" }, "Folder"),
|
||
h("p", { class: "muted" }, "The mods are stored in data/mods. Files copied there by hand are used after “Reload” (console: mods reload)."))),
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Server mods"), h("span", { class: "muted" }, "players get these when they join")),
|
||
h("div", { class: "table-wrap" }, h("table", {},
|
||
h("thead", {}, h("tr", {}, h("th", {}, "Mod"), h("th", {}, "Version"), h("th", {}, "Size"), h("th", {}, "SHA-256"), h("th", {}, ""))), rows))));
|
||
|
||
async function load() {
|
||
try {
|
||
const list = await api("GET", "mods");
|
||
if (!list.length) { rows.replaceChildren(h("tr", {}, h("td", { colspan: 5, class: "empty" }, "No mods - players join without mods."))); return; }
|
||
rows.replaceChildren(...list.map((m) => h("tr", {},
|
||
h("td", {}, h("b", {}, m.name), h("div", { class: "muted mono" }, m.id + ".dll")),
|
||
h("td", {}, m.version || "–"), h("td", {}, kb(m.size)),
|
||
h("td", { class: "mono muted", title: m.sha256 }, m.sha256.slice(0, 16) + "…"),
|
||
h("td", {}, h("div", { class: "actions" }, h("button", { class: "btn small danger", type: "button", onclick: () => remove(m) }, "Remove"))))));
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
load();
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Benutzer
|
||
|
||
function renderUsers(page) {
|
||
const tbody = h("tbody");
|
||
page.append(h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Panel users"),
|
||
h("button", { class: "btn primary small", type: "button", onclick: addUser }, "+ Add user")),
|
||
h("div", { class: "table-wrap" }, h("table", {},
|
||
h("thead", {}, h("tr", {}, h("th", {}, "Name"), h("th", {}, "Role"), h("th", {}, "Last login"), h("th", {}, ""))), tbody))),
|
||
h("div", { class: "section" }, h("div", { class: "section-body muted" },
|
||
h("b", {}, "Moderator"), ": players, kick, server messages, read console & bans. ",
|
||
h("b", {}, "Admin"), ": everything on the server (ban, console commands, world reset, backups). ",
|
||
h("b", {}, "Owner"), ": additionally manages panel users.")));
|
||
|
||
async function load() {
|
||
try {
|
||
const users = await api("GET", "users");
|
||
tbody.replaceChildren(...users.map((u) => h("tr", {},
|
||
h("td", {}, h("span", { class: "player-name" }, u.name, u.name.toLowerCase() === state.user.name.toLowerCase() ? h("span", { class: "pill gray" }, "you") : null)),
|
||
h("td", {}, h("span", { class: "pill" + (u.role === "Owner" ? "" : u.role === "Admin" ? " green" : " gray") }, u.role)),
|
||
h("td", { class: "muted" }, u.lastLogin ? since(u.lastLogin) + " ago" : "never"),
|
||
h("td", {}, h("div", { class: "actions" },
|
||
h("button", { class: "btn small ghost", type: "button", onclick: () => changeRole(u) }, "Role"),
|
||
h("button", { class: "btn small ghost", type: "button", onclick: () => resetPassword(u) }, "Password"),
|
||
u.name.toLowerCase() === state.user.name.toLowerCase() ? null
|
||
: h("button", { class: "btn small danger", type: "button", onclick: () => removeUser(u) }, "Delete"))))));
|
||
} catch (e) { toast(e.message, true); }
|
||
}
|
||
|
||
async function addUser() {
|
||
const v = await modal({ title: "Add panel user", fields: [
|
||
{ name: "name", label: "Username", max: 32 },
|
||
{ name: "password", label: "Password (min. 8 characters)", type: "password" },
|
||
{ name: "role", label: "Role", options: ["Moderator", "Admin", "Owner"], value: "Moderator" }], confirm: "Create" });
|
||
if (v && await act(api("POST", "users", v))) load();
|
||
}
|
||
async function changeRole(u) {
|
||
const v = await modal({ title: "Role of " + u.name, fields: [{ name: "role", label: "Role", options: ["Moderator", "Admin", "Owner"], value: u.role }], confirm: "Save" });
|
||
if (v && await act(api("POST", `users/${encodeURIComponent(u.name)}/role`, v))) load();
|
||
}
|
||
async function resetPassword(u) {
|
||
const v = await modal({ title: "New password for " + u.name, text: "They are logged out everywhere.", fields: [{ name: "password", label: "New password", type: "password" }], confirm: "Set password" });
|
||
if (v && await act(api("POST", `users/${encodeURIComponent(u.name)}/password`, v))) load();
|
||
}
|
||
async function removeUser(u) {
|
||
const ok = await modal({ title: "Delete " + u.name + "?", text: "They can no longer log in to the panel.", danger: true, confirm: "Delete" });
|
||
if (ok && await act(api("POST", `users/${encodeURIComponent(u.name)}/delete`))) load();
|
||
}
|
||
load();
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Konto
|
||
|
||
function renderAccount(page) {
|
||
const current = h("input", { type: "password", autocomplete: "current-password" });
|
||
const next = h("input", { type: "password", autocomplete: "new-password" });
|
||
const repeat = h("input", { type: "password", autocomplete: "new-password" });
|
||
const form = h("form", { class: "section-body form-grid" },
|
||
h("label", {}, "Current password", current),
|
||
h("label", {}, "New password (min. 8 characters)", next),
|
||
h("label", {}, "Repeat new password", repeat),
|
||
h("div", {}, h("button", { class: "btn primary", type: "submit" }, "Change password")));
|
||
form.addEventListener("submit", async (e) => {
|
||
e.preventDefault();
|
||
if (next.value !== repeat.value) { toast("The new passwords do not match", true); return; }
|
||
if (await act(api("POST", "me/password", { current: current.value, password: next.value }))) current.value = next.value = repeat.value = "";
|
||
});
|
||
page.append(h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "My account"),
|
||
h("span", { class: "muted" }, `${state.user.name} · ${state.user.role}`)), form));
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Updates
|
||
|
||
const UPDATE_STEPS = [["downloading", "Download"], ["building", "Build"], ["installing", "Install"], ["restarting", "Restart"], ["done", "Done"]];
|
||
const UPDATE_BUSY = ["downloading", "building", "installing", "restarting"];
|
||
|
||
function shortDate(iso) {
|
||
if (!iso) return "–";
|
||
return new Date(iso).toLocaleString(window.MSCO_LANG === "de" ? "de-DE" : "en-GB", { dateStyle: "medium", timeStyle: "short" });
|
||
}
|
||
|
||
/** Sidebar-Hinweis "Update verfügbar" und laufendes Update (auch von einem anderen Admin gestartet) anzeigen. */
|
||
async function refreshUpdateState() {
|
||
if (!can("Admin")) return;
|
||
try {
|
||
const u = await api("GET", "update");
|
||
const available = !!u.available;
|
||
if (available !== !!state.updateAvailable) { state.updateAvailable = available; renderNav(); }
|
||
if (UPDATE_BUSY.includes(u.step)) openUpdateOverlay(u);
|
||
} catch { /* Topbar zeigt Fehler */ }
|
||
}
|
||
|
||
function renderUpdate(page) {
|
||
const body = h("div", { class: "section-body update-body" });
|
||
const changes = h("div", { class: "section-body" });
|
||
const changesBox = h("div", { class: "section hidden" }, h("div", { class: "section-head" }, h("h2", {}, "What's new")), changes);
|
||
page.append(
|
||
h("div", { class: "section" },
|
||
h("div", { class: "section-head" }, h("h2", {}, "Server update"),
|
||
h("button", { class: "btn small", type: "button", onclick: () => load(true) }, "⟳ Check for updates")),
|
||
body),
|
||
changesBox);
|
||
load(false);
|
||
|
||
async function load(check) {
|
||
let u;
|
||
try { u = check ? await api("POST", "update/check") : await api("GET", "update"); }
|
||
catch (e) { toast(e.message, true); return; }
|
||
if (check) toast(u.checkError ? u.checkError : u.available ? "Update available: " + u.latest : "The server is up to date", !!u.checkError);
|
||
state.updateAvailable = !!u.available;
|
||
renderNav();
|
||
if (UPDATE_BUSY.includes(u.step)) openUpdateOverlay(u);
|
||
|
||
const tile = (label, value, sub, cls) => h("div", { class: "card" + (cls ? " " + cls : "") },
|
||
h("div", { class: "stat-label" }, label), h("div", { class: "stat-value" }, value), sub ? h("div", { class: "stat-sub" }, sub) : null);
|
||
const tiles = h("div", { class: "cards" },
|
||
tile("Installed", u.current, u.currentCommit ? "commit " + u.currentCommit.slice(0, 8) : "for MSCO mod " + u.modVersion),
|
||
tile("Newest", u.latest || "–", u.latestDate ? shortDate(u.latestDate) : "not checked yet", u.available ? "accent" : null),
|
||
tile("Last check", u.lastCheck ? since(u.lastCheck) : "–", "automatically every 6 hours"));
|
||
|
||
const notes = [];
|
||
if (u.checkError) notes.push(h("div", { class: "notice bad" }, h("div", {}, h("b", {}, "Check failed"), h("div", {}, u.checkError))));
|
||
if (u.step === "failed" && u.error) notes.push(h("div", { class: "notice bad" }, h("div", {}, h("b", {}, "The last update failed"), h("div", {}, u.error)),
|
||
h("button", { class: "btn small ghost", type: "button", onclick: () => openUpdateOverlay(u, true) }, "Show log")));
|
||
if (u.available) {
|
||
const modChange = u.latestModVersion && u.latestModVersion !== u.modVersion;
|
||
notes.push(h("div", { class: "notice update-notice" },
|
||
h("div", {}, h("b", {}, "Version " + u.latest + " is available"),
|
||
h("div", {}, u.latestMessage || ""),
|
||
modChange ? h("div", { class: "warn-text" }, "Players then need MSCO mod " + u.latestModVersion + " (currently " + u.modVersion + ")") : null),
|
||
u.canInstall === false ? null : h("button", { class: "btn primary", type: "button", onclick: () => install(u) }, "⇪ Install update")));
|
||
if (u.canInstall === false) notes.push(h("div", { class: "notice" }, h("div", {}, h("b", {}, "Update from the panel not possible"), h("div", {}, u.cannotInstall))));
|
||
} else if (u.latest) {
|
||
notes.push(h("div", { class: "notice ok" }, h("div", {}, h("b", {}, "The server is up to date"), h("div", {}, "Version " + u.current))));
|
||
}
|
||
body.replaceChildren(tiles, ...notes,
|
||
h("p", { class: "muted small-print" }, "Source: ", h("a", { href: u.source, target: "_blank", rel: "noopener" }, u.source.replace(/^https?:\/\//, "")), " (" + u.branch + ")"));
|
||
|
||
const list = u.changes || [];
|
||
changesBox.classList.toggle("hidden", !u.available || list.length === 0);
|
||
changes.replaceChildren(h("ul", { class: "changes" }, list.map((c) =>
|
||
h("li", {}, h("span", { class: "mono change-sha" }, c.sha), h("span", { class: "change-title mono" }, c.title), h("small", { class: "muted" }, shortDate(c.date))))));
|
||
}
|
||
|
||
async function install(u) {
|
||
const ok = await modal({ title: "Install update " + u.latest + "?",
|
||
text: "The server downloads and builds the new version (takes a minute or two), then restarts. Players are warned and reconnect automatically. The panel shows the progress and reloads itself.",
|
||
confirm: "Update now" });
|
||
if (!ok) return;
|
||
const r = await act(api("POST", "update/install"));
|
||
if (r) openUpdateOverlay({ ...u, step: "downloading", progress: 0, target: u.latest, log: [] });
|
||
}
|
||
}
|
||
|
||
let updateOverlay = null;
|
||
|
||
/** Vollbild-Fortschritt: Laden, Bauen, Installieren, Neustart - übersteht den Neustart des Panels und lädt danach neu. */
|
||
function openUpdateOverlay(initial, showLogOnly) {
|
||
if (updateOverlay) return;
|
||
const stepEls = {};
|
||
const list = h("ol", { class: "us-steps" }, UPDATE_STEPS.map(([id, label]) => (stepEls[id] = h("li", {}, h("span", { class: "us-dot" }), h("span", {}, label)))));
|
||
const fill = h("div", { class: "us-fill" });
|
||
const pct = h("b", { class: "us-pct" }, "0%");
|
||
const msg = h("div", { class: "us-msg" }, "…");
|
||
const clock = h("small", { class: "muted us-clock" }, "");
|
||
const logBox = h("div", { class: "console us-log hidden" });
|
||
const logToggle = h("button", { class: "btn small ghost", type: "button", onclick: () => {
|
||
logBox.classList.toggle("hidden");
|
||
logToggle.textContent = logBox.classList.contains("hidden") ? "Show build log" : "Hide build log";
|
||
logBox.scrollTop = logBox.scrollHeight;
|
||
} }, "Show build log");
|
||
const errorBox = h("div", { class: "notice bad hidden" });
|
||
const actions = h("div", { class: "buttons" });
|
||
const versions = h("div", { class: "us-versions" });
|
||
const card = h("div", { class: "update-card" },
|
||
h("div", { class: "us-head" }, h("img", { src: "logo.png", alt: "" }), h("div", {}, h("h2", {}, "Updating the server"), versions)),
|
||
list, h("div", { class: "us-bar" }, fill), h("div", { class: "us-row" }, msg, pct), h("div", { class: "us-row" }, logToggle, clock),
|
||
logBox, errorBox, actions);
|
||
const back = h("div", { class: "update-overlay" }, card);
|
||
document.body.append(back);
|
||
updateOverlay = back;
|
||
|
||
const from = initial.current;
|
||
let target = initial.target || initial.latest;
|
||
let sawRestart = initial.step === "restarting", offlineSince = null, done = false, timer = null, lastLog = "";
|
||
const started = Date.now();
|
||
const close = () => { clearTimeout(timer); back.remove(); updateOverlay = null; };
|
||
const showVersions = (now) => versions.replaceChildren(h("span", { class: "mono" }, from || now || "?"), " → ", h("span", { class: "mono" }, target || "?"));
|
||
showVersions();
|
||
|
||
const mark = (active, failed) => {
|
||
const idx = UPDATE_STEPS.findIndex(([id]) => id === active);
|
||
UPDATE_STEPS.forEach(([id], i) => { stepEls[id].className = i < idx ? "done" : i === idx ? (failed ? "failed" : "active") : ""; });
|
||
};
|
||
const setBar = (p) => { const v = Math.max(0, Math.min(100, p || 0)); fill.style.width = v + "%"; pct.textContent = v + "%"; };
|
||
const fmt = (s) => Math.floor(s / 60) + ":" + String(Math.floor(s % 60)).padStart(2, "0");
|
||
const showLog = (lines) => {
|
||
const text = (lines || []).join("\n");
|
||
if (text === lastLog) return;
|
||
lastLog = text;
|
||
const atEnd = logBox.scrollTop + logBox.clientHeight >= logBox.scrollHeight - 30;
|
||
logBox.replaceChildren(...(lines || []).map((l) => h("div", { class: /error|FAILED/i.test(l) ? "l-warn" : l.startsWith("==") ? "l-cmd" : "" }, l)));
|
||
if (atEnd) logBox.scrollTop = logBox.scrollHeight;
|
||
};
|
||
const fail = (text, lines) => {
|
||
done = true;
|
||
const active = UPDATE_STEPS.find(([id]) => stepEls[id].className === "active");
|
||
mark(active ? active[0] : "downloading", true);
|
||
errorBox.replaceChildren(h("div", {}, h("b", {}, "The update failed - the server keeps running the old version"), h("div", {}, text)));
|
||
errorBox.classList.remove("hidden");
|
||
if (lines) showLog(lines);
|
||
logBox.classList.remove("hidden");
|
||
logToggle.textContent = "Hide build log";
|
||
msg.textContent = "Update failed";
|
||
actions.replaceChildren(h("button", { class: "btn primary", type: "button", onclick: () => { close(); go("update"); } }, "Close"));
|
||
};
|
||
const finish = (version) => {
|
||
done = true;
|
||
mark("done");
|
||
stepEls.done.className = "done";
|
||
setBar(100);
|
||
msg.textContent = "Server updated to " + version + " - reloading the panel …";
|
||
card.classList.add("success");
|
||
setTimeout(() => location.reload(), 2500);
|
||
};
|
||
|
||
if (showLogOnly) { fail(initial.error || "", initial.log); return; }
|
||
mark(initial.step || "downloading");
|
||
setBar(initial.progress);
|
||
|
||
const poll = async () => {
|
||
if (done) return;
|
||
clock.textContent = fmt((Date.now() - started) / 1000);
|
||
let u = null, unauthorized = false;
|
||
try {
|
||
const res = await fetch("api/update", { credentials: "same-origin", headers: { "X-MSCO-Panel": "1" }, cache: "no-store" });
|
||
if (res.status === 401) unauthorized = true;
|
||
else if (res.ok) u = await res.json();
|
||
} catch { /* während des Neustarts normal */ }
|
||
|
||
if (unauthorized && sawRestart) { finish(target); return; } // nach dem Neuladen neu anmelden
|
||
if (!u) {
|
||
offlineSince = offlineSince || Date.now();
|
||
const away = (Date.now() - offlineSince) / 1000;
|
||
if (away > 15) sawRestart = true; // Verbindung länger weg = Neustart läuft
|
||
if (sawRestart) {
|
||
mark("restarting");
|
||
setBar(97);
|
||
msg.textContent = away > 180
|
||
? "The server has not come back yet. Docker/systemd restart it automatically - otherwise start it again."
|
||
: "Waiting for the server to come back …";
|
||
}
|
||
timer = setTimeout(poll, 1000);
|
||
return;
|
||
}
|
||
offlineSince = null;
|
||
if (u.target) target = u.target;
|
||
showVersions(u.current);
|
||
if (u.log && u.log.length) showLog(u.log);
|
||
|
||
if (u.step === "failed") { fail(u.error || "Unknown error", u.log); return; }
|
||
if (UPDATE_BUSY.includes(u.step)) {
|
||
if (u.step === "restarting") sawRestart = true;
|
||
mark(u.step);
|
||
setBar(u.progress);
|
||
msg.textContent = u.message || "…";
|
||
} else if (target && u.current === target) {
|
||
finish(u.current); return;
|
||
} else if (sawRestart) {
|
||
fail("The server restarted but still runs version " + u.current + ". The update could not be started - see the console.");
|
||
return;
|
||
} else {
|
||
close(); return; // nichts (mehr) zu tun
|
||
}
|
||
timer = setTimeout(poll, 1000);
|
||
};
|
||
poll();
|
||
}
|
||
|
||
// ------------------------------------------------------------------ Start
|
||
|
||
async function boot() {
|
||
let session;
|
||
try { session = await api("GET", "session"); }
|
||
catch (e) { document.body.replaceChildren(h("div", { class: "empty" }, "Panel not reachable: " + e.message)); return; }
|
||
if (!session.user) { showAuth(session.needsSetup, session.serverName); return; }
|
||
state.user = session.user;
|
||
$("auth").classList.add("hidden");
|
||
$("app").classList.remove("hidden");
|
||
$("meName").textContent = session.user.name;
|
||
$("meRole").textContent = session.user.role;
|
||
$("meAvatar").textContent = session.user.name.slice(0, 1).toUpperCase();
|
||
$("scName").textContent = session.serverName;
|
||
$("serverName").textContent = session.serverName;
|
||
let page = "dashboard";
|
||
try { page = localStorage.getItem("msco-panel-page") || page; } catch { /* egal */ }
|
||
go(page);
|
||
}
|
||
|
||
boot();
|