Add welcome dashboard as default view

Replace the All Services grid as the landing view with a
Nextcloud-style welcome dashboard:

- Greeting (time-of-day), "Welcome to Your Sovereign Digital &
  Financial Life" headline, version + role meta line
- Status cards: Systems Operational, Network (LAN/WAN/hostname),
  Bitcoin sync progress with ETA, and available-updates card
- Calm drifting orb background animation (transform-only,
  disabled under prefers-reduced-motion)
- Services grid stays mounted but hidden until the user browses
  (Browse button, search, or a nav category); Dashboard nav item
  returns to the welcome view
- Move role badge and autolaunch preference out of the sidebar;
  autolaunch toggle now lives in the Systems Operational modal
- Remove LAN/WAN chip from the topbar; IPs live on the Network card
This commit is contained in:
2026-09-08 18:46:43 -05:00
parent 61ef286420
commit 78ae9e78ed
8 changed files with 335 additions and 158 deletions
+163 -68
View File
@@ -1,23 +1,29 @@
"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).
Welcome dashboard (default view), category navigation, service
search, status cards, and the Systems Operational modal.
Everything lives in an IIFE so no globals leak into the other
Hub scripts; tiles.js calls window.dashboardServicesUpdated()
whenever the service list refreshes. */
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 $welcome = document.getElementById("welcome-view");
var $tilesArea = document.getElementById("tiles-area");
var $wcSystems = document.getElementById("wc-systems");
var $wcMore = document.getElementById("wc-more");
var $greeting = document.getElementById("welcome-greeting");
var $welcomeRole = document.getElementById("welcome-role");
var $browseBtn = document.getElementById("welcome-browse-btn");
var _view = "dashboard"; // "dashboard" | "services"
var _cat = "all";
var _query = "";
@@ -65,7 +71,42 @@
});
}
/* ── Category navigation ─────────────────────────────────────── */
/* ── Views ────────────────────────────────────────────────────── */
function setTitle(t) {
if ($pageTitle) $pageTitle.textContent = t;
}
function syncNav() {
if (!$nav) return;
$nav.querySelectorAll(".nav-item").forEach(function (b) {
var isActive;
if (b.dataset.cat === "__dash") isActive = (_view === "dashboard");
else isActive = (_view === "services" && b.dataset.cat === _cat);
b.classList.toggle("active", isActive);
});
}
function showDashboard() {
_view = "dashboard";
_cat = "all";
if ($welcome) $welcome.style.display = "";
if ($tilesArea) $tilesArea.style.display = "none";
setTitle("Dashboard");
syncNav();
}
function showServices(cat) {
_view = "services";
if (cat) _cat = cat;
if ($welcome) $welcome.style.display = "none";
if ($tilesArea) $tilesArea.style.display = "";
setTitle(_cat === "all" ? "All services" : catLabel(_cat));
syncNav();
applyFilter();
}
/* ── Category navigation ──────────────────────────────────────── */
function renderNav() {
if (!$nav) return;
@@ -79,7 +120,12 @@
});
var total = visibleServices().length;
var html = '<div class="nav-label">Services</div>';
var html = '<div class="nav-label">Menu</div>';
html += '<button class="nav-item' + (_view === "dashboard" ? " active" : "") + '" data-cat="__dash" type="button">' +
icon("g-home") +
'<span class="nav-text">Dashboard</span>' +
'</button>';
html += '<div class="nav-label">Services</div>';
html += navItem("all", "All services", total);
order.forEach(function (cat) {
html += navItem(cat, catLabel(cat), counts[cat]);
@@ -88,20 +134,14 @@
$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();
if (btn.dataset.cat === "__dash") showDashboard();
else showServices(btn.dataset.cat);
});
});
}
function navItem(cat, label, count) {
return '<button class="nav-item' + (cat === _cat ? " active" : "") + '" data-cat="' + escHtml(cat) + '" type="button">' +
return '<button class="nav-item' + (_view === "services" && 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>' +
@@ -113,10 +153,11 @@
return CAT_FALLBACK_LABELS[cat] || cat;
}
/* ── Filtering (category + search, applied together) ─────────── */
/* ── Filtering (applies in the services view) ─────────────────── */
function applyFilter() {
var area = document.getElementById("tiles-area");
if (_view !== "services") return;
var area = $tilesArea;
if (!area) return;
var q = _query.trim().toLowerCase();
area.querySelectorAll(".category-section").forEach(function (section) {
@@ -135,50 +176,84 @@
if ($search) {
$search.addEventListener("input", function () {
// Searching implies browsing services — leave the welcome view.
if (_view === "dashboard" && $search.value) showServices("all");
_query = $search.value;
applyFilter();
});
}
/* ── Status widgets ──────────────────────────────────────────── */
if ($browseBtn) {
$browseBtn.addEventListener("click", function () { showServices("all"); });
}
function renderWidgets() {
if (!$widgets) return;
/* ── Welcome header ───────────────────────────────────────────── */
function updateWelcomeMeta() {
if ($greeting) {
var h = new Date().getHours();
var g;
if (h >= 5 && h < 12) g = "Good morning";
else if (h >= 12 && h < 17) g = "Good afternoon";
else g = "Good evening";
$greeting.textContent = g;
}
if ($welcomeRole) {
$welcomeRole.textContent = (typeof window._roleLabel !== "undefined" && window._roleLabel) ? window._roleLabel : "";
}
}
/* ── Status cards ─────────────────────────────────────────────── */
function serviceCounts() {
var services = visibleServices();
if (!services.length) { $widgets.style.display = "none"; return; }
var running = 0, attention = 0, off = 0;
var attentionNames = [], offNames = [];
services.forEach(function (s) {
if (!s.enabled) { off++; return; }
if (!s.enabled) { off++; offNames.push(s.name); return; }
var h = s.health || s.status;
if (h === "needs_attention" || h === "failed") attention++;
if (h === "needs_attention" || h === "failed") { attention++; attentionNames.push(s.name); }
else running++;
});
return { services: services, running: running, attention: attention, off: off, attentionNames: attentionNames, offNames: offNames };
}
var html = "";
function renderWidgets() {
if (!$wcSystems) return;
var c = serviceCounts();
if (!c.services.length) { $wcSystems.innerHTML = ""; if ($wcMore) $wcMore.innerHTML = ""; return; }
/* 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 +=
var sub = '<b>' + c.running + '</b> running';
if (c.attention) sub += ' · <span class="warn">' + c.attention + ' needs attention</span>';
if (c.off) sub += ' · ' + c.off + ' off';
var attentionTitle = c.attention ? "Systems need attention" : "Systems operational";
$wcSystems.innerHTML =
'<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>';
var wSys = document.getElementById("w-systems");
if (wSys) {
wSys.addEventListener("click", openSystemsModal);
wSys.addEventListener("keydown", function (e) { if (e.key === "Enter") openSystemsModal(); });
}
if (!$wcMore) return;
var more = "";
/* Bitcoin Core sync */
var btc = null;
services.forEach(function (s) {
c.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 +=
more +=
'<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>' +
@@ -186,33 +261,49 @@
'<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>';
} else {
var btcDone = null;
c.services.forEach(function (s) { if (s.unit === "bitcoind.service" && s.enabled) btcDone = s; });
if (btcDone) {
var blk = btcDone.sync_blocks ? btcDone.sync_blocks.toLocaleString() : "";
more +=
'<div class="widget clickable" id="w-btc" role="button" tabindex="0" title="' + escHtml(btcDone.name) + ' details">' +
'<div class="widget-chip chip-btc"><img src="/static/icons/' + escHtml(btcDone.icon) + '.svg" alt="" style="width:46px;height:46px;display:block;object-fit:contain"/></div>' +
'<div class="w-body"><h3>' + escHtml(btcDone.name) + '</h3><div class="sub"><span class="good">Fully synced</span>' + (blk ? ' · Block <b>' + blk + '</b>' : '') + '</div></div>' +
'<span class="w-chev">' + icon("g-chev") + '</span>' +
'</div>';
}
}
$widgets.innerHTML = html;
$widgets.style.display = "";
/* Updates */
var upd = (typeof window._lastUpdateCheck === "object" && window._lastUpdateCheck) ? window._lastUpdateCheck : null;
var hasUpdates = !!(upd && upd.available);
more +=
'<div class="widget clickable" id="w-updates" role="button" tabindex="0" title="Check for system updates">' +
'<div class="widget-chip ' + (hasUpdates ? "chip-amber" : "chip-green") + '">' + icon("g-update") + '</div>' +
'<div class="w-body"><h3>' + (hasUpdates ? "Updates available" : "System is up to date") + '</h3>' +
'<div class="sub">' + (hasUpdates ? 'Click to review and update' : 'Sovran_SystemsOS keeps itself current') + '</div></div>' +
'<span class="w-chev">' + icon("g-chev") + '</span>' +
'</div>';
$wcMore.innerHTML = more;
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) {
if (wBtc) {
var svc = btc || btcDone;
if (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);
}
}
var wUpd = document.getElementById("w-updates");
if (wUpd) {
wUpd.addEventListener("click", function () { openUpdateModal(); });
wUpd.addEventListener("keydown", function (e) { if (e.key === "Enter") openUpdateModal(); });
}
}
/* ── Systems Operational modal ───────────────────────────────── */
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>';
}
/* ── Systems Operational modal ───────────────────────────────── */
function isNodeRole() {
return (typeof _currentRole !== "undefined" && _currentRole === "node");
@@ -231,33 +322,32 @@
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 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 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 c = serviceCounts();
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") +
step(1, "Services running", "", String(c.running)) +
step(2, "Needs attention", "", c.attention ? escHtml(c.attentionNames.join(", ")) : "None") +
step(3, "Turned off", "", c.off ? escHtml(c.offNames.join(", ")) : "None") +
'</div>';
/* Router — a simple open / not-open verdict. How to open the ports is
covered during onboarding, so the modal does not repeat instructions.
Node-only role: ports only matter once BTCPay Server or Lightning
Wallet Connections (LNURL) is turned on. */
if (isNodeRole() && !hasEnabledDomainService(services)) {
if (isNodeRole() && !hasEnabledDomainService(c.services)) {
html += '<div class="sysmodal-card">' +
'<div class="sysmodal-card-title">' + icon("g-wifi") + 'Router</div>' +
'<div class="sysnote"><div class="sysnote-title">' + icon("g-check") + 'No router setup needed yet</div>' +
@@ -272,7 +362,7 @@
/* Who uses these ports (redundant on a Node install with no domain
services on — the router note above already covers it) */
if (!isNodeRole() || hasEnabledDomainService(services)) {
if (!isNodeRole() || hasEnabledDomainService(c.services)) {
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>';
@@ -286,7 +376,7 @@
var portsCard = document.getElementById("sys-ports-card");
var portsEl = document.getElementById("sys-ports-status");
var units = DOMAIN_UNITS.filter(function (u) {
return services.some(function (s) { return s.unit === u && s.enabled; });
return c.services.some(function (s) { return s.unit === u && s.enabled; });
});
if (!units.length || !portsCard || !portsEl) return;
portsCard.style.display = "";
@@ -329,8 +419,13 @@
window.dashboardServicesUpdated = function () {
renderNav();
updateWelcomeMeta();
renderWidgets();
applyFilter();
};
// Initial view
showDashboard();
updateWelcomeMeta();
})();