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:
+212
-1
@@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user