Add boot splash; separate Systems Operational and Security icons

Loading indicator:
- Branded boot splash (Hub logo inside an accent spinner ring,
  "Starting The Hub") covers the shell while the first services
  data loads, then fades out once the welcome dashboard has
  rendered; never blocks longer than 25s and reassures the user
  after 8s (message about post-reboot delays)
- Fire the network and update checks before the first services
  render so the dashboard cards are current at first paint
- Sidebar Update button now adopts the last known update state
  when built (order-independent), and the welcome dashboard
  re-renders when the update state changes

Icons:
- New monochrome g-pulse glyph (activity line) for Systems
  Operational: welcome card, dialog header, and System Status
  section — the shield no longer doubles as Security
- Tech Support / Security dialog header gets a standard chip;
  Security shows the shield chip with a plain "Security" title
  (no emoji), and the shared dialog title now resets correctly
  when reopening Tech Support after Security
This commit is contained in:
2026-09-08 19:15:06 -05:00
parent 36b77e3f0a
commit 4476a5e0e2
7 changed files with 172 additions and 36 deletions
@@ -259,6 +259,67 @@
.welcome-inner { animation: none; } .welcome-inner { animation: none; }
} }
/* ── Boot splash ────────────────────────────────────────────────── */
/* Covers the shell while the first services/config data loads; the
inline script in index.html lifts it (window.__liftAppSplash). */
#app-splash {
position: fixed;
inset: 0;
z-index: 2000;
background: var(--bg);
display: flex;
align-items: center;
justify-content: center;
transition: opacity 0.45s ease;
}
#app-splash.done { opacity: 0; pointer-events: none; }
.splash-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 24px;
}
.splash-ring {
position: relative;
width: 96px;
height: 96px;
display: grid;
place-items: center;
margin-bottom: 8px;
}
.splash-ring img { width: 60px; height: 60px; opacity: 0.95; }
.splash-ring-arc {
position: absolute;
inset: 0;
border-radius: 50%;
border: 3px solid rgba(255, 255, 255, 0.10);
border-top-color: var(--accent);
animation: splash-spin 1s linear infinite;
}
@keyframes splash-spin { to { transform: rotate(360deg); } }
.splash-title {
font-size: 1.02rem;
font-weight: 700;
color: var(--text);
}
.splash-sub {
font-size: 0.84rem;
color: var(--text-3);
}
@media (prefers-reduced-motion: reduce) {
.splash-ring-arc { animation-duration: 2.5s; }
}
.welcome-inner { .welcome-inner {
position: relative; position: relative;
max-width: 1040px; max-width: 1040px;
@@ -387,6 +448,10 @@
background: linear-gradient(160deg, #42f39a, #1aa45d); background: linear-gradient(160deg, #42f39a, #1aa45d);
} }
.widget-chip.chip-neutral {
background: linear-gradient(160deg, #2e333a, #23272c);
}
.w-body { min-width: 0; } .w-body { min-width: 0; }
.widget h3 { .widget h3 {
@@ -230,7 +230,7 @@
var attentionTitle = c.attention ? "Systems need attention" : "Systems operational"; var attentionTitle = c.attention ? "Systems need attention" : "Systems operational";
$wcSystems.innerHTML = $wcSystems.innerHTML =
'<div class="widget clickable" id="w-systems" role="button" tabindex="0" title="System status and router setup">' + '<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="widget-chip chip-green">' + icon("g-pulse") + '</div>' +
'<div class="w-body"><h3>' + attentionTitle + '</h3><div class="sub">' + sub + '</div></div>' + '<div class="w-body"><h3>' + attentionTitle + '</h3><div class="sub">' + sub + '</div></div>' +
'<span class="w-chev">' + icon("g-chev") + '</span>' + '<span class="w-chev">' + icon("g-chev") + '</span>' +
'</div>'; '</div>';
@@ -337,7 +337,7 @@
/* System status */ /* System status */
html += '<div class="sysmodal-card">' + html += '<div class="sysmodal-card">' +
'<div class="sysmodal-card-title">' + icon("g-shield-check") + 'System Status</div>' + '<div class="sysmodal-card-title">' + icon("g-pulse") + 'System Status</div>' +
step(1, "Services running", "", String(c.running)) + step(1, "Services running", "", String(c.running)) +
step(2, "Needs attention", "", c.attention ? escHtml(c.attentionNames.join(", ")) : "None") + step(2, "Needs attention", "", c.attention ? escHtml(c.attentionNames.join(", ")) : "None") +
step(3, "Turned off", "", c.off ? escHtml(c.offNames.join(", ")) : "None") + step(3, "Turned off", "", c.off ? escHtml(c.offNames.join(", ")) : "None") +
+2 -2
View File
@@ -244,9 +244,9 @@ async function init() {
if (badge && cfg.role_label) badge.textContent = cfg.role_label; if (badge && cfg.role_label) badge.textContent = cfg.role_label;
window._roleLabel = cfg.role_label || ""; window._roleLabel = cfg.role_label || "";
await refreshServices();
loadNetwork(); loadNetwork();
checkUpdates(); checkUpdates();
await refreshServices();
setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(refreshServices, POLL_INTERVAL_SERVICES);
setInterval(checkUpdates, POLL_INTERVAL_UPDATES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
@@ -256,9 +256,9 @@ async function init() {
} }
loadAutolaunchToggle(); loadAutolaunchToggle();
} catch (_) { } catch (_) {
await refreshServices();
loadNetwork(); loadNetwork();
checkUpdates(); checkUpdates();
await refreshServices();
setInterval(refreshServices, POLL_INTERVAL_SERVICES); setInterval(refreshServices, POLL_INTERVAL_SERVICES);
setInterval(checkUpdates, POLL_INTERVAL_UPDATES); setInterval(checkUpdates, POLL_INTERVAL_UPDATES);
loadAutolaunchToggle(); loadAutolaunchToggle();
@@ -5,7 +5,12 @@
function openSecurityModal() { function openSecurityModal() {
if ($supportModal) $supportModal.classList.add("open"); if ($supportModal) $supportModal.classList.add("open");
var title = document.getElementById("support-modal-title"); var title = document.getElementById("support-modal-title");
if (title) title.textContent = "\uD83D\uDEE1 Security"; if (title) title.textContent = "Security";
var chip = document.getElementById("support-modal-chip");
if (chip) {
var use = chip.querySelector("use");
if (use) use.setAttribute("href", "#g-shield-check");
}
if ($supportBody) { if ($supportBody) {
$supportBody.innerHTML = $supportBody.innerHTML =
@@ -5,6 +5,14 @@
async function openSupportModal() { async function openSupportModal() {
if (!$supportModal) return; if (!$supportModal) return;
$supportModal.classList.add("open"); $supportModal.classList.add("open");
// The dialog is shared with Security — always restore its identity
var title = document.getElementById("support-modal-title");
if (title) title.textContent = "Tech Support";
var chip = document.getElementById("support-modal-chip");
if (chip) {
var use = chip.querySelector("use");
if (use) use.setAttribute("href", "#g-lifebuoy");
}
$supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>'; $supportBody.innerHTML = '<p class="creds-loading">Checking support status…</p>';
try { try {
var status = await apiFetch("/api/support/status"); var status = await apiFetch("/api/support/status");
+24 -9
View File
@@ -71,6 +71,8 @@ function renderSidebarSupport(supportServices) {
'</span>'; '</span>';
sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); }); sidebarUpdateBtn.addEventListener("click", function() { openUpdateModal(); });
$sidebarSupport.appendChild(sidebarUpdateBtn); $sidebarSupport.appendChild(sidebarUpdateBtn);
// checkUpdates may already have run before the sidebar was built
applyUpdateSidebarState(window._lastUpdateCheck);
for (var i = 0; i < supportServices.length; i++) { for (var i = 0; i < supportServices.length; i++) {
var svc = supportServices[i]; var svc = supportServices[i];
@@ -251,6 +253,10 @@ async function refreshServices() {
var services = await apiFetch("/api/services"); var services = await apiFetch("/api/services");
if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; } if (_firstLoad) { buildTiles(services, _categoryLabels); _firstLoad = false; }
else { updateTiles(services); } else { updateTiles(services); }
// Service data has rendered — the dashboard is ready; lift the boot
// splash (no-op once lifted). Note: dashboardServicesUpdated may also
// fire from checkUpdates before this resolves, so the lift lives here.
if (typeof window.__liftAppSplash === "function") window.__liftAppSplash();
} catch (err) { console.warn("Failed to fetch services:", err); } } catch (err) { console.warn("Failed to fetch services:", err); }
} }
@@ -270,16 +276,16 @@ async function loadNetwork() {
// ── Update check ────────────────────────────────────────────────── // ── Update check ──────────────────────────────────────────────────
async function checkUpdates() { // Paint the sidebar Update button from the last known update state.
try { // Called after each check AND when the button is (re)built, so the hint
var data = await apiFetch("/api/updates/check"); // is correct regardless of which finishes first at startup.
window._lastUpdateCheck = data; function applyUpdateSidebarState(data) {
if (typeof markUpdateChecked === "function") markUpdateChecked(); if (!data) return;
var hasUpdates = !!data.available;
var updateStatus = data.status || "idle";
var sidebarUpdateBtn = document.getElementById("sidebar-btn-update"); var sidebarUpdateBtn = document.getElementById("sidebar-btn-update");
var sidebarUpdateHint = document.getElementById("sidebar-update-hint"); var sidebarUpdateHint = document.getElementById("sidebar-update-hint");
if (sidebarUpdateBtn) { if (!sidebarUpdateBtn) return;
var hasUpdates = !!data.available;
var updateStatus = data.status || "idle";
if (updateStatus === "failed") { if (updateStatus === "failed") {
// Last update errored and did not apply — surface it as a persistent // 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. // red banner that re-opens the failed run with a "Retry Update" action.
@@ -303,6 +309,15 @@ async function checkUpdates() {
sidebarUpdateBtn.style.backgroundColor = ""; sidebarUpdateBtn.style.backgroundColor = "";
if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date"; if (sidebarUpdateHint) sidebarUpdateHint.textContent = "System is up to date";
} }
} }
async function checkUpdates() {
try {
var data = await apiFetch("/api/updates/check");
window._lastUpdateCheck = data;
if (typeof markUpdateChecked === "function") markUpdateChecked();
applyUpdateSidebarState(data);
// The welcome dashboard's updates card reads this state
if (typeof window.dashboardServicesUpdated === "function") window.dashboardServicesUpdated();
} catch (_) {} } catch (_) {}
} }
+45 -2
View File
@@ -111,6 +111,11 @@
<path d="M8.6 12l2.4 2.4 4.4-4.6"/> <path d="M8.6 12l2.4 2.4 4.4-4.6"/>
</g> </g>
</symbol> </symbol>
<!-- Systems Operational: activity pulse (system health) — distinct from
the Security shield above -->
<symbol id="g-pulse" viewBox="0 0 24 24">
<path fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" d="M3 12h4l3-7 4 14 3-7h4"/>
</symbol>
<symbol id="g-lifebuoy" viewBox="0 0 24 24"> <symbol id="g-lifebuoy" viewBox="0 0 24 24">
<g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"> <g fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<circle cx="12" cy="12" r="8.6"/><circle cx="12" cy="12" r="3.6"/> <circle cx="12" cy="12" r="8.6"/><circle cx="12" cy="12" r="3.6"/>
@@ -165,6 +170,41 @@
</symbol> </symbol>
</svg> </svg>
<!-- ═══ BOOT SPLASH ═══ covers the shell while the first data loads;
dashboard.js lifts it once the welcome cards are rendered -->
<div id="app-splash" role="status" aria-live="polite">
<div class="splash-card">
<div class="splash-ring">
<img src="/static/sovran-hub-icon.svg" alt="" width="64" height="64" />
<span class="splash-ring-arc" aria-hidden="true"></span>
</div>
<div class="splash-title">Starting The Hub</div>
<div class="splash-sub" id="splash-sub">Gathering your services…</div>
</div>
</div>
<script>
(function () {
var splash = document.getElementById("app-splash");
var sub = document.getElementById("splash-sub");
var lifted = false;
function lift() {
if (lifted || !splash) return;
lifted = true;
splash.classList.add("done");
setTimeout(function () {
if (splash && splash.parentNode) splash.parentNode.removeChild(splash);
}, 500);
}
window.__liftAppSplash = lift;
// Reassure the user if the backend is slow (e.g. right after a reboot)
setTimeout(function () {
if (!lifted && sub) sub.textContent = "Still starting\u2026 this can take a moment after a reboot.";
}, 8000);
// Never trap the user behind the splash
setTimeout(lift, 25000);
})();
</script>
<div class="app"> <div class="app">
<!-- ═══ SIDEBAR ═══ --> <!-- ═══ SIDEBAR ═══ -->
@@ -250,7 +290,7 @@
<div class="creds-dialog"> <div class="creds-dialog">
<div class="creds-header"> <div class="creds-header">
<span class="creds-title" id="systems-modal-title"> <span class="creds-title" id="systems-modal-title">
<span class="widget-chip chip-green" style="width:54px;height:54px;border-radius:16px"><svg style="width:27px;height:27px"><use href="#g-shield-check"/></svg></span> <span class="widget-chip chip-green" style="width:54px;height:54px;border-radius:16px"><svg style="width:27px;height:27px"><use href="#g-pulse"/></svg></span>
<span>Systems Operational</span> <span>Systems Operational</span>
</span> </span>
<button class="creds-close-btn" id="systems-close-btn" title="Close"></button> <button class="creds-close-btn" id="systems-close-btn" title="Close"></button>
@@ -317,7 +357,10 @@
<div class="modal-overlay" id="support-modal" role="dialog" aria-modal="true" aria-labelledby="support-modal-title"> <div class="modal-overlay" id="support-modal" role="dialog" aria-modal="true" aria-labelledby="support-modal-title">
<div class="creds-dialog"> <div class="creds-dialog">
<div class="creds-header"> <div class="creds-header">
<span class="creds-title" id="support-modal-title">Tech Support</span> <span class="creds-title">
<span class="widget-chip chip-neutral" id="support-modal-chip" style="width:54px;height:54px;border-radius:16px"><svg style="width:27px;height:27px"><use href="#g-lifebuoy"/></svg></span>
<span id="support-modal-title">Tech Support</span>
</span>
<button class="creds-close-btn" id="support-close-btn" title="Close"></button> <button class="creds-close-btn" id="support-close-btn" title="Close"></button>
</div> </div>
<div class="creds-body" id="support-body"> <div class="creds-body" id="support-body">