The full-system updater runs as a detached systemd service and can finish successfully even when the browser loses its status connection. In that case the update log and status file correctly report REBOOT_REQUIRED, but the Hub modal can remain on "Updating..." with its controls disabled. There were four independent ways for the frontend to get stuck: * update status fetches had no deadline, so a request that stayed pending never rejected and never advanced the existing failure counter; * setInterval started async polls without waiting for the previous poll, allowing slow requests to overlap and responses to arrive out of order; * each log chunk used textContent +=, replacing the complete and growing Nix build log every two seconds, which could stall browser rendering and was especially visible over RDP; and * page reload, tab resume, and RDP reconnect did not reattach the modal to the update status persisted by the backend. This produced a dangerous UX mismatch: the machine had a fully staged NixOS generation and was ready to reboot, while the Hub continued telling the user that the update was still running. Bound status requests with AbortController, prevent overlapping polls, and replace the endless spinner after sustained failures with an explicit "Update status unavailable" state and Retry Status action. Reconcile state immediately on focus, visibility, online, page startup, and before starting a new update. Use no-store requests and render verbose logs incrementally with a bounded visible tail while retaining the complete report in memory. Apply the same timeout and single-flight protection to rebuild polling. Record the exact generation produced by `nixos-rebuild boot`. The Hub now keeps REBOOT_REQUIRED visible until that generation matches /run/current-system, then clears the marker after reboot. For an update started by an older updater that did not write the marker, recover the staged generation from the final nixos-rebuild log line. The dashboard sidebar also distinguishes update-in-progress and restart-required states. Regression coverage verifies generation marker/log recovery, pre- versus post-reboot detection, request timeout wiring, single-flight polling, connection-loss UX, RDP/tab resume reconciliation, bounded log rendering, page-reload recovery, and JavaScript syntax. Validation: * python3 -m unittest discover -s tests -p 'test_*.py' -v (170 passed) * node --check app/sovran_systemsos_web/static/js/*.js * python3 -m py_compile for changed Python modules * git diff --check A Nix evaluation was not available in the development sandbox; the NixOS module should still be evaluated and built in CI or on a test machine before release.
298 lines
13 KiB
JavaScript
298 lines
13 KiB
JavaScript
"use strict";
|
|
|
|
// ── Bitcoin IBD sync state (for ETA calculation) ──────────────────
|
|
// Keyed by tileId: { progress: float, timestamp: ms }
|
|
var _btcSyncPrev = {};
|
|
|
|
|
|
function _firstElementFromHtml(html) {
|
|
var tmp = document.createElement("div");
|
|
tmp.innerHTML = html;
|
|
return tmp.firstElementChild || null;
|
|
}
|
|
|
|
// ── Render: initial build ─────────────────────────────────────────
|
|
|
|
function buildTiles(services, categoryLabels) {
|
|
_servicesCache = services;
|
|
var grouped = {};
|
|
var supportServices = [];
|
|
for (var i = 0; i < services.length; i++) {
|
|
var svc = services[i];
|
|
// Support tiles go to the sidebar, not the main grid
|
|
if (svc.category === "support" || svc.type === "support") {
|
|
supportServices.push(svc);
|
|
continue;
|
|
}
|
|
var cat = svc.category || "other";
|
|
if (!grouped[cat]) grouped[cat] = [];
|
|
grouped[cat].push(svc);
|
|
}
|
|
renderSidebarSupport(supportServices);
|
|
$tilesArea.innerHTML = "";
|
|
var orderedKeys = CATEGORY_ORDER.filter(function(k) { return grouped[k]; });
|
|
Object.keys(grouped).forEach(function(k) {
|
|
if (orderedKeys.indexOf(k) === -1) orderedKeys.push(k);
|
|
});
|
|
for (var j = 0; j < orderedKeys.length; j++) {
|
|
var catKey = orderedKeys[j];
|
|
var entries = grouped[catKey];
|
|
if (!entries || entries.length === 0) continue;
|
|
var label = categoryLabels[catKey] || catKey;
|
|
var section = document.createElement("div");
|
|
section.className = "category-section";
|
|
section.dataset.category = catKey;
|
|
section.innerHTML = '<div class="section-header">' + escHtml(label) + '</div><hr class="section-divider" /><div class="tiles-grid" data-cat="' + escHtml(catKey) + '"></div>';
|
|
var grid = section.querySelector(".tiles-grid");
|
|
for (var k = 0; k < entries.length; k++) {
|
|
grid.appendChild(buildTile(entries[k]));
|
|
}
|
|
$tilesArea.appendChild(section);
|
|
}
|
|
if ($tilesArea.children.length === 0) {
|
|
$tilesArea.innerHTML = '<div class="empty-state"><p>No services configured.</p></div>';
|
|
}
|
|
}
|
|
|
|
function renderSidebarSupport(supportServices) {
|
|
$sidebarSupport.innerHTML = "";
|
|
|
|
// ── Update System button (above Tech Help)
|
|
var sidebarUpdateBtn = document.createElement("button");
|
|
sidebarUpdateBtn.className = "sidebar-support-btn";
|
|
sidebarUpdateBtn.id = "sidebar-btn-update";
|
|
sidebarUpdateBtn.innerHTML =
|
|
'<img class="sidebar-support-icon" src="/static/icons/update.svg" alt="Update" style="width:1.5rem;height:1.5rem;">' +
|
|
'<span class="sidebar-support-text">' +
|
|
'<span class="sidebar-support-title">Update System</span>' +
|
|
'<span class="sidebar-support-hint" id="sidebar-update-hint">Check for updates</span>' +
|
|
'</span>';
|
|
sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); });
|
|
$sidebarSupport.appendChild(sidebarUpdateBtn);
|
|
|
|
for (var i = 0; i < supportServices.length; i++) {
|
|
var svc = supportServices[i];
|
|
var btn = document.createElement("button");
|
|
btn.className = "sidebar-support-btn";
|
|
btn.innerHTML =
|
|
'<span class="sidebar-support-icon">🛟</span>' +
|
|
'<span class="sidebar-support-text">' +
|
|
'<span class="sidebar-support-title">' + escHtml(svc.name || "Tech Support") + '</span>' +
|
|
'<span class="sidebar-support-hint">Click for help</span>' +
|
|
'</span>';
|
|
btn.addEventListener("click", function() { openSupportModal(); });
|
|
$sidebarSupport.appendChild(btn);
|
|
}
|
|
|
|
// ── Manual Backup button
|
|
var backupBtn = document.createElement("button");
|
|
backupBtn.className = "sidebar-support-btn";
|
|
backupBtn.innerHTML =
|
|
'<span class="sidebar-support-icon">💾</span>' +
|
|
'<span class="sidebar-support-text">' +
|
|
'<span class="sidebar-support-title">Manual Backup</span>' +
|
|
'<span class="sidebar-support-hint">Back up to external drive</span>' +
|
|
'</span>';
|
|
backupBtn.addEventListener("click", function() { openBackupModal(); });
|
|
$sidebarSupport.appendChild(backupBtn);
|
|
|
|
// ── Security button
|
|
var securityBtn = document.createElement("button");
|
|
securityBtn.className = "sidebar-support-btn";
|
|
securityBtn.innerHTML =
|
|
'<span class="sidebar-support-icon">\uD83D\uDEE1</span>' +
|
|
'<span class="sidebar-support-text">' +
|
|
'<span class="sidebar-support-title">Security</span>' +
|
|
'<span class="sidebar-support-hint">Reset & verify system</span>' +
|
|
'</span>';
|
|
securityBtn.addEventListener("click", function() { openSecurityModal(); });
|
|
$sidebarSupport.appendChild(securityBtn);
|
|
|
|
// ── Upgrade button (Node role only)
|
|
if (_currentRole === "node") {
|
|
var upgradeBtn = document.createElement("button");
|
|
upgradeBtn.className = "sidebar-support-btn";
|
|
upgradeBtn.innerHTML =
|
|
'<span class="sidebar-support-icon">🚀</span>' +
|
|
'<span class="sidebar-support-text">' +
|
|
'<span class="sidebar-support-title">Upgrade to Full Server</span>' +
|
|
'<span class="sidebar-support-hint">Unlock all services</span>' +
|
|
'</span>';
|
|
upgradeBtn.addEventListener("click", function() { openUpgradeModal(); });
|
|
$sidebarSupport.appendChild(upgradeBtn);
|
|
}
|
|
|
|
var hr = document.createElement("hr");
|
|
hr.className = "sidebar-divider";
|
|
$sidebarSupport.appendChild(hr);
|
|
}
|
|
|
|
function buildTile(svc) {
|
|
var isSupport = svc.type === "support";
|
|
var sc = statusClass(svc.health || svc.status);
|
|
var st = statusText(svc.health || svc.status, svc.enabled);
|
|
var dis = !svc.enabled;
|
|
|
|
var tile = document.createElement("div");
|
|
tile.className = "service-tile" + (dis ? " disabled" : "") + (isSupport ? " support-tile" : "");
|
|
tile.dataset.unit = svc.unit;
|
|
tile.dataset.tileId = tileId(svc);
|
|
if (isSupport) {
|
|
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="support-status-label">Click for help</span></div>';
|
|
tile.style.cursor = "pointer";
|
|
tile.addEventListener("click", function() { openSupportModal(); });
|
|
return tile;
|
|
}
|
|
|
|
if (svc.sync_ibd && svc.enabled) {
|
|
var pct = Math.round((svc.sync_progress || 0) * 100);
|
|
var id = tileId(svc);
|
|
var eta = _calcBtcEta(id, svc.sync_progress || 0);
|
|
tile.innerHTML =
|
|
'<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'">' +
|
|
'<div class="tile-icon-fallback" style="display:none">?</div>' +
|
|
'<div class="tile-name">' + escHtml(svc.name) + '</div>' +
|
|
'<div class="tile-sync-container">' +
|
|
'<div class="tile-sync-label">\u23F3 Syncing Timechain</div>' +
|
|
'<div class="tile-sync-bar-row">' +
|
|
'<div class="tile-sync-bar-track"><div class="tile-sync-bar-fill" style="width:' + pct + '%"></div></div>' +
|
|
'<span class="tile-sync-percent">' + pct + '%</span>' +
|
|
'</div>' +
|
|
'<div class="tile-sync-eta">' + escHtml(eta) + '</div>' +
|
|
'</div>';
|
|
tile.style.cursor = "pointer";
|
|
tile.addEventListener("click", function() {
|
|
openServiceDetailModal(svc.unit, svc.name, svc.icon);
|
|
});
|
|
return tile;
|
|
}
|
|
|
|
tile.innerHTML = '<img class="tile-icon" src="/static/icons/' + escHtml(svc.icon) + '.svg" alt="' + escHtml(svc.name) + '" onerror="this.style.display=\'none\';this.nextElementSibling.style.display=\'flex\'"><div class="tile-icon-fallback" style="display:none">?</div><div class="tile-name">' + escHtml(svc.name) + '</div><div class="tile-status"><span class="status-dot ' + sc + '"></span><span class="status-text">' + st + '</span></div>';
|
|
|
|
tile.style.cursor = "pointer";
|
|
tile.addEventListener("click", function() {
|
|
openServiceDetailModal(svc.unit, svc.name, svc.icon);
|
|
});
|
|
|
|
return tile;
|
|
}
|
|
|
|
// ── Render: live update ───────────────────────────────────────────
|
|
|
|
// Calculate ETA text for Bitcoin IBD and track progress history.
|
|
function _calcBtcEta(id, progress) {
|
|
var now = Date.now();
|
|
var prev = _btcSyncPrev[id];
|
|
// Only update the cache when progress has actually advanced
|
|
if (!prev || prev.progress < progress) {
|
|
_btcSyncPrev[id] = { progress: progress, timestamp: now };
|
|
}
|
|
if (!prev || prev.progress >= progress) return "Estimating\u2026";
|
|
var elapsed = (now - prev.timestamp) / 1000; // seconds
|
|
if (elapsed <= 0) return "Estimating\u2026";
|
|
var rate = (progress - prev.progress) / elapsed; // progress per second
|
|
if (rate <= 0) return "Estimating\u2026";
|
|
var remaining = (1.0 - progress) / rate;
|
|
return "\u007E" + formatDuration(remaining) + " remaining";
|
|
}
|
|
|
|
function updateTiles(services) {
|
|
_servicesCache = services;
|
|
for (var i = 0; i < services.length; i++) {
|
|
var svc = services[i];
|
|
if (svc.type === "support") continue;
|
|
var id = CSS.escape(tileId(svc));
|
|
var tile = $tilesArea.querySelector('.service-tile[data-tile-id="' + id + '"]');
|
|
if (!tile) continue;
|
|
|
|
if (svc.sync_ibd && svc.enabled) {
|
|
// If tile was previously normal, rebuild it with the sync layout
|
|
if (!tile.querySelector(".tile-sync-container")) {
|
|
var newTile = buildTile(svc);
|
|
tile.parentNode.replaceChild(newTile, tile);
|
|
continue;
|
|
}
|
|
// Update progress bar values in-place
|
|
var pct = Math.round((svc.sync_progress || 0) * 100);
|
|
var etaText = _calcBtcEta(tileId(svc), svc.sync_progress || 0);
|
|
var fill = tile.querySelector(".tile-sync-bar-fill");
|
|
var pctEl = tile.querySelector(".tile-sync-percent");
|
|
var etaEl = tile.querySelector(".tile-sync-eta");
|
|
if (fill) fill.style.width = pct + "%";
|
|
if (pctEl) pctEl.textContent = pct + "%";
|
|
if (etaEl) etaEl.textContent = etaText;
|
|
} else {
|
|
// IBD finished or not syncing — if tile had sync layout rebuild it normally
|
|
if (tile.querySelector(".tile-sync-container")) {
|
|
delete _btcSyncPrev[tileId(svc)];
|
|
var normalTile = buildTile(svc);
|
|
tile.parentNode.replaceChild(normalTile, tile);
|
|
continue;
|
|
}
|
|
var sc = statusClass(svc.health || svc.status);
|
|
var st = statusText(svc.health || svc.status, svc.enabled);
|
|
var dot = tile.querySelector(".status-dot");
|
|
var text = tile.querySelector(".status-text");
|
|
if (dot) dot.className = "status-dot " + sc;
|
|
if (text) text.textContent = st;
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Service polling ───────────────────────────────────────────────
|
|
|
|
var _firstLoad = true;
|
|
|
|
async function refreshServices() {
|
|
try {
|
|
var services = await apiFetch("/api/services");
|
|
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
|
|
else { updateTiles(services); }
|
|
} catch (err) { console.warn("Failed to fetch services:", err); }
|
|
}
|
|
|
|
// ── Network IPs ───────────────────────────────────────────────────
|
|
|
|
async function loadNetwork() {
|
|
try {
|
|
var data = await apiFetch("/api/network");
|
|
if ($internalIp) $internalIp.textContent = data.internal_ip || "—";
|
|
if ($externalIp) $externalIp.textContent = data.external_ip || "—";
|
|
_cachedExternalIp = data.external_ip || "unavailable";
|
|
} catch (_) {
|
|
if ($internalIp) $internalIp.textContent = "—";
|
|
if ($externalIp) $externalIp.textContent = "—";
|
|
}
|
|
}
|
|
|
|
// ── Update check ──────────────────────────────────────────────────
|
|
|
|
async function checkUpdates() {
|
|
try {
|
|
var data = await apiFetch("/api/updates/check");
|
|
var hasUpdates = !!data.available;
|
|
var updateStatus = data.status || "idle";
|
|
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
|
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
|
if (sidebarUpdateBtn) {
|
|
if (updateStatus === "reboot_required") {
|
|
sidebarUpdateBtn.style.borderColor = "#e5a50a";
|
|
sidebarUpdateBtn.style.backgroundColor = "rgba(229, 165, 10, 0.10)";
|
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
|
} else if (updateStatus === "running") {
|
|
sidebarUpdateBtn.style.borderColor = "#3584e4";
|
|
sidebarUpdateBtn.style.backgroundColor = "rgba(53, 132, 228, 0.10)";
|
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
|
} else if (hasUpdates) {
|
|
sidebarUpdateBtn.style.borderColor = "#2ec27e";
|
|
sidebarUpdateBtn.style.backgroundColor = "rgba(46, 194, 126, 0.08)";
|
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
|
} else {
|
|
sidebarUpdateBtn.style.borderColor = "";
|
|
sidebarUpdateBtn.style.backgroundColor = "";
|
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
|
|
}
|
|
}
|
|
} catch (_) {}
|
|
}
|