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
+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() {