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

691 lines
35 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", render: renderDashboard },
{ id: "players", label: "Players", icon: "☺", role: "Moderator", render: renderPlayers },
{ id: "console", label: "Console", icon: "›_", role: "Moderator", render: renderConsole },
{ id: "bans", label: "Bans", icon: "⊘", role: "Moderator", render: renderBans },
{ id: "world", label: "World & Backups", icon: "⟲", role: "Admin", render: renderWorld },
{ id: "settings", label: "Settings", icon: "☰", role: "Admin", render: renderSettings },
{ id: "users", label: "Panel Users", icon: "⚿", role: "Owner", render: renderUsers },
{ id: "account", label: "My Account", icon: "⚙", role: "Moderator", render: renderAccount },
];
function renderNav() {
const nav = $("nav");
nav.replaceChildren(...PAGES.filter((p) => can(p.role)).map((p) =>
h("button", { type: "button", class: p.id === state.page ? "active" : "", onclick: () => go(p.id) },
h("span", { class: "ico" }, p.icon), p.label)));
}
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;
$("serverMeta").textContent = s.gameStopped
? `game server stopped · v${s.version}`
: `${s.players}/${s.maxPlayers} players · v${s.version} · up ${duration(s.uptimeSeconds)}`;
$("statusDot").className = "status-dot " + (s.gameStopped ? "" : "ok");
$("statusDot").title = s.gameStopped ? "Game server stopped" : "Online";
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 row" });
if (can("Admin")) page.append(h("div", { class: "section" },
h("div", { class: "section-head" }, h("h2", {}, "Server control"), h("span", { class: "muted", 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() : "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);
}
function renderControl(el, s) {
const state = $("gameState");
if (state) state.textContent = s.gameStopped ? "game server is stopped - players cannot join" : "game server is running";
el.replaceChildren(
s.gameStopped
? h("button", { class: "btn primary", type: "button", onclick: () => serverAction("start-game") }, "▶ Start")
: h("button", { class: "btn", type: "button", onclick: () => serverAction("stop-game", { title: "Stop the game server?",
text: "All players are kicked and nobody can join until you start it again. The panel keeps running.", danger: true, confirm: "Stop" }) }, "■ Stop"),
h("button", { class: "btn", type: "button", onclick: () => serverAction("restart", { title: "Restart the server?",
text: "Settings (server.json), license and save game are loaded again. Players reconnect automatically. You stay logged in.", confirm: "Restart" }) }, "⟳ Restart"),
can("Owner") ? h("button", { class: "btn danger", type: "button", onclick: () => serverAction("shutdown", { title: "Shut down completely?",
text: "The game server AND this panel stop. Start it again on the machine (e.g. docker compose up -d).", danger: true, confirm: "Shut down" }) }, "⏻ Shut down") : 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) => {
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);
};
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;
}
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: "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("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 the server now?",
text: "Loads the saved settings. Players reconnect automatically.", confirm: "Restart" }) }, "Restart to apply")));
}
}
// ------------------------------------------------------------------ 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();
}
// ------------------------------------------------------------------ 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"))) : null,
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,application/zip" });
const uploadZip = async () => {
const file = zipInput.files && zipInput.files[0];
if (!file) { toast("Choose a ZIP 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/zip", "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" },
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 (ZIP)"),
h("p", { class: "muted" }, "ZIP the files from AppData\\LocalLow\\Amistech\\My Summer Car. 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;
$("serverName").textContent = session.serverName;
let page = "dashboard";
try { page = localStorage.getItem("msco-panel-page") || page; } catch { /* egal */ }
go(page);
}
boot();