Server updates from the web panel with progress screen

- Updater checks git.mbm.wtf every 6 h (and on demand) for a newer <Version>
  on main, shows the new commits and the required mod version
- Install: download the source, build it with dotnet publish into
  data/app/current, warn players and restart (exit code 75 for Docker/systemd,
  self-relaunch on Windows). At startup a newer installed update is loaded in
  its own AssemblyLoadContext; after 3 failed starts the built-in version runs
- Panel: Update page, sidebar badge, full-screen progress overlay (download,
  build, install, restart) that survives the restart and reloads the panel
- Panel sessions survive restarts (hashed tokens in data/panel_sessions.json)
- Docker image is based on the .NET SDK so it can build updates
- Version 2.9.0

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-01 21:07:09 +02:00
co-authored by Claude Opus 5.5
parent 1a2431db3a
commit 1ab9a01efb
12 changed files with 1084 additions and 13 deletions
+61
View File
@@ -231,3 +231,64 @@ input[type=file]::file-selector-button { background: var(--panel); color: var(--
th, td { padding: 10px 12px; }
.console { height: 55vh; }
}
/* ---------------------------------------------------------------- Updates */
.nav-badge { margin-left: auto; font-size: 10px; font-weight: 800; text-transform: uppercase; letter-spacing: .05em; padding: 2px 7px;
border-radius: 999px; background: var(--accent); color: #fff; box-shadow: 0 0 0 3px rgba(242, 107, 29, .18); }
.card.accent { border-color: rgba(242, 107, 29, .45); background: linear-gradient(180deg, rgba(242, 107, 29, .10), transparent 70%), var(--panel); }
.notice.ok { background: rgba(61, 214, 140, .07); border-color: rgba(61, 214, 140, .3); }
.notice.update-notice { background: rgba(242, 107, 29, .08); border-color: rgba(242, 107, 29, .35); }
.warn-text { color: var(--pending); margin-top: 4px; }
.update-body > * + * { margin-top: 14px; }
.small-print { font-size: 12.5px; margin-bottom: 0; }
.changes { list-style: none; margin: 0; padding: 0; display: grid; }
.changes li { display: grid; grid-template-columns: auto 1fr auto; gap: 12px; align-items: baseline; padding: 8px 4px; border-bottom: 1px solid var(--border); }
.changes li:last-child { border-bottom: 0; }
.change-sha { color: var(--accent); font-size: 12px; font-family: var(--mono); }
.change-title { overflow-wrap: anywhere; }
@media (max-width: 560px) { .changes li { grid-template-columns: auto 1fr; } .changes li small { grid-column: 2; } }
.update-overlay { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center; padding: 16px;
background: radial-gradient(1200px 600px at 50% 0%, rgba(242, 107, 29, .14), transparent 60%), rgba(10, 10, 13, .92);
backdrop-filter: blur(6px); animation: us-in .2s ease-out; }
@keyframes us-in { from { opacity: 0; } to { opacity: 1; } }
.update-card { width: min(640px, 100%); max-height: calc(100vh - 32px); overflow: auto; background: var(--panel); border: 1px solid var(--border);
border-radius: 16px; padding: 24px; box-shadow: 0 30px 80px rgba(0, 0, 0, .5); display: grid; gap: 16px; }
.update-card.success { border-color: rgba(61, 214, 140, .45); }
.us-head { display: flex; align-items: center; gap: 14px; }
.us-head img { width: 44px; height: 44px; border-radius: 10px; }
.us-versions { color: var(--muted); margin-top: 2px; }
.us-versions .mono { color: var(--text); font-family: var(--mono); }
.us-steps { list-style: none; margin: 4px 0 0; padding: 0; display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
.us-steps li { display: grid; justify-items: center; gap: 6px; font-size: 12.5px; color: var(--muted); text-align: center; position: relative; }
.us-steps li::before { content: ""; position: absolute; top: 11px; left: calc(-50% + 16px); right: calc(50% + 16px); height: 2px; background: var(--border); }
.us-steps li:first-child::before { display: none; }
.us-dot { width: 24px; height: 24px; border-radius: 50%; border: 2px solid var(--border); background: var(--panel-2); display: grid; place-items: center;
font-size: 12px; font-weight: 800; color: #fff; position: relative; z-index: 1; }
.us-steps li.done { color: var(--text); }
.us-steps li.done .us-dot { background: var(--online); border-color: var(--online); }
.us-steps li.done .us-dot::after { content: "✓"; }
.us-steps li.done::before, .us-steps li.active::before, .us-steps li.failed::before { background: var(--online); }
.us-steps li.active { color: #fff; font-weight: 700; }
.us-steps li.active .us-dot { border-color: var(--accent); box-shadow: 0 0 0 4px rgba(242, 107, 29, .2); animation: us-pulse 1.4s ease-in-out infinite; }
.us-steps li.active .us-dot::after { content: ""; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.us-steps li.failed { color: #ff8a8d; font-weight: 700; }
.us-steps li.failed .us-dot { background: var(--danger); border-color: var(--danger); }
.us-steps li.failed .us-dot::after { content: "!"; }
@keyframes us-pulse { 50% { box-shadow: 0 0 0 7px rgba(242, 107, 29, .08); } }
.us-bar { height: 10px; border-radius: 999px; background: var(--panel-2); overflow: hidden; border: 1px solid var(--border); }
.us-fill { height: 100%; width: 0; border-radius: 999px; transition: width .6s ease;
background: linear-gradient(90deg, #e5561a, #ffa24d, #e5561a); background-size: 200% 100%; animation: us-flow 2s linear infinite; }
.update-card.success .us-fill { background: var(--online); animation: none; }
@keyframes us-flow { to { background-position: -200% 0; } }
.us-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.us-msg { min-width: 0; overflow-wrap: anywhere; }
.us-pct, .us-clock { font-variant-numeric: tabular-nums; }
.us-log { height: 240px; min-height: 0; }
.update-card .buttons { display: flex; justify-content: flex-end; gap: 10px; }
.update-card .buttons:empty { display: none; }
@media (max-width: 560px) {
.update-card { padding: 18px; }
.us-steps li { font-size: 11px; }
}
@media (prefers-reduced-motion: reduce) { .us-fill, .us-steps li.active .us-dot, .update-overlay { animation: none; } }
+212 -1
View File
@@ -172,6 +172,7 @@ const PAGES = [
{ 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 },
];
@@ -183,7 +184,8 @@ function renderNav() {
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)));
h("span", { class: "ico" }, p.icon), h("span", { class: "nav-label" }, p.label),
p.id === "update" && state.updateAvailable ? h("span", { class: "nav-badge", title: "Update available" }, "new") : null));
}
nav.replaceChildren(...items);
$("meButton").classList.toggle("active", state.page === "account");
@@ -198,6 +200,7 @@ function go(pageId) {
document.querySelector(".sidebar").classList.remove("open");
clearTimers();
every(5000, refreshTopbar);
every(30000, refreshUpdateState);
renderNav();
const page = $("page");
page.replaceChildren();
@@ -812,6 +815,214 @@ function renderAccount(page) {
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() {
+62
View File
@@ -13,6 +13,53 @@
window.MSCO_LANG = lang;
const DE = {
// Updates
"Update": "Update",
"Update available": "Update verfügbar",
"new": "neu",
"Server update": "Server-Update",
"⟳ Check for updates": "⟳ Nach Updates suchen",
"What's new": "Neuerungen",
"Installed": "Installiert",
"Newest": "Neueste",
"Last check": "Letzte Prüfung",
"not checked yet": "noch nicht geprüft",
"automatically every 6 hours": "automatisch alle 6 Stunden",
"Check failed": "Prüfung fehlgeschlagen",
"The last update failed": "Das letzte Update ist fehlgeschlagen",
"Show log": "Log anzeigen",
"⇪ Install update": "⇪ Update installieren",
"Update from the panel not possible": "Update über das Panel nicht möglich",
"The server is up to date": "Der Server ist aktuell",
"Source: ": "Quelle: ",
"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.":
"Der Server lädt die neue Version herunter, baut sie (dauert ein, zwei Minuten) und startet dann neu. Spieler werden vorgewarnt und verbinden sich automatisch neu. Das Panel zeigt den Fortschritt und lädt sich danach selbst neu.",
"Update now": "Jetzt updaten",
"Update started": "Update gestartet",
"Updating the server": "Server wird aktualisiert",
"Download": "Download",
"Build": "Bauen",
"Install": "Installieren",
"Show build log": "Build-Log anzeigen",
"Hide build log": "Build-Log ausblenden",
"Update failed": "Update fehlgeschlagen",
"The update failed - the server keeps running the old version": "Das Update ist fehlgeschlagen - der Server läuft mit der alten Version weiter",
"Close": "Schließen",
"Waiting for the server to come back …": "Warte, bis der Server wieder da ist …",
"The server has not come back yet. Docker/systemd restart it automatically - otherwise start it again.":
"Der Server ist noch nicht wieder da. Docker/systemd starten ihn automatisch neu - sonst bitte von Hand starten.",
"Checking the newest version": "Prüfe die neueste Version",
"Download complete": "Download fertig",
"Building (dotnet publish) - this takes a minute": "Wird gebaut (dotnet publish) - das dauert etwa eine Minute",
"Building - packages restored": "Wird gebaut - Pakete geladen",
"Building - compiled": "Wird gebaut - kompiliert",
"Building - published": "Wird gebaut - veröffentlicht",
"Build complete": "Build fertig",
"Installing": "Wird installiert",
"An update is already running": "Es läuft schon ein Update",
"No update available - check again first": "Kein Update verfügbar - bitte zuerst erneut prüfen",
"No update available anymore": "Kein Update mehr verfügbar",
// Anmeldung
"Sign in": "Anmelden",
"Set up the panel": "Panel einrichten",
@@ -337,6 +384,21 @@
"Noch kein Spielstand - lade einen als ZIP/RAR im Panel hoch (Welt & Backups) oder kopiere ihn nach data/save"],
[/^No license key set - (.+)$/, "Kein Lizenzschlüssel gesetzt - hol dir einen unter msco.mbm.wtf/keys und trag ihn in den Einstellungen ein"],
[/^License key invalid: (.+)$/, "Lizenzschlüssel ungültig: $1"],
[/^Update available: (.+)$/, "Update verfügbar: $1"],
[/^Version (.+) is available$/, "Version $1 ist verfügbar"],
[/^Version ([0-9.]+)$/, "Version $1"],
[/^Install update (.+)\?$/, "Update $1 installieren?"],
[/^Players then need MSCO mod (.+) \(currently (.+)\)$/, "Spieler brauchen danach die MSCO-Mod $1 (aktuell $2)"],
[/^for MSCO mod (.+)$/, "für MSCO-Mod $1"],
[/^Downloading MSCO server (.+)$/, "Lade MSCO-Server $1 herunter"],
[/^Downloading … (.+)$/, "Lade herunter … $1"],
[/^Restarting with version (.+)$/, "Neustart mit Version $1"],
[/^Server updated to (.+) - reloading the panel …$/, "Server auf $1 aktualisiert - Panel wird neu geladen …"],
[/^The server restarted but still runs version (.+)\. The update could not be started - see the console\.$/,
"Der Server wurde neu gestartet, läuft aber noch mit Version $1. Das Update konnte nicht gestartet werden - siehe Konsole."],
[/^Could not check for updates: (.+)$/, "Konnte nicht nach Updates suchen: $1"],
[/^No \.NET (\d+) SDK found - (.+)$/, "Kein .NET-$1-SDK gefunden - es wird zum Bauen des Updates gebraucht. Mit Docker: einmal die neue Version holen (git pull && docker compose up -d --build), deren Image enthält das SDK."],
[/^The dotnet command was not found (.+)$/, "Der Befehl dotnet wurde nicht gefunden $1 - installiere das .NET SDK, um über das Panel zu updaten."],
[/^Master server not reachable (.+)$/, "Master-Server nicht erreichbar $1"],
[/^Crashed: (.+)$/, "Abgestürzt: $1"],
[/^The master server (rejects the license key|is not reachable) \(last confirmed (.+) ago\) - the server keeps running until the next restart$/,