Files
Sovran_SystemsOS/app/sovran_systemsos_web/static/js/tiles.js
T
naturallaw777 2013df55bd Redesign The Hub web UI: softer dark theme, sidebar nav, status widgets
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.
2026-09-08 15:52:49 -05:00

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 &amp; 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 (_) {}
}