"use strict"; // ── Bitcoin IBD sync state (for ETA calculation) ────────────────── // Keyed by tileId: { progress: float, timestamp: ms } var _btcSyncPrev = {}; // ── 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 = '
' + escHtml(label) + '

'; 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 = '

No services configured.

'; } } 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 = 'Update' + '' + 'Update System' + 'Check for updates' + ''; 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 = '🛟' + '' + '' + escHtml(svc.name || "Tech Support") + '' + 'Click for help' + ''; btn.addEventListener("click", function() { openSupportModal(); }); $sidebarSupport.appendChild(btn); } // ── Manual Backup button var backupBtn = document.createElement("button"); backupBtn.className = "sidebar-support-btn"; backupBtn.innerHTML = '💾' + '' + 'Manual Backup' + 'Back up to external drive' + ''; backupBtn.addEventListener("click", function() { openBackupModal(); }); $sidebarSupport.appendChild(backupBtn); // ── Upgrade button (Node role only) if (_currentRole === "node") { var upgradeBtn = document.createElement("button"); upgradeBtn.className = "sidebar-support-btn sidebar-upgrade-btn"; upgradeBtn.innerHTML = '🚀' + '' + 'Upgrade to Full Server' + 'Unlock all services' + ''; 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 (dis) tile.title = svc.name + " is not enabled in custom.nix"; if (isSupport) { tile.innerHTML = '' + escHtml(svc.name) + '
' + escHtml(svc.name) + '
Click for help
'; tile.style.cursor = "pointer"; tile.addEventListener("click", function() { openSupportModal(); }); return tile; } if (svc.sync_ibd) { var pct = Math.round((svc.sync_progress || 0) * 100); var id = tileId(svc); var eta = _calcBtcEta(id, svc.sync_progress || 0); tile.innerHTML = '' + escHtml(svc.name) + '' + '' + '
' + escHtml(svc.name) + '
' + '
' + '
\u23F3 Syncing Timechain
' + '
' + '
' + '' + pct + '%' + '
' + '
' + escHtml(eta) + '
' + '
'; tile.style.cursor = "pointer"; tile.addEventListener("click", function() { openServiceDetailModal(svc.unit, svc.name, svc.icon); }); return tile; } tile.innerHTML = '' + escHtml(svc.name) + '
' + escHtml(svc.name) + '
' + st + '
'; 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) { // 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 sidebarUpdateBtn = document.getElementById("sidebar-btn-update"); var sidebarUpdateHint = document.getElementById("sidebar-update-hint"); if (sidebarUpdateBtn) { 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 (_) {} }