Files
MSCO-Dedicated-Server/Panel/app.js
T

810 lines
43 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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: "settings", label: "Settings", icon: "⚙", role: "Admin", group: "Management", render: renderSettings },
{ 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)));
}
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);
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);
}
el.replaceChildren(buttons, problem ?? ""); // replaceChildren macht aus null den Text "null"
}
// ------------------------------------------------------------------ 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 }),
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")));
}
}
/**
* Warnung, wenn Spiel- oder Panel-Port geändert werden: Ports übernimmt erst ein Neustart des ganzen Programms
* (der Restart-Knopf startet nur den Gameserver neu), 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 erst nach einem Neustart des ganzen Programms (docker compose restart) - der Neustart-Knopf startet nur den Gameserver neu."
: "This only applies after the whole program restarts (docker compose restart) - the Restart button only restarts the game server.");
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" }, "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);
}
// ------------------------------------------------------------------ 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));
}
// ------------------------------------------------------------------ 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();