Web panel (like txAdmin) on the game port: login/roles/users, players, kick/ban/op, bans, live console, world reset/backups/restore, settings editor, save ZIP upload/download, start/stop/restart; in-process restart loop; tests
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
+184
@@ -0,0 +1,184 @@
|
||||
:root {
|
||||
--bg: #17171b;
|
||||
--panel: #1e1e23;
|
||||
--panel-2: #24242a;
|
||||
--sidebar: #0f0f12;
|
||||
--border: #2e2e36;
|
||||
--text: #ececf1;
|
||||
--muted: #8e8e99;
|
||||
--accent: #f26b1d;
|
||||
--accent-hover: #ff823a;
|
||||
--online: #3dd68c;
|
||||
--pending: #f2c230;
|
||||
--danger: #e5484d;
|
||||
--danger-hover: #f2555a;
|
||||
--radius: 10px;
|
||||
--mono: ui-monospace, "Cascadia Mono", "JetBrains Mono", Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font: 14px/1.45 system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
.muted { color: var(--muted); }
|
||||
.error { color: var(--danger); margin: 0; }
|
||||
h1, h2, h3 { margin: 0; font-weight: 700; letter-spacing: -0.01em; }
|
||||
h1 { font-size: 22px; }
|
||||
h2 { font-size: 18px; }
|
||||
h3 { font-size: 14px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
|
||||
/* ---------------------------------------------------------------- Knöpfe & Felder */
|
||||
.btn {
|
||||
border: 0; border-radius: 8px; padding: 9px 16px; font: inherit; font-weight: 600; cursor: pointer;
|
||||
background: var(--panel-2); color: var(--text); transition: background .12s, transform .06s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.btn:hover { background: #2f2f37; }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn:disabled { opacity: .5; cursor: default; }
|
||||
.btn.primary { background: var(--accent); color: #fff; }
|
||||
.btn.primary:hover { background: var(--accent-hover); }
|
||||
.btn.danger { background: rgba(229, 72, 77, .14); color: #ff8a8d; }
|
||||
.btn.danger:hover { background: var(--danger); color: #fff; }
|
||||
.btn.ghost { background: transparent; color: var(--muted); }
|
||||
.btn.ghost:hover { background: var(--panel-2); color: var(--text); }
|
||||
.btn.small { padding: 6px 11px; font-size: 13px; }
|
||||
.btn.wide { width: 100%; }
|
||||
|
||||
input, select, textarea {
|
||||
width: 100%; background: var(--panel-2); border: 1px solid var(--border); color: var(--text);
|
||||
border-radius: 8px; padding: 9px 12px; font: inherit; outline: none; transition: border-color .12s;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--accent); }
|
||||
label { display: block; font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
|
||||
label input, label select { margin-top: 6px; text-transform: none; letter-spacing: normal; font-size: 14px; font-weight: 400; }
|
||||
|
||||
.pill {
|
||||
display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 700;
|
||||
background: rgba(242, 107, 29, .16); color: var(--accent); letter-spacing: .03em;
|
||||
}
|
||||
.pill.gray { background: rgba(255, 255, 255, .08); color: var(--muted); }
|
||||
.pill.green { background: rgba(61, 214, 140, .14); color: var(--online); }
|
||||
.pill.red { background: rgba(229, 72, 77, .15); color: #ff8a8d; }
|
||||
|
||||
/* ---------------------------------------------------------------- Anmeldung */
|
||||
.auth { min-height: 100%; display: grid; place-items: center; padding: 24px 16px;
|
||||
background: radial-gradient(ellipse at top, rgba(242, 107, 29, .12), transparent 60%), var(--bg); }
|
||||
.auth-card { width: 100%; max-width: 380px; background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 28px; display: grid; gap: 16px; box-shadow: 0 20px 60px rgba(0, 0, 0, .4); }
|
||||
.auth-card h1 { margin-top: 6px; }
|
||||
.auth-card p { margin: -8px 0 0; }
|
||||
|
||||
.brand { display: flex; align-items: center; gap: 12px; }
|
||||
.brand img { width: 40px; height: 40px; border-radius: 9px; }
|
||||
.brand-title { font-weight: 800; font-size: 20px; letter-spacing: .02em; }
|
||||
.brand-sub { color: var(--muted); font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; }
|
||||
|
||||
/* ---------------------------------------------------------------- Layout */
|
||||
.layout { display: grid; grid-template-columns: 232px 1fr; min-height: 100%; }
|
||||
.sidebar { background: var(--sidebar); padding: 20px 14px; display: flex; flex-direction: column; gap: 22px;
|
||||
position: sticky; top: 0; height: 100vh; }
|
||||
.sidebar .brand { padding: 0 8px; }
|
||||
nav { display: grid; gap: 2px; }
|
||||
nav button { display: flex; align-items: center; gap: 10px; width: 100%; text-align: left; background: transparent;
|
||||
border: 0; color: var(--muted); font: inherit; font-weight: 600; padding: 10px 12px; border-radius: 8px; cursor: pointer; }
|
||||
nav button:hover { background: #18181d; color: var(--text); }
|
||||
nav button.active { background: var(--panel-2); color: var(--text); box-shadow: inset 3px 0 0 var(--accent); }
|
||||
nav .ico { width: 18px; text-align: center; opacity: .9; }
|
||||
.sidebar-foot { margin-top: auto; display: grid; gap: 8px; padding: 0 8px; }
|
||||
.me { display: flex; align-items: center; justify-content: space-between; gap: 8px; font-weight: 600; overflow: hidden; }
|
||||
.me span:first-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
.content { min-width: 0; display: flex; flex-direction: column; }
|
||||
.topbar { display: flex; align-items: center; gap: 14px; padding: 18px 28px; border-bottom: 1px solid var(--border);
|
||||
background: rgba(23, 23, 27, .85); backdrop-filter: blur(8px); position: sticky; top: 0; z-index: 5; }
|
||||
.server-name { font-weight: 700; font-size: 16px; }
|
||||
.server-meta { color: var(--muted); font-size: 12px; }
|
||||
.status-dot { margin-left: auto; width: 10px; height: 10px; border-radius: 50%; background: var(--pending); box-shadow: 0 0 0 4px rgba(242, 194, 48, .15); }
|
||||
.status-dot.ok { background: var(--online); box-shadow: 0 0 0 4px rgba(61, 214, 140, .15); }
|
||||
.status-dot.bad { background: var(--danger); box-shadow: 0 0 0 4px rgba(229, 72, 77, .15); }
|
||||
.menu { display: none; }
|
||||
.page { padding: 24px 28px 40px; display: grid; gap: 20px; align-content: start; }
|
||||
|
||||
/* ---------------------------------------------------------------- Karten */
|
||||
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 14px; }
|
||||
.card { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; }
|
||||
.stat-label { color: var(--muted); font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .06em; }
|
||||
.stat-value { font-size: 26px; font-weight: 800; margin-top: 6px; font-variant-numeric: tabular-nums; }
|
||||
.stat-sub { color: var(--muted); font-size: 12px; margin-top: 2px; }
|
||||
.section { background: var(--panel); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
|
||||
.section-head { display: flex; align-items: center; gap: 12px; padding: 14px 18px; border-bottom: 1px solid var(--border); flex-wrap: wrap; }
|
||||
.section-head h2 { margin-right: auto; }
|
||||
.section-body { padding: 18px; }
|
||||
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||
.row > input { flex: 1; min-width: 180px; }
|
||||
.empty { color: var(--muted); padding: 26px 18px; text-align: center; }
|
||||
|
||||
/* ---------------------------------------------------------------- Tabellen */
|
||||
.table-wrap { overflow-x: auto; }
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th { text-align: left; font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: .06em;
|
||||
font-weight: 700; padding: 10px 18px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
td { padding: 11px 18px; border-bottom: 1px solid #26262d; vertical-align: middle; }
|
||||
tr:last-child td { border-bottom: 0; }
|
||||
tbody tr:hover { background: rgba(255, 255, 255, .02); }
|
||||
.actions { display: flex; gap: 6px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.id { font-family: var(--mono); color: var(--muted); }
|
||||
.mono { font-family: var(--mono); font-size: 13px; }
|
||||
.player-name { font-weight: 600; display: flex; align-items: center; gap: 8px; }
|
||||
|
||||
/* ---------------------------------------------------------------- Konsole */
|
||||
.console { background: #0c0c0f; font-family: var(--mono); font-size: 12.5px; line-height: 1.55; height: 60vh; min-height: 300px;
|
||||
overflow-y: auto; padding: 14px 16px; white-space: pre-wrap; word-break: break-word; }
|
||||
.console .t { color: #5d5d68; margin-right: 8px; }
|
||||
.console .l-chat { color: #9fd3ff; }
|
||||
.console .l-panel { color: #ffb37f; }
|
||||
.console .l-cmd { color: var(--accent); }
|
||||
.console .l-warn { color: #ff8a8d; }
|
||||
.console.small { height: 260px; min-height: 0; }
|
||||
.form-grid { display: grid; gap: 14px; max-width: 420px; }
|
||||
.grid-2 { display: grid; gap: 20px; }
|
||||
.form-grid.wide { max-width: none; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px 20px; }
|
||||
.label-row { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.hint { display: block; margin-top: 5px; color: var(--muted); font-size: 12px; text-transform: none; letter-spacing: normal; font-weight: 400; }
|
||||
.toggle { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; padding-top: 18px; }
|
||||
.toggle input { width: 18px; height: 18px; margin: 1px 0 0; accent-color: var(--accent); flex: none; }
|
||||
input[type=file] { padding: 7px; }
|
||||
input[type=file]::file-selector-button { background: var(--panel); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 5px 10px; margin-right: 10px; cursor: pointer; }
|
||||
.console-input { display: flex; gap: 10px; padding: 12px; border-top: 1px solid var(--border); background: var(--panel); }
|
||||
.console-input input { font-family: var(--mono); }
|
||||
|
||||
/* ---------------------------------------------------------------- Modal & Hinweise */
|
||||
.modal-back { position: fixed; inset: 0; background: rgba(0, 0, 0, .6); display: grid; place-items: center; padding: 16px; z-index: 50;
|
||||
animation: fade .12s ease-out; }
|
||||
.modal { width: 100%; max-width: 440px; background: var(--panel); border: 1px solid var(--border); border-radius: 14px;
|
||||
padding: 22px; display: grid; gap: 14px; box-shadow: 0 24px 70px rgba(0, 0, 0, .5); }
|
||||
.modal.danger { border-top: 3px solid var(--danger); }
|
||||
.modal p { margin: 0; color: var(--muted); }
|
||||
.modal .buttons { display: flex; justify-content: flex-end; gap: 8px; margin-top: 4px; }
|
||||
@keyframes fade { from { opacity: 0; } }
|
||||
|
||||
.toasts { position: fixed; right: 16px; bottom: 16px; display: grid; gap: 8px; z-index: 60; max-width: min(380px, calc(100vw - 32px)); }
|
||||
.toast { background: var(--panel-2); border: 1px solid var(--border); border-left: 3px solid var(--online); border-radius: 10px;
|
||||
padding: 11px 14px; box-shadow: 0 10px 30px rgba(0, 0, 0, .4); animation: slide .2s ease-out; white-space: pre-wrap; }
|
||||
.toast.err { border-left-color: var(--danger); }
|
||||
@keyframes slide { from { transform: translateX(20px); opacity: 0; } }
|
||||
|
||||
/* ---------------------------------------------------------------- Handy */
|
||||
@media (max-width: 820px) {
|
||||
.layout { grid-template-columns: 1fr; }
|
||||
.sidebar { position: fixed; inset: 0 auto 0 0; width: 250px; z-index: 40; transform: translateX(-100%); transition: transform .18s; }
|
||||
.sidebar.open { transform: none; box-shadow: 20px 0 60px rgba(0, 0, 0, .5); }
|
||||
.menu { display: inline-block; }
|
||||
.topbar { padding: 14px 16px; }
|
||||
.page { padding: 16px 16px 32px; }
|
||||
th, td { padding: 10px 12px; }
|
||||
.console { height: 55vh; }
|
||||
}
|
||||
+690
@@ -0,0 +1,690 @@
|
||||
"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();
|
||||
@@ -0,0 +1,73 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>MSCO Server Panel</title>
|
||||
<link rel="icon" href="logo.png">
|
||||
<link rel="stylesheet" href="app.css">
|
||||
<script src="app.js" defer></script>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Anmeldung / Einrichtung -->
|
||||
<main id="auth" class="auth hidden">
|
||||
<form id="authForm" class="auth-card" autocomplete="on">
|
||||
<div class="brand">
|
||||
<img src="logo.png" alt="">
|
||||
<div>
|
||||
<div class="brand-title">MSCO</div>
|
||||
<div class="brand-sub" id="authServer">Server Panel</div>
|
||||
</div>
|
||||
</div>
|
||||
<h1 id="authTitle">Sign in</h1>
|
||||
<p id="authHint" class="muted"></p>
|
||||
<label id="codeRow" class="hidden">Setup code
|
||||
<input id="code" name="code" autocomplete="off" spellcheck="false" placeholder="XXXXX-XXXXX">
|
||||
</label>
|
||||
<label>Username
|
||||
<input id="username" name="username" autocomplete="username" required>
|
||||
</label>
|
||||
<label>Password
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" required>
|
||||
</label>
|
||||
<label id="password2Row" class="hidden">Repeat password
|
||||
<input id="password2" type="password" autocomplete="new-password">
|
||||
</label>
|
||||
<p id="authError" class="error hidden"></p>
|
||||
<button class="btn primary wide" id="authButton" type="submit">Sign in</button>
|
||||
</form>
|
||||
</main>
|
||||
|
||||
<!-- Panel -->
|
||||
<div id="app" class="layout hidden">
|
||||
<aside class="sidebar">
|
||||
<div class="brand">
|
||||
<img src="logo.png" alt="">
|
||||
<div>
|
||||
<div class="brand-title">MSCO</div>
|
||||
<div class="brand-sub">Server Panel</div>
|
||||
</div>
|
||||
</div>
|
||||
<nav id="nav"></nav>
|
||||
<div class="sidebar-foot">
|
||||
<div class="me"><span id="meName"></span><span id="meRole" class="pill"></span></div>
|
||||
<button class="btn ghost small" id="logout" type="button">Log out</button>
|
||||
</div>
|
||||
</aside>
|
||||
<section class="content">
|
||||
<header class="topbar">
|
||||
<button class="btn ghost small menu" id="menuToggle" type="button" aria-label="Menu">☰</button>
|
||||
<div>
|
||||
<div class="server-name" id="serverName">MSCO Server</div>
|
||||
<div class="server-meta" id="serverMeta"></div>
|
||||
</div>
|
||||
<div class="status-dot" id="statusDot" title="Server status"></div>
|
||||
</header>
|
||||
<div id="page" class="page"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div id="modalRoot"></div>
|
||||
<div id="toasts" class="toasts"></div>
|
||||
</body>
|
||||
</html>
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.7 KiB |
Reference in New Issue
Block a user