"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: "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; 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://:${newPanel}/), nicht mehr unter ${oldPanel}.` : `Afterwards the panel is only reachable on port ${newPanel} (http://:${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); } // ------------------------------------------------------------------ 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();