Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
220 changes: 220 additions & 0 deletions Overlord-Server/public/assets/settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,22 @@ const appearancePermissionNote = document.getElementById("appearance-permission-
const appearanceSaveBtn = document.getElementById("appearance-save-btn");
const appearanceCustomCssInput = document.getElementById("appearance-custom-css");

const updateSection = document.getElementById("update-section");
const updateVersionLabel = document.getElementById("update-version-label");
const updateStatusText = document.getElementById("update-status-text");
const updateCheckBtn = document.getElementById("update-check-btn");
const updateAvailablePanel = document.getElementById("update-available-panel");
const updateNewVersion = document.getElementById("update-new-version");
const updateApplyBtn = document.getElementById("update-apply-btn");
const updateUpToDatePanel = document.getElementById("update-up-to-date-panel");
const updateProgressPanel = document.getElementById("update-progress-panel");
const updateProgressTitle = document.getElementById("update-progress-title");
const updateProgressBar = document.getElementById("update-progress-bar");
const updateProgressMessage = document.getElementById("update-progress-message");
const updateProgressLog = document.getElementById("update-progress-log");
const updateErrorPanel = document.getElementById("update-error-panel");
const updateErrorText = document.getElementById("update-error-text");

const exportImportSection = document.getElementById("export-import-section");
const exportSettingsBtn = document.getElementById("export-settings-btn");
const importSettingsFile = document.getElementById("import-settings-file");
Expand Down Expand Up @@ -827,6 +843,209 @@ async function wipeOfflineClients() {
}
}

// ---- In-app update ----

let latestUpdateCheck = null;
let updatePollTimer = null;

function hideAllUpdatePanels() {
[updateAvailablePanel, updateUpToDatePanel, updateProgressPanel, updateErrorPanel].forEach(
(el) => el && el.classList.add("hidden"),
);
}

async function checkForUpdates() {
if (!updateCheckBtn) return;
updateCheckBtn.disabled = true;
updateCheckBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-1"></i>Checking...';
hideAllUpdatePanels();

try {
const res = await fetch("/api/update/check", { credentials: "include" });
const data = await res.json().catch(() => ({}));

if (!res.ok) {
if (updateErrorPanel && updateErrorText) {
updateErrorText.textContent = data.error || "Update check failed.";
updateErrorPanel.classList.remove("hidden");
}
return;
}

latestUpdateCheck = data;
if (updateVersionLabel) updateVersionLabel.textContent = data.currentVersion || "-";
if (updateStatusText) updateStatusText.textContent = `Last checked: ${new Date().toLocaleTimeString()}`;

if (data.updateAvailable) {
if (updateNewVersion) updateNewVersion.textContent = data.latestVersion;
if (updateAvailablePanel) updateAvailablePanel.classList.remove("hidden");
} else {
if (updateUpToDatePanel) updateUpToDatePanel.classList.remove("hidden");
}
} catch (err) {
if (updateErrorPanel && updateErrorText) {
updateErrorText.textContent = `Network error: ${err.message || err}`;
updateErrorPanel.classList.remove("hidden");
}
} finally {
updateCheckBtn.disabled = false;
updateCheckBtn.innerHTML = '<i class="fa-solid fa-magnifying-glass mr-1"></i>Check for Updates';
}
}

async function applyUpdate() {
if (!latestUpdateCheck?.latestVersion) return;
if (!confirm(`Update Overlord to version ${latestUpdateCheck.latestVersion}?\n\nThe server will restart during the update. You may briefly lose connection.`)) return;

if (updateApplyBtn) {
updateApplyBtn.disabled = true;
updateApplyBtn.innerHTML = '<i class="fa-solid fa-spinner fa-spin mr-1"></i>Requesting...';
}

try {
const res = await fetch("/api/update/start", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({ targetVersion: latestUpdateCheck.latestVersion }),
});

const data = await res.json().catch(() => ({}));
if (!res.ok) {
if (updateErrorPanel && updateErrorText) {
updateErrorText.textContent = data.error || "Failed to start update.";
updateErrorPanel.classList.remove("hidden");
}
return;
}

hideAllUpdatePanels();
if (updateProgressPanel) updateProgressPanel.classList.remove("hidden");
if (updateProgressMessage) updateProgressMessage.textContent = "Waiting for host updater to pick up the request...";
if (updateProgressBar) updateProgressBar.style.width = "5%";
startUpdatePolling();
} catch (err) {
if (updateErrorPanel && updateErrorText) {
updateErrorText.textContent = `Network error: ${err.message || err}`;
updateErrorPanel.classList.remove("hidden");
}
} finally {
if (updateApplyBtn) {
updateApplyBtn.disabled = false;
updateApplyBtn.innerHTML = '<i class="fa-solid fa-rocket mr-1"></i>Apply Update';
}
}
}

