From 3bd27f59d790b7ee29183eefdc4499510d9d3d7f Mon Sep 17 00:00:00 2001 From: Andrew Simonson Date: Fri, 20 Feb 2026 22:15:21 -0600 Subject: [PATCH] feat: implement SPA loading bar, harden status page logic, and optimize Dockerfile --- Dockerfile | 30 +++++++- src/static/css/App.css | 37 +++++++++- src/static/js/responsive.js | 93 ++++++++++++++++-------- src/static/js/status.js | 136 ++++++++++++++++++++---------------- src/templates/header.html | 1 + 5 files changed, 202 insertions(+), 95 deletions(-) diff --git a/Dockerfile b/Dockerfile index b7fb346..32b0363 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,10 +1,34 @@ -FROM python:3.10-bullseye +# Use a slimmer base image to reduce image size and pull times +FROM python:3.10-slim-bullseye LABEL maintainer="Andrew Simonson " +# Set environment variables for better Python performance in Docker +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + WORKDIR /app +# Create a non-root user for security +RUN groupadd -r appuser && useradd -r -g appuser appuser + +# Copy only the requirements file first to leverage Docker layer caching +COPY src/requirements.txt . + +# Install dependencies as root, but then switch to the non-root user +RUN pip install -r requirements.txt + +# Copy the rest of the source code COPY src/ . -RUN pip install --no-cache-dir -r requirements.txt +# Ensure the appuser owns the app directory +RUN chown -R appuser:appuser /app -CMD [ "gunicorn", "--bind", "0.0.0.0:8080", "app:app"] +# Switch to the non-root user for better security +USER appuser + +# Expose the port (Gunicorn's default or specified in CMD) +EXPOSE 8080 + +# Start Gunicorn +CMD ["gunicorn", "--bind", "0.0.0.0:8080", "app:app"] diff --git a/src/static/css/App.css b/src/static/css/App.css index 0654037..8341f42 100755 --- a/src/static/css/App.css +++ b/src/static/css/App.css @@ -1839,4 +1839,39 @@ tr { .text-muted { color: #888 !important; -} \ No newline at end of file +} +/* SPA Loading Bar */ +#loading-bar { + position: fixed; + top: 0; + left: 0; + height: 3px; + background: linear-gradient(90deg, var(--accent) 0%, #fff 50%, var(--accent) 100%); + background-size: 200% 100%; + box-shadow: 0 0 12px rgba(var(--accent-rgb), 0.6); + z-index: 2000; + width: 0%; + opacity: 0; + transition: width 0.4s cubic-bezier(0.1, 0.05, 0.1, 1), opacity 0.3s ease; + pointer-events: none; +} + +#loading-bar.visible { + opacity: 1; +} + +#loading-bar.active { + width: 70%; + animation: loading-shimmer 2s infinite linear; +} + +#loading-bar.finish { + opacity: 0; + width: 100%; + transition: width 0.2s ease, opacity 0.4s ease 0.1s; +} + +@keyframes loading-shimmer { + from { background-position: 200% 0; } + to { background-position: -200% 0; } +} diff --git a/src/static/js/responsive.js b/src/static/js/responsive.js index aebcbd5..8a6e62e 100755 --- a/src/static/js/responsive.js +++ b/src/static/js/responsive.js @@ -14,50 +14,81 @@ function toggleMenu(collapse) { } async function goto(location, { push = true } = {}) { - let response; + const loadingBar = document.getElementById('loading-bar'); + console.log(`Navigating to ${location}`); + + if (loadingBar) { + loadingBar.style.width = ''; // Clear inline style from previous run + } + + let loadingTimeout = setTimeout(() => { + if (loadingBar) { + console.log("Navigation taking > 150ms, showing bar"); + loadingBar.classList.remove('finish'); + loadingBar.classList.add('active'); + loadingBar.classList.add('visible'); + } + }, 150); + try { - response = await fetch("/api/goto/" + location, { + const response = await fetch("/api/goto/" + location, { credentials: "include", method: "GET", mode: "cors", }); + if (!response.ok) { - console.error(`Navigation failed: HTTP ${response.status}`); - return; + throw new Error(`HTTP ${response.status}`); } - } catch (err) { - console.error("Navigation fetch failed:", err); - return; - } - document.dispatchEvent(new Event('beforenavigate')); + // Wait for the full body to download - this is usually the slow part + const [metadata, content] = await response.json(); + + document.dispatchEvent(new Event('beforenavigate')); - const [metadata, content] = await response.json(); - const root = document.getElementById("root"); - root.innerHTML = content; + const root = document.getElementById("root"); + root.innerHTML = content; - // Re-execute scripts injected via innerHTML (browser ignores them otherwise) - root.querySelectorAll("script").forEach(function(oldScript) { - const newScript = document.createElement("script"); - Array.from(oldScript.attributes).forEach(function(attr) { - newScript.setAttribute(attr.name, attr.value); + // Re-execute scripts + root.querySelectorAll("script").forEach(function(oldScript) { + const newScript = document.createElement("script"); + Array.from(oldScript.attributes).forEach(function(attr) { + newScript.setAttribute(attr.name, attr.value); + }); + newScript.textContent = oldScript.textContent; + oldScript.parentNode.replaceChild(newScript, oldScript); }); - newScript.textContent = oldScript.textContent; - oldScript.parentNode.replaceChild(newScript, oldScript); - }); - if (window.location.href.includes("#")) { - const id = decodeURIComponent(window.location.hash.substring(1)); - const el = document.getElementById(id); - if (el) el.scrollIntoView(); - } else { - window.scrollTo({ top: 0, left: 0, behavior: "instant" }); - } + if (window.location.href.includes("#")) { + const id = decodeURIComponent(window.location.hash.substring(1)); + const el = document.getElementById(id); + if (el) el.scrollIntoView(); + } else { + window.scrollTo({ top: 0, left: 0, behavior: "instant" }); + } - toggleMenu(true); - document.querySelector("title").textContent = metadata["title"]; - if (push) { - history.pushState(null, null, metadata["canonical"]); + toggleMenu(true); + document.querySelector("title").textContent = metadata["title"]; + if (push) { + history.pushState(null, null, metadata["canonical"]); + } + + } catch (err) { + console.error("Navigation failed:", err); + } finally { + clearTimeout(loadingTimeout); + if (loadingBar && loadingBar.classList.contains('active')) { + console.log("Navigation finished, hiding bar"); + loadingBar.classList.add('finish'); + loadingBar.classList.remove('active'); + setTimeout(() => { + if (!loadingBar.classList.contains('active')) { + loadingBar.style.width = '0%'; + loadingBar.classList.remove('finish'); + loadingBar.classList.remove('visible'); + } + }, 500); + } } } diff --git a/src/static/js/status.js b/src/static/js/status.js index 45744ab..dff2791 100644 --- a/src/static/js/status.js +++ b/src/static/js/status.js @@ -1,4 +1,8 @@ -let statusIntervalId = null; +// Use a global to track the interval and ensure we don't stack listeners +if (window.statusIntervalId) { + clearInterval(window.statusIntervalId); + window.statusIntervalId = null; +} async function fetchStatus() { try { @@ -17,7 +21,8 @@ async function fetchStatus() { function updateStatusDisplay(data) { if (data.last_check) { const lastCheck = new Date(data.last_check); - document.getElementById('lastUpdate').textContent = `Last checked: ${lastCheck.toLocaleString()}`; + const lastUpdateEl = document.getElementById('lastUpdate'); + if (lastUpdateEl) lastUpdateEl.textContent = `Last checked: ${lastCheck.toLocaleString()}`; } if (data.next_check) { @@ -28,11 +33,12 @@ function updateStatusDisplay(data) { } } - data.services.forEach(function(service) { - updateServiceCard(service); - }); - - updateOverallStatus(data.services); + if (data.services) { + data.services.forEach(function(service) { + updateServiceCard(service); + }); + updateOverallStatus(data.services); + } const refreshBtn = document.getElementById('refreshBtn'); if (refreshBtn) { @@ -65,36 +71,38 @@ function updateServiceCard(service) { const uptimeDisplay = document.getElementById(`uptime-${service.id}`); const checksDisplay = document.getElementById(`checks-${service.id}`); - timeDisplay.textContent = service.response_time !== null ? `${service.response_time}ms` : '--'; + if (timeDisplay) timeDisplay.textContent = service.response_time !== null ? `${service.response_time}ms` : '--'; - if (service.status_code !== null) { - codeDisplay.textContent = service.status_code; - } else { - codeDisplay.textContent = service.status === 'unknown' ? 'Unknown' : 'Error'; + if (codeDisplay) { + if (service.status_code !== null) { + codeDisplay.textContent = service.status_code; + } else { + codeDisplay.textContent = service.status === 'unknown' ? 'Unknown' : 'Error'; + } } card.classList.remove('online', 'degraded', 'offline', 'unknown'); switch (service.status) { case 'online': - stateDot.className = 'state-dot online'; - stateText.textContent = 'Operational'; + if (stateDot) stateDot.className = 'state-dot online'; + if (stateText) stateText.textContent = 'Operational'; card.classList.add('online'); break; case 'degraded': case 'timeout': - stateDot.className = 'state-dot degraded'; - stateText.textContent = service.status === 'timeout' ? 'Timeout' : 'Degraded'; + if (stateDot) stateDot.className = 'state-dot degraded'; + if (stateText) stateText.textContent = service.status === 'timeout' ? 'Timeout' : 'Degraded'; card.classList.add('degraded'); break; case 'offline': - stateDot.className = 'state-dot offline'; - stateText.textContent = 'Offline'; + if (stateDot) stateDot.className = 'state-dot offline'; + if (stateText) stateText.textContent = 'Offline'; card.classList.add('offline'); break; default: - stateDot.className = 'state-dot loading'; - stateText.textContent = 'Unknown'; + if (stateDot) stateDot.className = 'state-dot loading'; + if (stateText) stateText.textContent = 'Unknown'; card.classList.add('unknown'); } @@ -114,6 +122,8 @@ function updateServiceCard(service) { function updateOverallStatus(services) { const overallBar = document.getElementById('overallStatus'); + if (!overallBar) return; + const icon = overallBar.querySelector('.summary-icon'); const title = overallBar.querySelector('.summary-title'); const subtitle = document.getElementById('summary-subtitle'); @@ -125,46 +135,52 @@ function updateOverallStatus(services) { const degraded = services.filter(function(s) { return s.status === 'degraded' || s.status === 'timeout'; }).length; const offline = services.filter(function(s) { return s.status === 'offline'; }).length; - onlineCount.textContent = online; - totalCount.textContent = total; + if (onlineCount) onlineCount.textContent = online; + if (totalCount) totalCount.textContent = total; overallBar.classList.remove('online', 'degraded', 'offline'); - icon.classList.remove('operational', 'partial', 'major', 'loading'); + if (icon) icon.classList.remove('operational', 'partial', 'major', 'loading'); // Determine overall status if (online === total) { - // All systems operational overallBar.classList.add('online'); - icon.classList.add('operational'); - icon.textContent = '\u2713'; - title.textContent = 'All Systems Operational'; - subtitle.textContent = `All ${total} services are running normally`; + if (icon) { + icon.classList.add('operational'); + icon.textContent = '\u2713'; + } + if (title) title.textContent = 'All Systems Operational'; + if (subtitle) subtitle.textContent = `All ${total} services are running normally`; } else if (offline >= Math.ceil(total / 2)) { - // Major outage (50% or more offline) overallBar.classList.add('offline'); - icon.classList.add('major'); - icon.textContent = '\u2715'; - title.textContent = 'Major Outage'; - subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline, ${degraded} degraded`; + if (icon) { + icon.classList.add('major'); + icon.textContent = '\u2715'; + } + if (title) title.textContent = 'Major Outage'; + if (subtitle) subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline, ${degraded} degraded`; } else if (offline > 0 || degraded > 0) { - // Partial outage overallBar.classList.add('degraded'); - icon.classList.add('partial'); - icon.textContent = '\u26A0'; - title.textContent = 'Partial Outage'; - if (offline > 0 && degraded > 0) { - subtitle.textContent = `${offline} offline, ${degraded} degraded`; - } else if (offline > 0) { - subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline`; - } else { - subtitle.textContent = `${degraded} service${degraded !== 1 ? 's' : ''} degraded`; + if (icon) { + icon.classList.add('partial'); + icon.textContent = '\u26A0'; + } + if (title) title.textContent = 'Partial Outage'; + if (subtitle) { + if (offline > 0 && degraded > 0) { + subtitle.textContent = `${offline} offline, ${degraded} degraded`; + } else if (offline > 0) { + subtitle.textContent = `${offline} service${offline !== 1 ? 's' : ''} offline`; + } else { + subtitle.textContent = `${degraded} service${degraded !== 1 ? 's' : ''} degraded`; + } } } else { - // Unknown state - icon.classList.add('loading'); - icon.textContent = '\u25D0'; - title.textContent = 'Status Unknown'; - subtitle.textContent = 'Waiting for service data'; + if (icon) { + icon.classList.add('loading'); + icon.textContent = '\u25D0'; + } + if (title) title.textContent = 'Status Unknown'; + if (subtitle) subtitle.textContent = 'Waiting for service data'; } } @@ -191,23 +207,23 @@ function refreshStatus() { } function initStatusPage() { - if (statusIntervalId !== null) { - clearInterval(statusIntervalId); + if (window.statusIntervalId) { + clearInterval(window.statusIntervalId); } fetchStatus(); - statusIntervalId = setInterval(fetchStatus, 60000); + window.statusIntervalId = setInterval(fetchStatus, 60000); } -// Clean up polling interval when navigating away via SPA -document.addEventListener('beforenavigate', function() { - if (statusIntervalId !== null) { - clearInterval(statusIntervalId); - statusIntervalId = null; +function cleanupStatusPage() { + if (window.statusIntervalId) { + clearInterval(window.statusIntervalId); + window.statusIntervalId = null; } -}); + document.removeEventListener('beforenavigate', cleanupStatusPage); +} -if (document.readyState === 'loading') { - document.addEventListener('DOMContentLoaded', initStatusPage); -} else { +document.addEventListener('beforenavigate', cleanupStatusPage); + +if (document.getElementById('overallStatus')) { initStatusPage(); } diff --git a/src/templates/header.html b/src/templates/header.html index 2a9b733..bd4f532 100755 --- a/src/templates/header.html +++ b/src/templates/header.html @@ -59,6 +59,7 @@ {% block header %} +