The update dialog kept the old bare title + spinner layout from the previous theme. Rework it (and the rebuild dialog, for consistency) to the approved The Hub dialog anatomy: - Header: green chip icon, title, version chip, status pill (Checking… / Up to date / Updating… / Restart required / Update failed / Status unknown) and a header close button. A spinner appears in the header while an update is starting or running. - System Details card: current version, release channel, and last checked (relative time, refreshed on every check). - The log renders as a console with green "ok" and dim hint lines; the up-to-date result shows as a single green console line exactly once (the redundant status line is hidden in that state). - Footer: Close plus a "Check again" primary action that re-runs the update check. Close and Check again are disabled while a check or update is in flight; all existing recovery actions (Save Error Report, Retry Status, Retry Update, Restart Entire System) keep their exact semantics. - Opening the dialog now shows a "Checking…" state immediately while the existing reattach-then-check logic runs; reattaching to an in-progress update (page reload, RDP reconnect) is unchanged. Sidebar Update System status tints now use the theme palette (red / amber / blue / green), and the periodic background check refreshes the dialog's "last checked" value. Rebuild dialog gets the same header (icon, version chip, Applying… / Done / Restart required / Failed pill, header close disabled while a rebuild runs); its log stays hidden as before. No API or state-machine changes: same endpoints, same polling, same reattach and recovery behavior.
501 lines
18 KiB
JavaScript
501 lines
18 KiB
JavaScript
"use strict";
|
|
|
|
// ── Update modal ──────────────────────────────────────────────────
|
|
|
|
// ── Update dialog presentation (pill, status line, last-checked) ──
|
|
|
|
var _updateLastCheckTs = 0;
|
|
|
|
function markUpdateChecked() {
|
|
_updateLastCheckTs = Date.now();
|
|
}
|
|
|
|
function _updateLastCheckedText() {
|
|
if (!$updLastChecked) return;
|
|
if (!_updateLastCheckTs) { $updLastChecked.textContent = "—"; return; }
|
|
var s = Math.floor((Date.now() - _updateLastCheckTs) / 1000);
|
|
if (s < 30) { $updLastChecked.textContent = "just now"; return; }
|
|
var m = Math.floor(s / 60);
|
|
if (m < 60) { $updLastChecked.textContent = m + (m === 1 ? " minute ago" : " minutes ago"); return; }
|
|
var h = Math.floor(m / 60);
|
|
$updLastChecked.textContent = h + (h === 1 ? " hour ago" : " hours ago");
|
|
}
|
|
|
|
function setUpdatePill(state) {
|
|
if (!$updPill) return;
|
|
var cls = "upd-pill";
|
|
var html;
|
|
if (state === "checking") {
|
|
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Checking…';
|
|
} else if (state === "uptodate") {
|
|
cls += " st-active"; html = '<span class="status-dot active"></span>Up to date';
|
|
} else if (state === "complete") {
|
|
cls += " st-active"; html = '<span class="status-dot active"></span>Update complete';
|
|
} else if (state === "reboot") {
|
|
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Restart required';
|
|
} else if (state === "failed") {
|
|
cls += " st-failed"; html = '<span class="status-dot failed"></span>Update failed';
|
|
} else if (state === "unavailable") {
|
|
cls += " st-needs-attention"; html = '<span class="status-dot needs-attention pulse"></span>Status unknown';
|
|
} else {
|
|
cls += " st-loading"; html = '<span class="status-dot loading pulse"></span>Updating…';
|
|
}
|
|
$updPill.className = cls;
|
|
$updPill.innerHTML = html;
|
|
}
|
|
|
|
// Single place that reflects update state in the dialog: status message
|
|
// text + color, header pill, and the Close / Check again availability.
|
|
function _setUpdateStatus(text) {
|
|
if ($modalStatus) $modalStatus.textContent = text;
|
|
var state;
|
|
if (text.charAt(0) === "✗") state = "failed";
|
|
else if (text.indexOf("restart required") !== -1) state = "reboot";
|
|
else if (text.charAt(0) === "✓") state = (text.indexOf("already up to date") !== -1) ? "uptodate" : "complete";
|
|
else if (text.indexOf("unavailable") !== -1) state = "unavailable";
|
|
else if (text.indexOf("Checking") === 0) state = "checking";
|
|
else state = "updating";
|
|
if ($modalStatus) {
|
|
var msg = "update-status-msg";
|
|
if (state === "failed") $modalStatus.className = msg + " st-err";
|
|
else if (state === "reboot" || state === "unavailable") $modalStatus.className = msg + " st-warn";
|
|
else if (state === "uptodate" || state === "complete") $modalStatus.className = msg + " st-ok";
|
|
else $modalStatus.className = msg;
|
|
// In the up-to-date state the green console line already says it —
|
|
// the mockup shows it exactly once.
|
|
$modalStatus.style.display = (state === "uptodate") ? "none" : "";
|
|
}
|
|
setUpdatePill(state);
|
|
var interactive = (state !== "updating");
|
|
if ($updateCloseBtn) $updateCloseBtn.disabled = !interactive;
|
|
if ($btnCheckAgain) {
|
|
$btnCheckAgain.disabled = (state === "checking" || state === "updating");
|
|
$btnCheckAgain.style.display = interactive ? "inline-flex" : "none";
|
|
}
|
|
_updateLastCheckedText();
|
|
}
|
|
|
|
async function openUpdateModal() {
|
|
if (!$modal) return;
|
|
|
|
// Open immediately in a checking state; the status/check requests below
|
|
// fill in the real result (mockup: auto-check on open).
|
|
$modal.classList.add("open");
|
|
if ($modalLog) $modalLog.innerHTML = '<span class="dim">Checking for updates…</span>';
|
|
_setUpdateStatus("Checking for updates…");
|
|
|
|
// Reattach before checking for new updates. This makes a browser reload,
|
|
// RDP reconnect, or suspended tab recover the authoritative systemd-backed
|
|
// state instead of starting over or claiming the system is merely up to date.
|
|
try {
|
|
var current = await apiFetchWithTimeout(
|
|
"/api/updates/status?offset=0",
|
|
{ cache: "no-store" },
|
|
STATUS_POLL_FETCH_TIMEOUT
|
|
);
|
|
if (current.running || current.result === "reboot_required" || current.result === "failed") {
|
|
// An in-progress update, a staged update awaiting reboot, or a prior
|
|
// failed update — reattach to the persisted systemd/log state instead of
|
|
// starting over or wrongly reporting "up to date".
|
|
showExistingUpdate(current);
|
|
return;
|
|
}
|
|
} catch (_) {
|
|
// The normal start path below has its own visible error handling.
|
|
}
|
|
|
|
apiFetchWithTimeout(
|
|
"/api/updates/check",
|
|
{ cache: "no-store" },
|
|
STATUS_POLL_FETCH_TIMEOUT
|
|
)
|
|
.then(function(data) {
|
|
markUpdateChecked();
|
|
if (!data.available) {
|
|
stopUpdatePoll();
|
|
_updateLog = "";
|
|
_updateLogOffset = 0;
|
|
_updateVisibleLogChars = 0;
|
|
_updateFinished = true;
|
|
_updateStatusUnavailable = false;
|
|
if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
|
|
_setUpdateStatus("✓ System is already up to date");
|
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
|
if ($btnReboot) $btnReboot.style.display = "none";
|
|
if ($btnSave) $btnSave.style.display = "none";
|
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
|
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
|
$modal.classList.add("open");
|
|
return;
|
|
}
|
|
_doOpenUpdateModal();
|
|
})
|
|
.catch(function() {
|
|
_doOpenUpdateModal();
|
|
});
|
|
}
|
|
|
|
function prepareUpdateModal() {
|
|
if (!$modal) return;
|
|
stopUpdatePoll();
|
|
_updateLog = "";
|
|
_updateLogOffset = 0;
|
|
_updateVisibleLogChars = 0;
|
|
_updatePollInFlight = false;
|
|
_serverWasDown = false;
|
|
_updateFinished = false;
|
|
_updateStatusUnavailable = false;
|
|
_updatePollFailures = 0;
|
|
if ($modalLog) $modalLog.textContent = "";
|
|
_setUpdateStatus("Starting update…");
|
|
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
|
if ($btnReboot) $btnReboot.style.display = "none";
|
|
if ($btnSave) $btnSave.style.display = "none";
|
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
|
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
|
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
|
$modal.classList.add("open");
|
|
}
|
|
|
|
function _doOpenUpdateModal() {
|
|
prepareUpdateModal();
|
|
startUpdate();
|
|
}
|
|
|
|
function showExistingUpdate(data) {
|
|
prepareUpdateModal();
|
|
if (data.log) appendLog(data.log);
|
|
_updateLogOffset = Number(data.offset) || 0;
|
|
|
|
if (data.running) {
|
|
_setUpdateStatus("Updating…");
|
|
startUpdatePoll();
|
|
return;
|
|
}
|
|
|
|
_updateFinished = true;
|
|
if (data.result === "reboot_required") {
|
|
onUpdateDone("reboot_required");
|
|
} else if (data.result === "success") {
|
|
onUpdateDone(true);
|
|
} else {
|
|
onUpdateDone(false);
|
|
}
|
|
}
|
|
|
|
async function restoreUpdateModalIfNeeded() {
|
|
if (!$modal || $modal.classList.contains("open")) return;
|
|
try {
|
|
var data = await apiFetchWithTimeout(
|
|
"/api/updates/status?offset=0",
|
|
{ cache: "no-store" },
|
|
STATUS_POLL_FETCH_TIMEOUT
|
|
);
|
|
if (data.running || data.result === "reboot_required") {
|
|
showExistingUpdate(data);
|
|
}
|
|
} catch (_) {
|
|
// Dashboard startup must remain usable when status cannot be reached.
|
|
}
|
|
}
|
|
|
|
function closeUpdateModal() {
|
|
if (!$modal) return;
|
|
$modal.classList.remove("open");
|
|
stopUpdatePoll();
|
|
}
|
|
|
|
function appendLog(text) {
|
|
if (!text) return;
|
|
_updateLog += text;
|
|
if ($modalLog) {
|
|
// Appending a text node avoids reparsing/replacing the complete log on
|
|
// every two-second poll. Trim only occasionally once the visible log is
|
|
// large; the complete _updateLog remains available for error reports.
|
|
if (_updateVisibleLogChars + text.length > UPDATE_VISIBLE_LOG_MAX_CHARS) {
|
|
var tail = _updateLog.slice(-UPDATE_VISIBLE_LOG_TRIM_CHARS);
|
|
var notice = "[Earlier update output hidden from this view; it remains in the saved report.]\n\n";
|
|
$modalLog.textContent = notice + tail;
|
|
_updateVisibleLogChars = notice.length + tail.length;
|
|
} else {
|
|
$modalLog.appendChild(document.createTextNode(text));
|
|
_updateVisibleLogChars += text.length;
|
|
}
|
|
$modalLog.scrollTop = $modalLog.scrollHeight;
|
|
}
|
|
}
|
|
|
|
function startUpdate() {
|
|
apiFetchWithTimeout(
|
|
"/api/updates/run",
|
|
{ method: "POST" },
|
|
STATUS_POLL_FETCH_TIMEOUT * 2
|
|
)
|
|
.then(function(data) {
|
|
if (data.status === "no_updates") {
|
|
if ($modalLog) $modalLog.innerHTML = '<span class="ok">✓ System is already up to date</span>';
|
|
_setUpdateStatus("✓ System is already up to date");
|
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
|
if ($btnReboot) $btnReboot.style.display = "none";
|
|
if ($btnSave) $btnSave.style.display = "none";
|
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
|
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
|
_updateFinished = true;
|
|
return;
|
|
}
|
|
if (data.status === "already_running") appendLog("[Update already in progress, attaching…]\n\n");
|
|
_setUpdateStatus("Updating…");
|
|
startUpdatePoll();
|
|
})
|
|
.catch(function(err) {
|
|
appendLog("[Error: failed to start update — " + err + "]\n");
|
|
onUpdateDone(false);
|
|
});
|
|
}
|
|
|
|
function startUpdatePoll() {
|
|
if (_updatePollTimer) clearInterval(_updatePollTimer);
|
|
pollUpdateStatus();
|
|
_updatePollTimer = setInterval(pollUpdateStatus, UPDATE_POLL_INTERVAL);
|
|
}
|
|
|
|
function stopUpdatePoll() {
|
|
if (_updatePollTimer) { clearInterval(_updatePollTimer); _updatePollTimer = null; }
|
|
}
|
|
|
|
async function pollUpdateStatus() {
|
|
// setInterval does not wait for an async callback. The guard prevents a slow
|
|
// request from creating overlapping, out-of-order status polls.
|
|
if (_updateFinished || _updatePollInFlight) return;
|
|
_updatePollInFlight = true;
|
|
try {
|
|
var data = await apiFetchWithTimeout(
|
|
"/api/updates/status?offset=" + _updateLogOffset,
|
|
{ cache: "no-store" },
|
|
STATUS_POLL_FETCH_TIMEOUT
|
|
);
|
|
_updatePollFailures = 0;
|
|
if (_serverWasDown) {
|
|
_serverWasDown = false;
|
|
if (!data.running) {
|
|
// The update finished while the server or browser connection was away.
|
|
// Re-fetch from offset 0 so the final result and complete tail agree.
|
|
_updateLog = "";
|
|
_updateLogOffset = 0;
|
|
_updateVisibleLogChars = 0;
|
|
if ($modalLog) $modalLog.textContent = "";
|
|
try {
|
|
var fullData = await apiFetchWithTimeout(
|
|
"/api/updates/status?offset=0",
|
|
{ cache: "no-store" },
|
|
STATUS_POLL_FETCH_TIMEOUT
|
|
);
|
|
if (fullData.log) appendLog(fullData.log);
|
|
_updateLogOffset = fullData.offset;
|
|
data = fullData;
|
|
} catch (_) {
|
|
if (data.log) appendLog(data.log);
|
|
_updateLogOffset = data.offset;
|
|
}
|
|
if (data.result === "reboot_required") {
|
|
appendLog("[Reconnected — update completed, reboot required.]\n");
|
|
} else if (data.result === "success") {
|
|
appendLog("[Reconnected — update completed successfully.]\n");
|
|
} else {
|
|
appendLog("[Reconnected — update encountered an error.]\n");
|
|
}
|
|
_updateFinished = true;
|
|
stopUpdatePoll();
|
|
if (data.result === "reboot_required") {
|
|
onUpdateDone("reboot_required");
|
|
} else {
|
|
onUpdateDone(data.result === "success");
|
|
}
|
|
return;
|
|
}
|
|
appendLog("[Update status reconnected]\n");
|
|
_setUpdateStatus("Updating…");
|
|
}
|
|
if (data.log) appendLog(data.log);
|
|
_updateLogOffset = data.offset;
|
|
if (data.running) return;
|
|
_updateFinished = true;
|
|
stopUpdatePoll();
|
|
if (data.result === "reboot_required") {
|
|
onUpdateDone("reboot_required");
|
|
} else if (data.result === "success") {
|
|
onUpdateDone(true);
|
|
} else {
|
|
onUpdateDone(false);
|
|
}
|
|
} catch (err) {
|
|
_updatePollFailures += 1;
|
|
if (_updatePollFailures >= STATUS_POLL_MAX_FAILURES) {
|
|
showUpdateStatusUnavailable();
|
|
return;
|
|
}
|
|
if (!_serverWasDown) {
|
|
_serverWasDown = true;
|
|
appendLog("\n[Update status connection interrupted — retrying…]\n");
|
|
_setUpdateStatus("Reconnecting to update…");
|
|
}
|
|
} finally {
|
|
_updatePollInFlight = false;
|
|
}
|
|
}
|
|
|
|
function showUpdateStatusUnavailable() {
|
|
_updateFinished = true;
|
|
_updateStatusUnavailable = true;
|
|
stopUpdatePoll();
|
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
|
_setUpdateStatus("Update status unavailable — update may still be running");
|
|
appendLog("\n[The Hub could not confirm update status. The background update was not stopped. Select Retry Status after reconnecting.]\n");
|
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "inline-flex";
|
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
|
}
|
|
|
|
function retryUpdateStatus() {
|
|
if (!$modal) return;
|
|
_updateFinished = false;
|
|
_updateStatusUnavailable = false;
|
|
_updatePollFailures = 0;
|
|
_serverWasDown = true;
|
|
if ($modalSpinner) $modalSpinner.classList.add("spinning");
|
|
_setUpdateStatus("Reconnecting to update…");
|
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
|
if ($btnCloseModal) $btnCloseModal.disabled = true;
|
|
startUpdatePoll();
|
|
}
|
|
|
|
// Re-run a failed (or never-applied) update from scratch. The backend always
|
|
// allows this after a FAILED attempt even though flake.lock may already be
|
|
// advanced (the previous build never staged a bootable generation).
|
|
function retryUpdateRun() {
|
|
if ($btnRetryRun) $btnRetryRun.style.display = "none";
|
|
if ($btnSave) $btnSave.style.display = "none";
|
|
if ($btnReboot) $btnReboot.style.display = "none";
|
|
_doOpenUpdateModal();
|
|
}
|
|
|
|
function resumeUpdateStatusAfterInterruption() {
|
|
if (!$modal || !$modal.classList.contains("open")) return;
|
|
if (_updateStatusUnavailable) {
|
|
retryUpdateStatus();
|
|
} else if (!_updateFinished) {
|
|
pollUpdateStatus();
|
|
}
|
|
}
|
|
|
|
function onUpdateDone(result) {
|
|
_updateStatusUnavailable = false;
|
|
if ($modalSpinner) $modalSpinner.classList.remove("spinning");
|
|
if ($btnRetryUpdate) $btnRetryUpdate.style.display = "none";
|
|
if ($btnCloseModal) $btnCloseModal.disabled = false;
|
|
if (result === true) {
|
|
_setUpdateStatus("✓ Update complete");
|
|
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
|
} else if (result === "reboot_required") {
|
|
_setUpdateStatus("✓ Update complete — restart required");
|
|
if ($btnReboot) $btnReboot.style.display = "inline-flex";
|
|
} else {
|
|
_setUpdateStatus("✗ Update failed — your system was not changed. Run the update again or save the error report for support.");
|
|
if ($btnRetryRun) $btnRetryRun.style.display = "inline-flex";
|
|
if ($btnSave) $btnSave.style.display = "inline-flex";
|
|
if ($btnReboot) $btnReboot.style.display = "none";
|
|
}
|
|
}
|
|
|
|
function saveErrorReport() {
|
|
var blob = new Blob([_updateLog], { type: "text/plain" });
|
|
var url = URL.createObjectURL(blob);
|
|
var a = document.createElement("a");
|
|
a.href = url;
|
|
a.download = "sovran-update-error-" + new Date().toISOString().split(".")[0].replace(/:/g, "-") + ".txt";
|
|
document.body.appendChild(a);
|
|
a.click();
|
|
document.body.removeChild(a);
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
|
|
// ── Reboot ────────────────────────────────────────────────────────
|
|
|
|
var _rebootStartTime = 0;
|
|
var _serverWentDown = false;
|
|
var _rebootFailed = false;
|
|
|
|
function _setRebootStatus(msg) {
|
|
if ($rebootSubmessage) $rebootSubmessage.textContent = msg;
|
|
}
|
|
|
|
function doReboot() {
|
|
if ($modal) $modal.classList.remove("open");
|
|
if ($rebuildModal) $rebuildModal.classList.remove("open");
|
|
stopUpdatePoll();
|
|
stopRebuildPoll();
|
|
// Reset overlay to main card
|
|
if ($rebootMainCard) $rebootMainCard.style.display = "";
|
|
if ($rebootErrorCard) $rebootErrorCard.style.display = "none";
|
|
_setRebootStatus("Sending restart request\u2026");
|
|
if ($rebootOverlay) $rebootOverlay.classList.add("visible");
|
|
_rebootStartTime = Date.now();
|
|
_serverWentDown = false;
|
|
_rebootFailed = false;
|
|
var rebootCtrl = new AbortController();
|
|
setTimeout(function() { rebootCtrl.abort(); }, REBOOT_REQUEST_TIMEOUT);
|
|
fetch("/api/reboot", { method: "POST", signal: rebootCtrl.signal })
|
|
.then(function(res) {
|
|
if (!res.ok) {
|
|
// Definitive HTTP error — server rejected the request before going down
|
|
_rebootFailed = true;
|
|
if ($rebootMainCard) $rebootMainCard.style.display = "none";
|
|
if ($rebootErrorCard) $rebootErrorCard.style.display = "";
|
|
// Leave overlay visible so the error card is shown
|
|
}
|
|
// HTTP 2xx: request accepted, proceed with polling
|
|
})
|
|
.catch(function() {
|
|
// Connection dropped or request aborted — the server is likely already going
|
|
// down as part of the restart. Treat as success and continue polling.
|
|
});
|
|
// Wait before the first check — NixOS shutdown after an update can take 20-40s
|
|
setTimeout(waitForServerReboot, REBOOT_INITIAL_DELAY);
|
|
}
|
|
|
|
function waitForServerReboot() {
|
|
if (_rebootFailed) return;
|
|
// Update status on first check (server hasn't gone down yet)
|
|
if (!_serverWentDown) _setRebootStatus("Waiting for the computer to shut down\u2026");
|
|
var controller = new AbortController();
|
|
var timeoutId = setTimeout(function() { controller.abort(); }, REBOOT_FETCH_TIMEOUT);
|
|
|
|
fetch("/api/ping", { cache: "no-store", signal: controller.signal, headers: { "Connection": "close" } })
|
|
.then(function(res) {
|
|
clearTimeout(timeoutId);
|
|
if (_serverWentDown) {
|
|
// Server is responding after having been down — reboot is complete.
|
|
// Any response (even 401/500) means the server process is back.
|
|
_setRebootStatus("System is back online. Reconnecting\u2026");
|
|
window.location.reload();
|
|
} else if ((Date.now() - _rebootStartTime) < 90000) {
|
|
// Server still responding but hasn't gone down yet — keep waiting
|
|
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
|
} else {
|
|
// Been over 90 seconds and server is responding — just reload
|
|
_setRebootStatus("System is back online. Reconnecting\u2026");
|
|
window.location.reload();
|
|
}
|
|
})
|
|
.catch(function() {
|
|
clearTimeout(timeoutId);
|
|
if (!_serverWentDown) {
|
|
_serverWentDown = true;
|
|
_setRebootStatus("The computer is restarting\u2026");
|
|
}
|
|
setTimeout(waitForServerReboot, REBOOT_CHECK_INTERVAL);
|
|
});
|
|
}
|