function startUpdatePolling() {
if (updatePollTimer) clearInterval(updatePollTimer);
updatePollTimer = setInterval(pollUpdateStatus, 3000);
pollUpdateStatus();
}

async function pollUpdateStatus() {
try {
const res = await fetch("/api/update/status", { credentials: "include" });
if (!res.ok) return;
const status = await res.json().catch(() => null);
if (!status) return;

if (status.state === "idle" && status.updatedAt === 0) return;

hideAllUpdatePanels();
if (updateProgressPanel) updateProgressPanel.classList.remove("hidden");

if (updateProgressBar) updateProgressBar.style.width = `${status.progress || 0}%`;
if (updateProgressMessage) updateProgressMessage.textContent = status.message || "";

const stateLabels = {
pending: "Update pending...",
pulling: "Pulling new image from registry...",
restarting: "Restarting container...",
done: "Update complete!",
error: "Update failed",
};
if (updateProgressTitle) {
updateProgressTitle.textContent = stateLabels[status.state] || `State: ${status.state}`;
}

if (status.log && status.log.length && updateProgressLog) {
updateProgressLog.classList.remove("hidden");
updateProgressLog.textContent = status.log.join("\n");
updateProgressLog.scrollTop = updateProgressLog.scrollHeight;
}

if (status.state === "done") {
if (updatePollTimer) clearInterval(updatePollTimer);
await fetch("/api/update/reset", { method: "POST", credentials: "include" }).catch(() => {});
hideAllUpdatePanels();
const toast = document.createElement("p");
toast.className = "text-sm text-emerald-400 flex items-center gap-2";
toast.innerHTML = '<i class="fa-solid fa-circle-check"></i>Update complete!';
updateSection?.prepend(toast);
setTimeout(() => toast.remove(), 4000);
}

if (status.state === "error") {
if (updatePollTimer) clearInterval(updatePollTimer);
hideAllUpdatePanels();
if (updateErrorPanel && updateErrorText) {
updateErrorText.textContent = status.message || "Update failed. Check logs.";
updateErrorPanel.classList.remove("hidden");
}
}
} catch {
// Server may be restarting - keep polling
}
}

async function initUpdateSection() {
if (!isAdmin(currentUser?.role) || !updateSection) return;
updateSection.classList.remove("hidden");

// Fetch current version
try {
const res = await fetch("/api/version", { credentials: "include" });
if (res.ok) {
const data = await res.json();
if (updateVersionLabel) updateVersionLabel.textContent = data.version || "-";
}
} catch {}

// Check if an update is already in progress or recently completed
try {
const res = await fetch("/api/update/status", { credentials: "include" });
if (res.ok) {
const status = await res.json();
if (status.state === "done") {
// Clear the status file so future page loads start fresh
await fetch("/api/update/reset", { method: "POST", credentials: "include" }).catch(() => {});
// Briefly show a success toast then restore the normal check-for-updates UI
hideAllUpdatePanels();
const toast = document.createElement("p");
toast.className = "text-sm text-emerald-400 flex items-center gap-2";
toast.innerHTML = '<i class="fa-solid fa-circle-check"></i>Last update completed successfully.';
updateSection?.prepend(toast);
setTimeout(() => toast.remove(), 4000);
} else if (status.state === "error") {
hideAllUpdatePanels();
if (updateErrorPanel && updateErrorText) {
updateErrorText.textContent = status.message || "Last update failed.";
updateErrorPanel.classList.remove("hidden");
}
} else if (status.state && status.state !== "idle") {
// In-progress — resume live polling
hideAllUpdatePanels();
if (updateProgressPanel) updateProgressPanel.classList.remove("hidden");
startUpdatePolling();
}
}
} catch {}

if (updateCheckBtn) updateCheckBtn.addEventListener("click", checkForUpdates);
if (updateApplyBtn) updateApplyBtn.addEventListener("click", applyUpdate);
}

