Loading indicator: - Branded boot splash (Hub logo inside an accent spinner ring, "Starting The Hub") covers the shell while the first services data loads, then fades out once the welcome dashboard has rendered; never blocks longer than 25s and reassures the user after 8s (message about post-reboot delays) - Fire the network and update checks before the first services render so the dashboard cards are current at first paint - Sidebar Update button now adopts the last known update state when built (order-independent), and the welcome dashboard re-renders when the update state changes Icons: - New monochrome g-pulse glyph (activity line) for Systems Operational: welcome card, dialog header, and System Status section — the shield no longer doubles as Security - Tech Support / Security dialog header gets a standard chip; Security shows the shield chip with a plain "Security" title (no emoji), and the shared dialog title now resets correctly when reopening Tech Support after Security
324 lines
15 KiB
JavaScript
324 lines
15 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 (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat];
|
|
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>';
|
|
}
|
|
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
|
}
|
|
|
|
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 =
|
|
'<span class="sidebar-support-icon"><svg><use href="#g-update"/></svg></span>' +
|
|
'<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);
|
|
// checkUpdates may already have run before the sidebar was built
|
|
applyUpdateSidebarState(window._lastUpdateCheck);
|
|
|
|
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"><svg><use href="#g-lifebuoy"/></svg></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"><svg><use href="#g-box"/></svg></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"><svg><use href="#g-shield-check"/></svg></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"><svg><use href="#g-antenna"/></svg></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;
|
|
}
|
|
}
|
|
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
|
}
|
|
|
|
// ── 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); }
|
|
// Service data has rendered — the dashboard is ready; lift the boot
|
|
// splash (no-op once lifted). Note: dashboardServicesUpdated may also
|
|
// fire from checkUpdates before this resolves, so the lift lives here.
|
|
if (typeof window.__liftAppSplash === "function") window.__liftAppSplash();
|
|
} 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 ──────────────────────────────────────────────────
|
|
|
|
// Paint the sidebar Update button from the last known update state.
|
|
// Called after each check AND when the button is (re)built, so the hint
|
|
// is correct regardless of which finishes first at startup.
|
|
function applyUpdateSidebarState(data) {
|
|
if (!data) return;
|
|
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
|
|
var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
|
|
if (!sidebarUpdateBtn) return;
|
|
var hasUpdates = !!data.available;
|
|
var updateStatus = data.status || "idle";
|
|
if (updateStatus === "failed") {
|
|
// Last update errored and did not apply — surface it as a persistent
|
|
// red banner that re-opens the failed run with a "Retry Update" action.
|
|
sidebarUpdateBtn.style.borderColor = "#f66151";
|
|
sidebarUpdateBtn.style.backgroundColor = "rgba(246, 97, 81, 0.10)";
|
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update failed — click to retry";
|
|
} else if (updateStatus === "reboot_required") {
|
|
sidebarUpdateBtn.style.borderColor = "#e9b64a";
|
|
sidebarUpdateBtn.style.backgroundColor = "rgba(233, 182, 74, 0.10)";
|
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Restart required";
|
|
} else if (updateStatus === "running") {
|
|
sidebarUpdateBtn.style.borderColor = "#78aeed";
|
|
sidebarUpdateBtn.style.backgroundColor = "rgba(120, 174, 237, 0.10)";
|
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update in progress…";
|
|
} else if (hasUpdates) {
|
|
sidebarUpdateBtn.style.borderColor = "#3ecf8e";
|
|
sidebarUpdateBtn.style.backgroundColor = "rgba(62, 207, 142, 0.10)";
|
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Updates available!";
|
|
} else {
|
|
sidebarUpdateBtn.style.borderColor = "";
|
|
sidebarUpdateBtn.style.backgroundColor = "";
|
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
|
|
}
|
|
}
|
|
|
|
async function checkUpdates() {
|
|
try {
|
|
var data = await apiFetch("/api/updates/check");
|
|
window._lastUpdateCheck = data;
|
|
if (typeof markUpdateChecked === "function") markUpdateChecked();
|
|
applyUpdateSidebarState(data);
|
|
// The welcome dashboard's updates card reads this state
|
|
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
|
|
} catch (_) {}
|
|
}
|