"use strict"; /* ── The Hub dashboard chrome ────────────────────────────────────── 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. */ (function () { var $nav = document.getElementById("sidebar-nav"); var $pageTitle = document.getElementById("page-title"); 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 = ""; var CAT_ICONS = { "all": "g-grid", "infrastructure": "g-server", "bitcoin": "g-btc-sym", "communication": "g-chat", "apps": "g-dots", "nostr": "g-antenna", "other": "g-dots" }; var CAT_FALLBACK_LABELS = { "infrastructure": "Infrastructure", "bitcoin": "Bitcoin", "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 ''; } function visibleServices() { var services = (typeof _servicesCache !== "undefined" && _servicesCache) ? _servicesCache : []; return services.filter(function (s) { return s.category !== "support" && s.type !== "support"; }); } /* ── 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; var counts = {}; var order = []; visibleServices().forEach(function (s) { var cat = s.category || "other"; if (CATEGORY_ALIASES[cat]) cat = CATEGORY_ALIASES[cat]; if (!counts[cat]) { counts[cat] = 0; order.push(cat); } counts[cat]++; }); var total = visibleServices().length; var html = ''; html += ''; html += ''; 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 () { if (btn.dataset.cat === "__dash") showDashboard(); else showServices(btn.dataset.cat); }); }); } function navItem(cat, label, count) { return ''; } function catLabel(cat) { if (typeof _categoryLabels !== "undefined" && _categoryLabels[cat]) return _categoryLabels[cat]; return CAT_FALLBACK_LABELS[cat] || cat; } /* ── Filtering (applies in the services view) ─────────────────── */ function applyFilter() { if (_view !== "services") return; var area = $tilesArea; 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 () { // Searching implies browsing services — leave the welcome view. if (_view === "dashboard" && $search.value) showServices("all"); _query = $search.value; applyFilter(); }); } if ($browseBtn) { $browseBtn.addEventListener("click", function () { showServices("all"); }); } /* ── 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(); 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++; }); return { services: services, running: running, attention: attention, off: off, attentionNames: attentionNames, offNames: offNames }; } function renderWidgets() { if (!$wcSystems) return; var c = serviceCounts(); if (!c.services.length) { $wcSystems.innerHTML = ""; if ($wcMore) $wcMore.innerHTML = ""; return; } /* Systems operational */ var sub = '' + c.running + ' running'; if (c.attention) sub += ' · ' + c.attention + ' needs attention'; if (c.off) sub += ' · ' + c.off + ' off'; var attentionTitle = c.attention ? "Systems need attention" : "Systems operational"; $wcSystems.innerHTML = '
' + '
' + icon("g-pulse") + '
' + '

' + attentionTitle + '

' + sub + '
' + '
'; 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; 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) : ""; more += '
' + '
' + '

' + escHtml(btc.name) + ' — syncing timechain

' + '
' + '
Block ' + blocks + ' · ' + pct + '% · ' + escHtml(eta) + '
' + '
'; } 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 += '
' + '
' + '

' + escHtml(btcDone.name) + '

Fully synced' + (blk ? ' · Block ' + blk + '' : '') + '
' + '
'; } } /* Updates — mirror the sidebar's read of the update state, so the card never claims "up to date" while an update failed or needs a restart */ var upd = (typeof window._lastUpdateCheck === "object" && window._lastUpdateCheck) ? window._lastUpdateCheck : null; var updStatus = (upd && upd.status) || "idle"; var updTitle, updSub, updChip; if (updStatus === "failed") { updTitle = "Update failed"; updSub = "Click to retry the update"; updChip = "chip-amber"; } else if (updStatus === "reboot_required") { updTitle = "Restart required"; updSub = "Click to restart and finish the update"; updChip = "chip-amber"; } else if (updStatus === "running") { updTitle = "Update in progress"; updSub = "Installing the new system generation"; updChip = "chip-amber"; } else if (upd && upd.available) { updTitle = "Updates available"; updSub = "Click to review and update"; updChip = "chip-amber"; } else if (!upd) { /* First check hasn't returned yet — never claim "up to date" before a check has actually completed */ updTitle = "Checking for updates"; updSub = "Comparing with the latest Sovran_SystemsOS release"; updChip = "chip-green"; } else { /* Updates are applied by the user, not automatically — the card must not imply the OS updates itself */ updTitle = "System is up to date"; updSub = "Last check found no updates · Click to check again"; updChip = "chip-green"; } more += '
' + '
' + icon("g-update") + '
' + '

' + updTitle + '

' + '
' + updSub + '
' + '
'; $wcMore.innerHTML = more; var wBtc = document.getElementById("w-btc"); 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); }); } } 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 isNodeRole() { return (typeof _currentRole !== "undefined" && _currentRole === "node"); } function hasEnabledDomainService(services) { return DOMAIN_UNITS.some(function (u) { return services.some(function (s) { return s.unit === u && s.enabled; }); }); } function whoUsesPorts() { if (isNodeRole()) { return 'On this Bitcoin Node 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 step(n, title, sub, value) { return '
' + n + '
' + '
' + title + (sub ? ' · ' + sub + '' : '') + '
' + (value ? '
' + value + '
' : '') + '
'; } function openSystemsModal() { if (!$sysModal || !$sysBody) return; var c = serviceCounts(); var html = ""; /* System status */ html += '
' + '
' + icon("g-pulse") + 'System Status
' + 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") + '
'; /* Router — a simple open / not-open verdict. How to open the ports is covered during onboarding, so the modal does not repeat instructions. When no domain service is enabled (a fresh Desktop-only install, a Node with BTCPay/LNURL off, or everything turned off), there is nothing to check on the router — say so instead of listing services this machine does not have. */ if (!hasEnabledDomainService(c.services)) { var noRouterDesc = isNodeRole() ? 'Ports 80 and 443 only need to be forwarded on your router if you turn on BTCPay Server or Lightning Wallet Connections (LNURL). If you enable one of them, come back here to check your ports.' : 'None of your services need ports forwarded from your router right now. If you turn on a service that uses a domain, come back here to check your ports.'; html += '
' + '
' + icon("g-wifi") + 'Router
' + '
' + icon("g-check") + 'No router setup needed yet
' + '
' + noRouterDesc + '
' + '
'; } else { html += ''; } /* Who uses these ports (only meaningful when a domain service is actually enabled — otherwise the router note above covers it) */ if (hasEnabledDomainService(c.services)) { html += '
' + '
' + icon("g-antenna") + 'Who uses these ports
' + '
' + whoUsesPorts() + '
'; } $sysBody.innerHTML = html; $sysModal.classList.add("open"); /* Poll the first configured domain service and reduce its diagnostics to one verdict: the ports are open or they are not. */ var portsCard = document.getElementById("sys-ports-card"); var portsEl = document.getElementById("sys-ports-status"); var units = DOMAIN_UNITS.filter(function (u) { return c.services.some(function (s) { return s.unit === u && s.enabled; }); }); if (!units.length || !portsCard || !portsEl) return; portsCard.style.display = ""; apiFetch("/api/service-detail/" + encodeURIComponent(units[0])) .then(function (data) { var steps = (data && data.domain_check_steps) || []; var portsStep = null; steps.forEach(function (s) { if (Number(s.step) === 3 || /ports?\s*80/i.test(s.label || "")) portsStep = s; }); if (!portsStep) { portsCard.style.display = "none"; return; } if (portsStep.status === "ok") { portsEl.innerHTML = '
Ports 80 and 443 are open
'; } else { portsEl.innerHTML = '
Ports 80 and 443 are not open
'; } }) .catch(function () { portsCard.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(); updateWelcomeMeta(); renderWidgets(); applyFilter(); }; // Initial view showDashboard(); updateWelcomeMeta(); })();