async function init() {
try {
await loadCurrentUser();
Expand All @@ -840,6 +1059,7 @@ async function init() {
wipeOfflineSection.classList.remove("hidden");
}

await initUpdateSection();
await loadSecurityPolicy();
await loadTlsSettings();
await loadAppearanceSettings();
Expand Down
23 changes: 23 additions & 0 deletions Overlord-Server/public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,11 @@
</head>
<body class="min-h-screen bg-slate-950 text-slate-100">
<header id="top-nav"></header>
<div id="update-banner" class="hidden bg-green-900/80 border-b border-green-700/60 px-4 py-2 text-center text-sm">
<i class="fa-solid fa-circle-up text-green-400 mr-1"></i>
<span class="text-green-100">A new version of Overlord is available: <strong id="update-banner-version"></strong></span>
<a href="/settings" class="ml-2 text-green-300 hover:text-green-100 underline font-medium">Update now</a>
</div>
<main class="px-5 py-6">
<div id="clients-shell" class="w-full flex flex-col gap-5">
<div class="flex items-center justify-between gap-3 flex-wrap">
Expand Down Expand Up @@ -177,5 +182,23 @@ <h1 class="text-2xl font-semibold">Clients</h1>
<script src="/assets/toast.js"></script>
<script src="/assets/ripple.js"></script>
<script type="module" src="/assets/main.js"></script>
<script>
// Background update check for admin users
(async function checkUpdateBanner() {
try {
const me = await fetch("/api/auth/me", { credentials: "include" }).then(r => r.ok ? r.json() : null);
if (!me || me.role !== "admin") return;
const data = await fetch("/api/update/check", { credentials: "include" }).then(r => r.ok ? r.json() : null);
if (data && data.updateAvailable) {
const banner = document.getElementById("update-banner");
const versionEl = document.getElementById("update-banner-version");
if (banner && versionEl) {
versionEl.textContent = "v" + data.latestVersion;
banner.classList.remove("hidden");
}
}
} catch {}
})();
</script>
</body>
</html>
70 changes: 70 additions & 0 deletions Overlord-Server/public/settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,76 @@ <h2 class="text-lg font-semibold">News &amp; Updates</h2>
</div>
</section>

<section id="update-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-green-500/10 border border-green-500/30">
<i class="fa-solid fa-cloud-arrow-down text-green-400"></i>
</div>
<div>
<h2 class="text-lg font-semibold">Software Update</h2>
<p class="text-sm text-slate-400">Check for and install Overlord updates from the container registry.</p>
</div>
</div>

<div id="update-current-version" class="rounded-lg border border-slate-700 bg-slate-900 px-4 py-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-slate-300">Current version: <span id="update-version-label" class="font-mono font-semibold text-slate-100">-</span></p>
<p id="update-status-text" class="text-xs text-slate-500 mt-0.5">Click "Check for Updates" to see if a new version is available.</p>
</div>
<button
id="update-check-btn"
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-700 hover:bg-green-600 transition-colors text-white text-sm font-medium whitespace-nowrap"
>
<i class="fa-solid fa-magnifying-glass"></i>
Check for Updates
</button>
</div>
</div>

<div id="update-available-panel" class="hidden rounded-lg border border-green-700/50 bg-green-900/20 px-4 py-4 space-y-3">
<div class="flex items-center justify-between">
<div>
<p class="text-sm font-semibold text-green-200">
<i class="fa-solid fa-circle-up mr-1"></i>
Update available: <span id="update-new-version" class="font-mono">-</span>
</p>
</div>
<button
id="update-apply-btn"
type="button"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-green-600 hover:bg-green-500 transition-colors text-white text-sm font-medium whitespace-nowrap"
>
<i class="fa-solid fa-rocket"></i>
Apply Update
</button>
</div>
</div>

<div id="update-up-to-date-panel" class="hidden rounded-lg border border-slate-700 bg-slate-900 px-4 py-3">
<p class="text-sm text-emerald-300"><i class="fa-solid fa-circle-check mr-1"></i> You are running the latest version.</p>
</div>

<div id="update-progress-panel" class="hidden rounded-lg border border-sky-700/50 bg-sky-900/20 px-4 py-4 space-y-3">
<div class="flex items-center gap-2">
<i class="fa-solid fa-spinner fa-spin text-sky-400"></i>
<p id="update-progress-title" class="text-sm font-semibold text-sky-200">Update in progress...</p>
</div>
<div class="w-full bg-slate-800 rounded-full h-2.5">
<div id="update-progress-bar" class="bg-sky-500 h-2.5 rounded-full transition-all duration-500" style="width: 0%"></div>
</div>
<p id="update-progress-message" class="text-xs text-slate-400"></p>
<pre id="update-progress-log" class="hidden text-xs text-slate-400 bg-slate-950 rounded-lg p-3 max-h-48 overflow-y-auto font-mono"></pre>
</div>

<div id="update-error-panel" class="hidden rounded-lg border border-red-700/50 bg-red-900/20 px-4 py-3">
<p class="text-sm text-red-300"><i class="fa-solid fa-triangle-exclamation mr-1"></i> <span id="update-error-text"></span></p>
</div>

<p id="update-message" class="hidden text-sm rounded-lg px-3 py-2 border"></p>
</section>

<section id="export-import-section" class="hidden bg-slate-900/60 border border-slate-800 rounded-xl p-5 space-y-4">
<div class="flex items-center gap-3">
<div class="flex items-center justify-center w-9 h-9 rounded-lg bg-amber-500/10 border border-amber-500/30">
Expand Down
Loading