Files
Sovran_SystemsOS/app/sovran_systemsos_web/static/js/dashboard.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

334 lines
14 KiB
JavaScript

"use strict";
/* ── The Hub dashboard chrome ──────────────────────────────────────
Category navigation, service search, status widgets, and the
Systems Operational modal (router ports 80/443 + the live domain
diagnostics checklist).
Everything lives in an IIFE so no globals leak into the other
Hub scripts; tiles.js calls window.dashboardServicesUpdated()
whenever the service list refreshes. */
(function () {
var $nav = document.getElementById("sidebar-nav");
var $pageTitle = document.getElementById("page-title");
var $widgets = document.getElementById("widgets");
var $search = document.getElementById("search-input");
var $sysModal = document.getElementById("systems-modal");
var $sysBody = document.getElementById("systems-body");
var _cat = "all";
var _query = "";
var CAT_ICONS = {
"all": "g-grid",
"infrastructure": "g-server",
"bitcoin-base": "g-btc-sym",
"bitcoin-apps": "g-btc-sym",
"communication": "g-chat",
"apps": "g-dots",
"nostr": "g-antenna",
"other": "g-dots"
};
var CAT_FALLBACK_LABELS = {
"infrastructure": "Infrastructure",
"bitcoin-base": "Bitcoin",
"bitcoin-apps": "Bitcoin Apps",
"communication": "Communication",
"apps": "Personal Apps",
"nostr": "Nostr",
"other": "Other"
};
/* Units that serve a domain — used to find a live diagnostics
checklist for the Systems Operational modal (order matters only
for which service is polled first). */
var DOMAIN_UNITS = [
"matrix-synapse.service",
"btcpayserver.service",
"vaultwarden.service",
"phpfpm-nextcloud.service",
"phpfpm-wordpress.service",
"haven-relay.service",
"livekit.service",
"albyhub.service"
];
function icon(id) {
return '<svg><use href="#' + id + '"/></svg>';
}
function visibleServices() {
var services = (typeof _servicesCache !== "undefined" && _servicesCache) ? _servicesCache : [];
return services.filter(function (s) {
return s.category !== "support" && s.type !== "support";
});
}
/* ── Category navigation ─────────────────────────────────────── */
function renderNav() {
if (!$nav) return;
var counts = {};
var order = [];
visibleServices().forEach(function (s) {
var cat = s.category || "other";
if (!counts[cat]) { counts[cat] = 0; order.push(cat); }
counts[cat]++;
});
var total = visibleServices().length;
var html = '<div class="nav-label">Services</div>';
html += navItem("all", "All services", total);
order.forEach(function (cat) {
html += navItem(cat, catLabel(cat), counts[cat]);
});
$nav.innerHTML = html;
$nav.querySelectorAll(".nav-item").forEach(function (btn) {
btn.addEventListener("click", function () {
_cat = btn.dataset.cat;
$nav.querySelectorAll(".nav-item").forEach(function (b) {
b.classList.toggle("active", b === btn);
});
if ($pageTitle) {
$pageTitle.textContent = _cat === "all" ? "Dashboard" : catLabel(_cat);
}
applyFilter();
});
});
}
function navItem(cat, label, count) {
return '<button class="nav-item' + (cat === _cat ? " active" : "") + '" data-cat="' + escHtml(cat) + '" type="button">' +
icon(CAT_ICONS[cat] || "g-dots") +
'<span class="nav-text">' + escHtml(label) + '</span>' +
'<span class="nav-count">' + count + '</span>' +
'</button>';
}
function catLabel(cat) {
if (typeof _categoryLabels !== "undefined" && _categoryLabels[cat]) return _categoryLabels[cat];
return CAT_FALLBACK_LABELS[cat] || cat;
}
/* ── Filtering (category + search, applied together) ─────────── */
function applyFilter() {
var area = document.getElementById("tiles-area");
if (!area) return;
var q = _query.trim().toLowerCase();
area.querySelectorAll(".category-section").forEach(function (section) {
var catMatch = (_cat === "all") || (section.dataset.category === _cat);
var anyVisible = false;
section.querySelectorAll(".service-tile").forEach(function (tile) {
var nameEl = tile.querySelector(".tile-name");
var name = nameEl ? nameEl.textContent.toLowerCase() : "";
var match = catMatch && (!q || name.indexOf(q) !== -1);
tile.style.display = match ? "" : "none";
if (match) anyVisible = true;
});
section.style.display = (catMatch && (anyVisible || !q)) ? "" : "none";
});
}
if ($search) {
$search.addEventListener("input", function () {
_query = $search.value;
applyFilter();
});
}
/* ── Status widgets ──────────────────────────────────────────── */
function renderWidgets() {
if (!$widgets) return;
var services = visibleServices();
if (!services.length) { $widgets.style.display = "none"; return; }
var running = 0, attention = 0, off = 0;
services.forEach(function (s) {
if (!s.enabled) { off++; return; }
var h = s.health || s.status;
if (h === "needs_attention" || h === "failed") attention++;
else running++;
});
var html = "";
/* Systems operational */
var sub = '<b>' + running + '</b> running';
if (attention) sub += ' · <span class="warn">' + attention + ' needs attention</span>';
if (off) sub += ' · ' + off + ' off';
var attentionTitle = attention ? "Systems need attention" : "Systems operational";
html +=
'<div class="widget clickable" id="w-systems" role="button" tabindex="0" title="System status and router setup">' +
'<div class="widget-chip chip-green">' + icon("g-shield-check") + '</div>' +
'<div class="w-body"><h3>' + attentionTitle + '</h3><div class="sub">' + sub + '</div></div>' +
'<span class="w-chev">' + icon("g-chev") + '</span>' +
'</div>';
/* Bitcoin Core sync */
var btc = null;
services.forEach(function (s) {
if (s.sync_ibd && s.enabled) btc = s;
});
if (btc) {
var pct = Math.round((btc.sync_progress || 0) * 100);
var blocks = btc.sync_blocks ? btc.sync_blocks.toLocaleString() : "—";
var eta = (typeof _calcBtcEta === "function") ? _calcBtcEta(btc.unit + "::" + btc.name, btc.sync_progress || 0) : "";
html +=
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btc.name) + ' details">' +
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btc.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
'<div class="w-body"><h3>' + escHtml(btc.name) + ' — syncing timechain</h3>' +
'<div class="w-bar"><div class="w-bar-fill" style="width:' + pct + '%"></div></div>' +
'<div class="sub">Block <b>' + blocks + '</b> · ' + pct + '% · <span class="warn">' + escHtml(eta) + '</span></div></div>' +
'<span class="w-chev">' + icon("g-chev") + '</span>' +
'</div>';
}
$widgets.innerHTML = html;
$widgets.style.display = "";
var wSys = document.getElementById("w-systems");
if (wSys) {
wSys.addEventListener("click", openSystemsModal);
wSys.addEventListener("keydown", function (e) { if (e.key === "Enter") openSystemsModal(); });
}
var wBtc = document.getElementById("w-btc");
if (wBtc && btc) {
(function (svc) {
wBtc.addEventListener("click", function () { openServiceDetailModal(svc.unit, svc.name, svc.icon); });
wBtc.addEventListener("keydown", function (e) { if (e.key === "Enter") openServiceDetailModal(svc.unit, svc.name, svc.icon); });
})(btc);
}
}
/* ── Systems Operational modal ───────────────────────────────── */
function internalIp() {
var el = document.getElementById("ip-internal");
var v = el ? el.textContent.trim() : "";
return (v && v !== "…" && v !== "—") ? v : "";
}
function step(n, title, sub, value) {
return '<div class="sysstep"><div class="sysnum">' + n + '</div><div class="sysstep-x">' +
'<div class="sysstep-t">' + title + (sub ? ' <span class="sysstep-sub">· ' + sub + '</span>' : '') + '</div>' +
(value ? '<div class="sysval"><span class="sysval-text">' + value + '</span></div>' : '') +
'</div></div>';
}
function whoUsesPorts() {
if (typeof _currentRole !== "undefined" && _currentRole === "node") {
return 'On this <strong>Bitcoin Node</strong> install, BTCPay Server and Lightning Wallet Connections (LNURL) are the domain services that use these ports.';
}
return 'All your domain services share ports 80 and 443 — Matrix, BTCPay Server, VaultWarden, Nextcloud, WordPress, Haven Relay, Lightning Wallet Connections, and Element Calling.';
}
function openSystemsModal() {
if (!$sysModal || !$sysBody) return;
var services = visibleServices();
var running = 0, attention = 0, off = 0;
var attentionNames = [], offNames = [];
services.forEach(function (s) {
if (!s.enabled) { off++; offNames.push(s.name); return; }
var h = s.health || s.status;
if (h === "needs_attention" || h === "failed") { attention++; attentionNames.push(s.name); }
else running++;
});
var ip = internalIp();
var fwd = ip ? ('\u2192 ' + escHtml(ip)) : "forward to this computer";
var html = "";
/* System status */
html += '<div class="sysmodal-card">' +
'<div class="sysmodal-card-title">' + icon("g-shield-check") + 'System Status</div>' +
step(1, "Services running", "", String(running)) +
step(2, "Needs attention", "", attention ? escHtml(attentionNames.join(", ")) : "None") +
step(3, "Turned off", "", off ? escHtml(offNames.join(", ")) : "None") +
'</div>';
/* Router ports — repo wording (domain-prereqs.js / server.py) */
html += '<div class="sysmodal-card">' +
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router — Ports to Forward</div>' +
step(1, "Port 80", "TCP — HTTP (redirect to HTTPS)", fwd) +
step(2, "Port 443", "TCP — HTTPS", fwd) +
'<div class="sysnote"><div class="sysnote-title">' + icon("g-alert") + 'One router task</div>' +
'<div class="sysnote-desc">Set the internal and external port to the <strong>same number</strong>. You only need to do this once — all your services share these two ports. Test from your phone on mobile data (your home network may not support hairpin NAT / loopback).</div></div>' +
'</div>';
/* Live domain diagnostics (sequential checklist from the backend —
same data the domain-service modals show) */
html += '<div class="sysmodal-card" id="sys-check-card" style="display:none">' +
'<div class="sysmodal-card-title">' + icon("g-check") + 'Ports 80 &amp; 443 Check</div>' +
'<div id="sys-check-steps"><div class="sysfineprint">Checking…</div></div>' +
'</div>';
/* Who uses these ports */
html += '<div class="sysnote" style="margin-top:14px">' +
'<div class="sysnote-title">' + icon("g-antenna") + 'Who uses these ports</div>' +
'<div class="sysnote-desc">' + whoUsesPorts() + '</div></div>';
$sysBody.innerHTML = html;
$sysModal.classList.add("open");
/* Poll the first configured domain service for its live checklist */
var checkCard = document.getElementById("sys-check-card");
var stepsEl = document.getElementById("sys-check-steps");
var units = DOMAIN_UNITS.filter(function (u) {
return services.some(function (s) { return s.unit === u; });
});
if (!units.length || !checkCard || !stepsEl) return;
checkCard.style.display = "";
apiFetch("/api/service-detail/" + encodeURIComponent(units[0]))
.then(function (data) {
var steps = (data && data.domain_check_steps) || [];
if (!steps.length) { checkCard.style.display = "none"; return; }
var mark = { ok: "\u2705", error: "\u274c", warning: "\u26a0\ufe0f", skipped: "\u23ed\ufe0f" };
var html2 = "";
steps.forEach(function (st) {
var m = mark[st.status] || "\u2014";
var detail = escHtml(st.detail || "").replace(/\n/g, "<br>");
html2 += '<div class="sysstep"><div class="sysnum">' + st.step + '</div><div class="sysstep-x">' +
'<div class="sysstep-t">' + m + " " + escHtml(st.label || "") + '</div>' +
(detail ? '<div class="sysstep-t" style="font-weight:400;color:var(--text-2);font-size:0.77rem;margin-top:3px">' + detail + '</div>' : '') +
'</div></div>';
});
stepsEl.innerHTML = html2;
})
.catch(function () {
checkCard.style.display = "none";
});
}
function closeSystemsModal() {
if ($sysModal) $sysModal.classList.remove("open");
}
var sysClose = document.getElementById("systems-close-btn");
if (sysClose) sysClose.addEventListener("click", closeSystemsModal);
if ($sysModal) {
$sysModal.addEventListener("click", function (e) {
if (e.target === $sysModal) closeSystemsModal();
});
$sysModal.addEventListener("keydown", function (e) {
if (e.key === "Escape") closeSystemsModal();
});
}
/* ── Public hook for tiles.js ────────────────────────────────── */
window.dashboardServicesUpdated = function () {
renderNav();
renderWidgets();
applyFilter();
};
})();