Apply the approved "The Hub" redesign to the web admin while keeping every existing mechanic intact (polling, service-detail modals, Matrix and system password management, NWC wallet manager, update/rebuild/backup/security/ reboot flows, feature manager, onboarding, role handling). Layout (templates/index.html): - Old header bar + IP bar replaced by a sidebar + topbar app shell. Sidebar carries the brand (The Hub / Sovran_SystemsOS version), category navigation with live counts, the System actions (Update System, Tech Support, Manual Backup, Security, node-only Upgrade), Feature Manager / Preferences, and the role badge. - Topbar carries the page title, a service search box, the LAN | WAN network chip (external IP always visible, one line), Reboot and Sign Out. - New widgets row: Systems Operational summary (opens the new Systems Operational modal) and Bitcoin Core sync progress with block/ETA. - New Systems Operational modal: service counts, router port-forwarding steps (80/443 to this machine's LAN IP), the live domain diagnostics checklist, and which services use those ports. New static/js/dashboard.js (namespaced IIFE, no new globals) renders the nav, search filtering, widgets, and the Systems Operational modal; it is driven by the existing /api/services payloads via a window.dashboardServicesUpdated() hook called from buildTiles/updateTiles. Visual design (static/css/*): - New token set (softer dark surfaces, lifted contrast, Sovran green reserved for status and actions) with legacy variable names aliased so every secondary sheet re-skins automatically. - Tiles, dialogs, buttons, inputs, toggles, tables, forms, overlays and the login page restyled to the GNOME/libadwaita-flavored surfaces: 20px cards, 26px dialogs, pill buttons, libadwaita switches, mono value pills with Copy buttons, consistent modal anatomy. - Inline SVG symbol set for chrome/nav icons (monochrome, currentColor); service icons still load from /static/icons/*.svg as before. - Sidebar system buttons now use vector glyphs instead of emoji. Behavioral details: - Service detail modal header gains a status pill next to the version chip; credentials render with pre-wrap for multiline values. - First-login security banner now renders as a card inside the content area instead of a full-width strip above the app. - Search + category filtering hide/show sections and tiles without touching the polling or update logic. Onboarding and login pages rebranded to "The Hub" with aligned palette.
306 lines
14 KiB
JavaScript
306 lines
14 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>';
|
|
}
|
|
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-refresh"/></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);
|
|
|
|
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); }
|
|
} 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 === "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 = "#e01b24";
|
|
sidebarUpdateBtn.style.backgroundColor = "rgba(224, 27, 36, 0.10)";
|
|
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "Update failed — click to retry";
|
|
} else 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 (_) {}
|
|
}
|