From 0f4bd419e1158a92aaf3ad24d7e99ff09b6f7494 Mon Sep 17 00:00:00 2001 From: Sami Date: Wed, 11 Mar 2026 22:39:29 -0700 Subject: [PATCH] Add batched status endpoint and optimize frontend performance Server-side batched /api/v1/services/status endpoint replaces N individual browser probes with a single API call (HEAD-first with GET fallback, concurrency-limited, CA-aware HTTPS agent). Frontend: clock reuses DOM instead of rebuilding innerHTML every second with drift-correcting timer that pauses on hidden tabs. Card animations use CSS transitionDelay + requestAnimationFrame. Internet dot blink moved from JS intervals to CSS keyframes with prefers-reduced-motion support. Service worker rewritten with network-first navigation, stale-while-revalidate assets, and navigation preload. Font faces drop TTF fallbacks, use font-display swap. Co-Authored-By: Claude Opus 4.6 --- dashcaddy-api/routes/services.js | 158 ++++++++++++++++++++++++++++++- status/css/dashboard.css | 76 +++++++++------ status/js/clock.js | 70 ++++++++++++-- status/js/core/grid.js | 144 ++++++++++++++++------------ status/js/core/init.js | 29 ++++-- status/sw.js | 149 +++++++++++++++++++++-------- 6 files changed, 476 insertions(+), 150 deletions(-) diff --git a/dashcaddy-api/routes/services.js b/dashcaddy-api/routes/services.js index 05703ee..abedd0f 100644 --- a/dashcaddy-api/routes/services.js +++ b/dashcaddy-api/routes/services.js @@ -1,13 +1,135 @@ const express = require('express'); const fs = require('fs'); +const http = require('http'); +const https = require('https'); +const tls = require('tls'); const validatorLib = require('validator'); -const { REGEX } = require('../constants'); +const { APP, REGEX, TIMEOUTS } = require('../constants'); const { validateServiceConfig, isValidPort } = require('../input-validator'); const { exists } = require('../fs-helpers'); const { paginate, parsePaginationParams } = require('../pagination'); module.exports = function(ctx) { const router = express.Router(); + const CA_CERT_PATH = process.env.CA_CERT_PATH || '/app/pki/root.crt'; + const PROBE_CONCURRENCY = 6; + let probeHttpsAgent; + + try { + const caCert = fs.readFileSync(CA_CERT_PATH); + probeHttpsAgent = new https.Agent({ ca: [...tls.rootCertificates, caCert] }); + } catch (_) { + probeHttpsAgent = new https.Agent(); + } + + function isServiceUp(statusCode) { + return (statusCode >= 200 && statusCode < 400) || statusCode === 401 || statusCode === 403; + } + + async function loadServicesList() { + if (!await exists(ctx.SERVICES_FILE)) return []; + const data = await ctx.servicesStateManager.read(); + return Array.isArray(data) ? data : data.services || []; + } + + function resolveProbeUrl(id, service) { + if (id === 'internet') return 'https://www.google.com'; + if (service?.isExternal && service.externalUrl) return service.externalUrl; + if (service?.url) return service.url.startsWith('http') ? service.url : `https://${service.url}`; + return ctx.buildServiceUrl(id); + } + + function requestStatusCode(url, method) { + const parsed = new URL(url); + const isHttps = parsed.protocol === 'https:'; + const lib = isHttps ? https : http; + + return new Promise((resolve, reject) => { + const req = lib.request({ + hostname: parsed.hostname, + port: parsed.port || (isHttps ? 443 : 80), + path: parsed.pathname + parsed.search, + method, + timeout: TIMEOUTS.HTTP_DEFAULT, + agent: isHttps ? probeHttpsAgent : undefined, + headers: { 'User-Agent': APP.USER_AGENTS.PROBE }, + }, (response) => { + response.resume(); + resolve(response.statusCode || 0); + }); + + req.on('error', reject); + req.on('timeout', () => { + req.destroy(); + reject(new Error('Timeout')); + }); + req.end(); + }); + } + + async function probeServiceStatus(id, service) { + const startedAt = process.hrtime.bigint(); + let url = resolveProbeUrl(id, service); + let statusCode = 502; + let error = null; + + try { + statusCode = await requestStatusCode(url, 'HEAD'); + if (statusCode === 405 || statusCode === 501) { + statusCode = await requestStatusCode(url, 'GET'); + } + } catch (primaryError) { + error = primaryError; + if (id !== 'internet') { + const fallbackUrl = ctx.buildServiceUrl(id); + if (fallbackUrl !== url) { + try { + statusCode = await requestStatusCode(fallbackUrl, 'GET'); + url = fallbackUrl; + error = null; + } catch (fallbackError) { + error = fallbackError; + } + } + } + } + + const responseTime = Number((process.hrtime.bigint() - startedAt) / 1000000n); + if (error) { + return { + id, + isUp: false, + statusCode: 502, + responseTime, + error: error.message + }; + } + + return { + id, + isUp: isServiceUp(statusCode), + statusCode, + responseTime, + url + }; + } + + async function mapWithConcurrency(items, limit, worker) { + const results = new Array(items.length); + let cursor = 0; + + async function next() { + while (true) { + const index = cursor++; + if (index >= items.length) return; + results[index] = await worker(items[index], index); + } + } + + const workers = Array.from({ length: Math.min(limit, items.length || 1) }, () => next()); + await Promise.all(workers); + return results; + } // ===== SERVICE CREDENTIAL ENDPOINTS ===== @@ -111,6 +233,40 @@ module.exports = function(ctx) { // ===== SERVICE CRUD ENDPOINTS ===== + // Batched live status for dashboard cards + router.get('/services/status', ctx.asyncHandler(async (req, res) => { + const services = await loadServicesList(); + const serviceMap = new Map(services.filter(s => s && s.id).map(s => [s.id, s])); + const ids = []; + const seen = new Set(); + + function addId(id) { + if (!id || seen.has(id)) return; + seen.add(id); + ids.push(id); + } + + addId('internet'); + Object.keys(ctx.siteConfig?.dnsServers || {}).forEach(addId); + services.forEach(service => addId(service.id)); + + const statusResults = await mapWithConcurrency(ids, PROBE_CONCURRENCY, (id) => + probeServiceStatus(id, serviceMap.get(id)) + ); + + const statuses = {}; + statusResults.forEach((result) => { + statuses[result.id] = result; + }); + + res.set('Cache-Control', 'no-store'); + res.json({ + success: true, + checkedAt: new Date().toISOString(), + statuses + }); + }, 'services-status')); + // List all services router.get('/services', ctx.asyncHandler(async (req, res) => { if (!await exists(ctx.SERVICES_FILE)) { diff --git a/status/css/dashboard.css b/status/css/dashboard.css index 0c559d8..c0125d4 100644 --- a/status/css/dashboard.css +++ b/status/css/dashboard.css @@ -1,75 +1,67 @@ /* Sami Grotesk Custom Font Family */ @font-face { font-family: 'Sami Grotesk'; - src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Light.woff2') format('woff2'), - url('/assets/fonts/sami-grotesk/SamiGrotesk-Light.ttf') format('truetype'); + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Light.woff2') format('woff2'); font-weight: 300; font-style: normal; - font-display: block; + font-display: swap; } @font-face { font-family: 'Sami Grotesk'; - src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Regular.woff2') format('woff2'), - url('/assets/fonts/sami-grotesk/SamiGrotesk-Regular.ttf') format('truetype'); + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Regular.woff2') format('woff2'); font-weight: 400; font-style: normal; - font-display: block; + font-display: swap; } @font-face { font-family: 'Sami Grotesk'; - src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Medium.woff2') format('woff2'), - url('/assets/fonts/sami-grotesk/SamiGrotesk-Medium.ttf') format('truetype'); + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Medium.woff2') format('woff2'); font-weight: 500; font-style: normal; - font-display: block; + font-display: swap; } @font-face { font-family: 'Sami Grotesk'; - src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Bold.woff2') format('woff2'), - url('/assets/fonts/sami-grotesk/SamiGrotesk-Bold.ttf') format('truetype'); + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Bold.woff2') format('woff2'); font-weight: 700; font-style: normal; - font-display: block; + font-display: swap; } @font-face { font-family: 'Sami Grotesk'; - src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Black.woff2') format('woff2'), - url('/assets/fonts/sami-grotesk/SamiGrotesk-Black.ttf') format('truetype'); + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-Black.woff2') format('woff2'); font-weight: 900; font-style: normal; - font-display: block; + font-display: swap; } /* Italic variants */ @font-face { font-family: 'Sami Grotesk'; - src: url('/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.woff2') format('woff2'), - url('/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.ttf') format('truetype'); + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-RegularItalic.woff2') format('woff2'); font-weight: 400; font-style: italic; - font-display: block; + font-display: swap; } @font-face { font-family: 'Sami Grotesk'; - src: url('/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.woff2') format('woff2'), - url('/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.ttf') format('truetype'); + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-MediumItalic.woff2') format('woff2'); font-weight: 500; font-style: italic; - font-display: block; + font-display: swap; } @font-face { font-family: 'Sami Grotesk'; - src: url('/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.woff2') format('woff2'), - url('/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.ttf') format('truetype'); + src: url('/assets/fonts/sami-grotesk/SamiGrotesk-BoldItalic.woff2') format('woff2'); font-weight: 700; font-style: italic; - font-display: block; + font-display: swap; } /* Theme variables and transitions are in themes.css */ @@ -2018,15 +2010,37 @@ button:focus-visible { /* Internet card packet blink */ .card[data-app="internet"] .dot { - transition: all 0.1s ease; + transition: background-color 0.18s ease, box-shadow 0.18s ease, transform 0.18s ease; } -.card[data-app="internet"] .dot.packet-rx { - box-shadow: 0 0 8px 2px #4caf50; - background: #4caf50; + +.card[data-app="internet"][data-status="on"] .dot { + animation: internet-traffic 1.35s ease-in-out infinite; } -.card[data-app="internet"] .dot.packet-tx { - box-shadow: 0 0 8px 2px #2196f3; - background: #2196f3; + +@keyframes internet-traffic { + 0%, 100% { + transform: scale(1); + background: var(--dot-ok); + box-shadow: 0 0 0 rgba(39, 174, 96, 0); + } + + 38% { + transform: scale(1.06); + background: #4caf50; + box-shadow: 0 0 10px 2px rgba(76, 175, 80, 0.55); + } + + 68% { + transform: scale(0.98); + background: #2196f3; + box-shadow: 0 0 10px 2px rgba(33, 150, 243, 0.5); + } +} + +@media (prefers-reduced-motion: reduce) { + .card[data-app="internet"][data-status="on"] .dot { + animation: none; + } } /* Restart button styling */ diff --git a/status/js/clock.js b/status/js/clock.js index daaaa74..a658003 100644 --- a/status/js/clock.js +++ b/status/js/clock.js @@ -12,6 +12,9 @@ let lastChimeHour = -1; let chimePlaying = false; let prevFlipDigits = ''; + let activeLayout = ''; + let digitalRefs = null; + let tickTimer = null; // ===== CHIMES ===== function playChimes(count) { @@ -37,24 +40,49 @@ return DAYS[now.getDay()] + ', ' + MONTHS[now.getMonth()] + ' ' + now.getDate() + ', ' + now.getFullYear(); } + function resetRenderLayout() { + activeLayout = ''; + digitalRefs = null; + } + + function ensureDigitalLayout() { + if (activeLayout !== 'digital') { + render.innerHTML = + '
' + + '
'; + digitalRefs = { + main: render.querySelector('.clock-main'), + seconds: render.querySelector('.clock-seconds'), + ampm: render.querySelector('.clock-ampm'), + date: render.querySelector('.clock-date') + }; + activeLayout = 'digital'; + } + return digitalRefs; + } + // ===== STYLE RENDERERS ===== function renderDefault(now) { const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds(); const ampm = h24 >= 12 ? 'PM' : 'AM'; const h = h24 % 12 || 12; - render.innerHTML = - `
${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}${ampm}
` + - `
${dateStr(now)}
`; + const refs = ensureDigitalLayout(); + refs.main.textContent = `${h}:${String(m).padStart(2,'0')}`; + refs.seconds.textContent = `:${String(s).padStart(2,'0')}`; + refs.ampm.textContent = ampm; + refs.date.textContent = dateStr(now); } function renderLcd(now, cls) { const h24 = now.getHours(), m = now.getMinutes(), s = now.getSeconds(); const ampm = h24 >= 12 ? 'PM' : 'AM'; const h = h24 % 12 || 12; - render.innerHTML = - `
${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}${ampm}
` + - `
${dateStr(now)}
`; + const refs = ensureDigitalLayout(); + refs.main.textContent = `${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`; + refs.seconds.textContent = `:${String(s).padStart(2,'0')}`; + refs.ampm.textContent = ampm; + refs.date.textContent = dateStr(now); } function renderFlip(now) { @@ -79,6 +107,7 @@ html += ''; html += `
${dateStr(now)}
`; render.innerHTML = html; + activeLayout = 'flip'; // Trigger flip animation on changed digits if (prevFlipDigits) { @@ -130,6 +159,7 @@ html += ''; html += `
${dateStr(now)}
`; render.innerHTML = html; + activeLayout = 'binary'; } function renderAnalog(now, useRoman) { @@ -180,6 +210,7 @@ const ampm = now.getHours() >= 12 ? 'PM' : 'AM'; render.innerHTML = `
${svg}
${(now.getHours() % 12 || 12)}:${String(m).padStart(2,'0')} ${ampm}${dateStr(now)}
`; + activeLayout = 'analog'; } // ===== MAIN TICK ===== @@ -190,7 +221,10 @@ const s = now.getSeconds(); // Set style class on widget - widget.className = 'clock-widget' + (currentStyle !== 'default' ? ' ' + currentStyle : ''); + const nextClassName = 'clock-widget' + (currentStyle !== 'default' ? ' ' + currentStyle : ''); + if (widget.className !== nextClassName) { + widget.className = nextClassName; + } switch (currentStyle) { case 'lcd': renderLcd(now); break; @@ -213,8 +247,25 @@ if (m !== 0) lastChimeHour = -1; } + function scheduleNextTick() { + clearTimeout(tickTimer); + const interval = document.hidden ? 60000 : 1000; + const delay = interval - (Date.now() % interval) + 25; + tickTimer = setTimeout(() => { + tick(); + scheduleNextTick(); + }, delay); + } + + document.addEventListener('visibilitychange', () => { + prevFlipDigits = ''; + resetRenderLayout(); + tick(); + scheduleNextTick(); + }); + tick(); - setInterval(tick, 1000); + scheduleNextTick(); // ===== SETTINGS MODAL ===== const STYLES = [ @@ -308,7 +359,9 @@ safeSet('clock-chime-volume', volumeSlider.value); currentStyle = style; prevFlipDigits = ''; + resetRenderLayout(); tick(); + scheduleNextTick(); modal.classList.remove('show'); showNotification('Clock settings saved', 'success', 2000); }); @@ -324,6 +377,7 @@ radio.addEventListener('change', () => { currentStyle = radio.value; prevFlipDigits = ''; + resetRenderLayout(); tick(); }); }); diff --git a/status/js/core/grid.js b/status/js/core/grid.js index 478432f..89d8008 100644 --- a/status/js/core/grid.js +++ b/status/js/core/grid.js @@ -29,45 +29,8 @@ card.setAttribute('data-status', up ? 'on' : 'off'); } - // Internet card packet blink effect - if (id === 'internet' && dot && up) { - blinkInternetPacket(dot); - } } - // Internet card packet activity blink - let internetBlinkInterval = null; - function blinkInternetPacket(dot) { - // Alternate between rx (green) and tx (blue) to simulate bidirectional traffic - const isRx = Math.random() > 0.5; - dot.classList.add(isRx ? 'packet-rx' : 'packet-tx'); - setTimeout(() => { - dot.classList.remove('packet-rx', 'packet-tx'); - }, 150); - } - - // Continuous packet simulation for Internet card when online - function startInternetPacketSimulation() { - if (internetBlinkInterval) return; - internetBlinkInterval = setInterval(() => { - const dot = document.getElementById('internet-dot'); - const card = document.querySelector('[data-app="internet"]'); - if (dot && card && card.getAttribute('data-status') === 'on') { - blinkInternetPacket(dot); - } - }, 300 + Math.random() * 400); // Random interval 300-700ms - } - - function stopInternetPacketSimulation() { - if (internetBlinkInterval) { - clearInterval(internetBlinkInterval); - internetBlinkInterval = null; - } - } - - // Start simulation on page load - startInternetPacketSimulation(); - function getResponseTimeClass(time, isUp) { if (!isUp) return 'timeout'; if (time < 200) return 'excellent'; @@ -93,6 +56,8 @@ /* App grid - loaded from API */ window.APPS = []; // Use window.APPS as the main array + let refreshInFlight = null; + let refreshQueued = false; // Load services from API async function loadServices() { @@ -290,17 +255,17 @@ btn.onclick = () => window.open(serviceUrl(s.id), '_blank', 'noopener'); btnRow.appendChild(btn); card.appendChild(btnRow); + card.style.transitionDelay = `${Math.min(i * 45, 270)}ms`; root.appendChild(card); - - // Staggered loading animation - setTimeout(() => { - card.classList.add('loaded'); - }, i * 100); // 100ms delay between each card } + requestAnimationFrame(() => { + root.querySelectorAll('.card').forEach(card => card.classList.add('loaded')); + }); + // Group recipe cards visually after grid is built - if (window.groupRecipeCards) setTimeout(window.groupRecipeCards, 50); + if (window.groupRecipeCards) requestAnimationFrame(() => window.groupRecipeCards()); } function setBadge(id, up, responseTime = null) { @@ -332,30 +297,83 @@ } async function refreshAll() { - // Check DNS servers dynamically (only those configured in SITE.dnsServers) - const dnsIds = Object.keys(SITE.dnsServers); - const topChecks = dnsIds.map(id => checkServiceWithTiming(id)); - topChecks.push(checkServiceWithTiming('internet')); - const topResults = await Promise.all(topChecks); + if (refreshInFlight) { + refreshQueued = true; + return refreshInFlight; + } - dnsIds.forEach((id, i) => setQuick(id, topResults[i].isUp, topResults[i].responseTime)); - const internetResult = topResults[topResults.length - 1]; - setQuick('internet', internetResult.isUp, internetResult.responseTime); + function updateStamp(label, checkedAt = new Date()) { + const stamp = document.getElementById('stamp'); + if (stamp) stamp.textContent = `${label}: ${new Date(checkedAt).toLocaleTimeString()}`; + } - // Check app services with timing - const appResults = await Promise.all( - window.APPS.map(async s => { - const result = await checkServiceWithTiming(s.id); - return { id: s.id, ...result }; - }) - ); + function applyBatchResults(statuses) { + const dnsIds = Object.keys(SITE.dnsServers); + dnsIds.forEach((id) => { + const result = statuses[id]; + if (result) setQuick(id, result.isUp, result.responseTime); + }); - appResults.forEach(result => { - setBadge(result.id, result.isUp, result.responseTime); - }); + if (statuses.internet) { + setQuick('internet', statuses.internet.isUp, statuses.internet.responseTime); + } - const stamp = document.getElementById('stamp'); - if (stamp) stamp.textContent = 'last check: ' + new Date().toLocaleTimeString(); + window.APPS.forEach((service) => { + const result = statuses[service.id]; + if (result) setBadge(service.id, result.isUp, result.responseTime); + }); + } + + async function refreshFallback() { + const dnsIds = Object.keys(SITE.dnsServers); + const topChecks = dnsIds.map(id => checkServiceWithTiming(id)); + topChecks.push(checkServiceWithTiming('internet')); + const topResults = await Promise.all(topChecks); + + dnsIds.forEach((id, i) => setQuick(id, topResults[i].isUp, topResults[i].responseTime)); + const internetResult = topResults[topResults.length - 1]; + setQuick('internet', internetResult.isUp, internetResult.responseTime); + + const appResults = await Promise.all( + window.APPS.map(async s => { + const result = await checkServiceWithTiming(s.id); + return { id: s.id, ...result }; + }) + ); + + appResults.forEach(result => { + setBadge(result.id, result.isUp, result.responseTime); + }); + } + + refreshInFlight = (async () => { + try { + const response = await fetch('/api/v1/services/status', { cache: 'no-store' }); + if (!response.ok) { + throw new Error(`Status refresh failed (${response.status})`); + } + const data = await response.json(); + applyBatchResults(data.statuses || {}); + updateStamp('last check', data.checkedAt || new Date()); + } catch (batchError) { + console.warn('Batched status refresh failed, falling back to direct probes:', batchError); + try { + await refreshFallback(); + updateStamp('last check'); + } catch (fallbackError) { + console.error('Dashboard refresh failed:', fallbackError); + updateStamp('last failed'); + } + } finally { + refreshInFlight = null; + if (refreshQueued) { + refreshQueued = false; + setTimeout(() => { window.refreshAll(); }, 0); + } + } + })(); + + return refreshInFlight; } // DNS open buttons — use event delegation on .top container diff --git a/status/js/core/init.js b/status/js/core/init.js index 5fa3617..fa25312 100644 --- a/status/js/core/init.js +++ b/status/js/core/init.js @@ -25,14 +25,28 @@ function animateTopCards() { const topCards = document.querySelectorAll('.top .card'); topCards.forEach((card, index) => { - card.style.opacity = '0'; - card.style.transform = 'translateY(20px)'; - setTimeout(() => { - card.style.transition = 'opacity 0.3s cubic-bezier(0.4, 0, 0.2, 1), transform 0.3s cubic-bezier(0.4, 0, 0.2, 1)'; - card.style.opacity = '1'; - card.style.transform = 'translateY(0)'; - }, index * 150); // 150ms delay between top cards + card.style.transitionDelay = `${Math.min(index * 60, 300)}ms`; }); + requestAnimationFrame(() => { + topCards.forEach(card => card.classList.add('loaded')); + }); + } + + function registerServiceWorker() { + if (!('serviceWorker' in navigator)) return; + if (!window.isSecureContext && location.hostname !== 'localhost' && location.hostname !== '127.0.0.1') return; + + const register = () => { + navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }).catch((error) => { + console.warn('[init] Service worker registration failed:', error); + }); + }; + + if (document.readyState === 'complete') { + register(); + } else { + window.addEventListener('load', register, { once: true }); + } } // Initialize dashboard (called after TOTP gate check or directly if TOTP disabled) @@ -184,6 +198,7 @@ window.initializeDashboard = initializeDashboard; window.loadCustomServices = loadCustomServices; + registerServiceWorker(); // TOTP-gated initialization (async () => { diff --git a/status/sw.js b/status/sw.js index 47f30fb..8bfc8ed 100644 --- a/status/sw.js +++ b/status/sw.js @@ -1,49 +1,118 @@ - -const CACHE = "sami-cloud-dashboard-v9"; +const CACHE = 'dashcaddy-shell-v10'; const PRECACHE = [ - "index.html", - "assets/site.webmanifest", - "assets/favicon.svg", - "assets/icon-192.png", - "assets/icon-512.png", - "assets/apple-touch-icon.png", - "assets/weather/clear-day.svg", - "assets/weather/clear-night.svg", - "assets/weather/partly-cloudy-day.svg", - "assets/weather/partly-cloudy-night.svg", - "assets/weather/cloudy.svg", - "assets/weather/fog.svg", - "assets/weather/drizzle.svg", - "assets/weather/rain.svg", - "assets/weather/sleet.svg", - "assets/weather/snow.svg", - "assets/weather/thunderstorm.svg", - "assets/weather/wind.svg" + '/', + '/index.html', + '/css/themes.css', + '/css/dashboard.css', + '/css/driver.min.css', + '/css/onboarding.css', + '/dist/core.js', + '/dist/features.js', + '/dist/init.js', + '/dist/onboarding.js', + '/assets/fonts.css', + '/assets/site.webmanifest', + '/assets/favicon.svg', + '/assets/dashcaddy-favicon.ico', + '/assets/icon-192.png', + '/assets/icon-512.png', + '/assets/apple-touch-icon.png', + '/assets/dashcaddy-logo-dark.png', + '/assets/dashcaddy-logo-light.png', + '/assets/sami7777-logo.png', + '/assets/fonts/sami-grotesk/SamiGrotesk-Regular.woff2', + '/assets/fonts/sami-grotesk/SamiGrotesk-Medium.woff2', + '/assets/fonts/sami-grotesk/SamiGrotesk-Bold.woff2', + '/assets/fonts/DSEG7Classic-Bold.woff2', + '/assets/weather/clear-day.svg', + '/assets/weather/clear-night.svg', + '/assets/weather/partly-cloudy-day.svg', + '/assets/weather/partly-cloudy-night.svg', + '/assets/weather/cloudy.svg', + '/assets/weather/fog.svg', + '/assets/weather/drizzle.svg', + '/assets/weather/rain.svg', + '/assets/weather/sleet.svg', + '/assets/weather/snow.svg', + '/assets/weather/thunderstorm.svg', + '/assets/weather/wind.svg' ]; -self.addEventListener("install", (e) => { +function isNavigationRequest(request) { + return request.mode === 'navigate'; +} + +function isStaticAsset(pathname) { + return pathname.startsWith('/assets/') + || pathname.startsWith('/css/') + || pathname.startsWith('/dist/'); +} + +async function networkFirst(request, preloadResponsePromise) { + const cache = await caches.open(CACHE); + try { + const preloadResponse = preloadResponsePromise ? await preloadResponsePromise : null; + if (preloadResponse) { + cache.put(request, preloadResponse.clone()).catch(() => {}); + return preloadResponse; + } + const response = await fetch(request); + cache.put(request, response.clone()).catch(() => {}); + return response; + } catch (_) { + return caches.match(request) || caches.match('/index.html'); + } +} + +async function staleWhileRevalidate(request) { + const cache = await caches.open(CACHE); + const cached = await cache.match(request); + + const networkPromise = fetch(request) + .then((response) => { + cache.put(request, response.clone()).catch(() => {}); + return response; + }) + .catch(() => null); + + if (cached) return cached; + return networkPromise.then((response) => response || Response.error()); +} + +self.addEventListener('install', (event) => { self.skipWaiting(); - e.waitUntil(caches.open(CACHE).then((c) => c.addAll(PRECACHE))); -}); - -self.addEventListener("activate", (e) => { - e.waitUntil( - caches.keys().then(keys => - Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))) - ).then(() => self.clients.claim()) + event.waitUntil( + caches.open(CACHE).then((cache) => + cache.addAll(PRECACHE.map((url) => new Request(url, { cache: 'reload' }))) + ) ); }); -self.addEventListener("fetch", (e) => { - const { request } = e; +self.addEventListener('activate', (event) => { + event.waitUntil((async () => { + const keys = await caches.keys(); + await Promise.all(keys.filter((key) => key !== CACHE).map((key) => caches.delete(key))); + if ('navigationPreload' in self.registration) { + await self.registration.navigationPreload.enable(); + } + await self.clients.claim(); + })()); +}); + +self.addEventListener('fetch', (event) => { + const { request } = event; + if (request.method !== 'GET') return; + const url = new URL(request.url); - if (url.origin !== location.origin) return; - // Never cache API or probe responses - always go to network - if (url.pathname.startsWith("/api/") || url.pathname.startsWith("/probe/")) return; - e.respondWith( - fetch(request).then(resp => { - caches.open(CACHE).then(cache => cache.put(request, resp.clone())).catch(()=>{}); - return resp.clone(); - }).catch(() => caches.match(request)) - ); + if (url.origin !== self.location.origin) return; + if (url.pathname.startsWith('/api/') || url.pathname.startsWith('/probe/')) return; + + if (isNavigationRequest(request) || url.pathname === '/' || url.pathname.endsWith('/index.html')) { + event.respondWith(networkFirst(request, event.preloadResponse)); + return; + } + + if (isStaticAsset(url.pathname)) { + event.respondWith(staleWhileRevalidate(request)); + } });