Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b5aef58
fix(web): eliminate white screen on PWA startup
stritti Aug 17, 2026
f7948a6
fix(web): resolve cached response before chaining in service worker
stritti Aug 17, 2026
f735d47
fix(web): keep service worker alive during background revalidation
stritti Aug 17, 2026
cefd262
style(web): add missing final newline to service worker
stritti Aug 17, 2026
3ccc493
fix(web): restrict app-shell fallback to dashboard routes
stritti Aug 17, 2026
eb5e751
perf(web): lazy-load config and sensors on tab activation
stritti Aug 17, 2026
64b510b
perf(web): serve pre-compressed gzip web assets
stritti Aug 17, 2026
a68e161
fix(web): drop duplicate Content-Encoding header for gzip assets
stritti Aug 17, 2026
bc02bd7
style(web): fix shfmt formatting in gzip script
stritti Aug 17, 2026
f230280
style(web): apply clang-format and fix script final newline
stritti Aug 17, 2026
ddd989d
fix(web): invalidate stale gzip variant on plain asset upload
stritti Aug 17, 2026
bdcbb29
fix(web): disable config saves while config is loading
stritti Aug 17, 2026
bf9e2ad
fix(web): use c_str() for LittleFS exists/remove in upload handler
stritti Aug 17, 2026
fc21fc8
fix(web): keep config saves disabled across telemetry refreshes
stritti Aug 17, 2026
742cc5f
fix(web): retry config load on failure and keep saves disabled
stritti Aug 17, 2026
b79e63e
fix(web): disable config fields while config is loading
stritti Aug 17, 2026
ddc90e3
fix(web): allow sensor loading to retry after failures
stritti Aug 18, 2026
ad25b3f
fix(web): keep failed config loads disabled across telemetry polls
stritti Aug 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -134,3 +134,6 @@ test/native/relay_safety/build/
# Frontend (separate JS project) — ignore lockfiles and node_modules
test/frontend/
graphify-out/

# Pre-compressed web assets (generated by scripts/gzip-web-assets.sh)
data/web/*.gz
80 changes: 73 additions & 7 deletions data/web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,18 @@ function switchTab(tabName) {
// Close more menu if open
const moreMenu = document.getElementById('moreMenu');
if (moreMenu) moreMenu.style.display = 'none';

// Lazy-load tab data on first activation instead of at page load: the
// single-threaded device server would otherwise queue config/sensor
// requests behind the dashboard telemetry poll on every page open.
if (!configLoaded && ['pool', 'time', 'wifi', 'mqtt', 'system'].includes(tabName)) {
configLoaded = true;
loadConfig();
Comment thread
stritti marked this conversation as resolved.
Comment thread
stritti marked this conversation as resolved.
}
if (!sensorsLoaded && tabName === 'sensors') {
sensorsLoaded = true;
loadSensors();
Comment thread
stritti marked this conversation as resolved.
}
}

function toggleMoreMenu() {
Expand All @@ -38,6 +50,9 @@ console.log('[pool] app.js loaded, version=2026-06-05');

let isAuthenticated = false;
let hasAutoSwitchedToWifi = false; // one-shot guard so AP-mode redirect doesn't fight user navigation
let configLoaded = false; // lazy-load guard: /api/config fetched on first admin tab activation
let sensorsLoaded = false; // lazy-load guard: /api/sensors fetched on first Sensors tab activation
let configLoadsInFlight = 0; // >0 while /api/config is loading; config save buttons stay disabled

async function loadTelemetry() {
try {
Expand Down Expand Up @@ -301,6 +316,14 @@ function updateAuthUI() {
}
}

// Config fields and save buttons must stay disabled while /api/config is
// loading (or failed to load): the loops above re-enable every tab control
// on each telemetry poll and would otherwise undo setConfigFieldsDisabled()
// before the load finishes — or expose markup defaults after a failure.
if (isAuthenticated && (configLoadsInFlight > 0 || !configLoaded)) {
setConfigFieldsDisabled(true);
}

// System / WiFi / MQTT / Logs / Sensors tabs: fully hide when not authenticated. Never
// force-show here — that previously used `''` (empty string), which falls back
// to the CSS default `display:block`, making the tab visible again on every 2s
Expand Down Expand Up @@ -941,9 +964,34 @@ async function factoryReset() {

// ── Load Config ──

// Config save buttons stay disabled while /api/config is loading, so a save
// cannot submit markup defaults for fields not yet populated by the response.
// Config fields and save buttons stay disabled while /api/config is loading:
// a save must not submit markup defaults, and a late response must not
// overwrite edits entered before the load finished.
const CONFIG_FIELD_SELECTOR = '#tab-pool input, #tab-pool select, #tab-pool button, ' +
'#tab-time input, #tab-time select, #tab-time button, ' +
'#tab-wifi input, #tab-wifi select, #tab-wifi button, ' +
'#tab-mqtt input, #tab-mqtt select, #tab-mqtt button';
const CONFIG_SAVE_BUTTONS = ['btnSavePassword']; // system tab save is outside the field tabs

function setConfigFieldsDisabled(disabled) {
for (const el of document.querySelectorAll(CONFIG_FIELD_SELECTOR)) {
el.disabled = disabled;
}
CONFIG_SAVE_BUTTONS.forEach(id => {
const btn = document.getElementById(id);
if (btn) btn.disabled = disabled;
});
}

async function loadConfig() {
configLoadsInFlight++;
setConfigFieldsDisabled(true);
let ok = false;
try {
const res = await fetch('/api/config');
if (!res.ok) throw new Error('config request failed: ' + res.status);
const data = await res.json();

document.getElementById('wifiSsid').value = data.wifi.ssid;
Expand Down Expand Up @@ -976,8 +1024,17 @@ async function loadConfig() {
document.getElementById('poolThreshold').textContent = 'max ' + data.settings.temp_max_pool.toFixed(1) + '°C';
document.getElementById('solarThreshold').textContent = 'min ' + data.settings.temp_min_solar.toFixed(1) + '°C';
highlightMode(data.settings.op_mode);
ok = true;
} catch (e) {
// Silent
// Transient failure — keep the save buttons disabled and reset the
// lazy-load guard so the next tab activation retries, instead of
// leaving markup defaults editable without a loaded config.
configLoaded = false;
} finally {
configLoadsInFlight--;
if (configLoadsInFlight === 0 && ok) {
setConfigFieldsDisabled(false);
}
}
}

Expand Down Expand Up @@ -1104,6 +1161,7 @@ let loadedMapping = { solar: null, pool: null };
async function loadSensors() {
try {
const res = await fetch('/api/sensors');
if (!res.ok) throw new Error('sensors request failed: ' + res.status);
const data = await res.json();

const solarAddr = data.mapping.solar || null;
Expand Down Expand Up @@ -1139,9 +1197,17 @@ async function loadSensors() {
buildRadioGroup('poolRadioGroup', devices, solarAddr, poolAddr, 'pool');

updateSensorSaveBar();

// Only mark the guard loaded on success so a failed request retries on
// the next tab activation instead of being skipped forever.
sensorsLoaded = true;
} catch (e) {
// Transient failure — reset the guard for a retry on the next tab
// activation and offer an inline refresh control.
sensorsLoaded = false;
document.getElementById('sensorList').innerHTML =
'<div style="padding: 1rem; text-align: center; color: var(--danger); font-size: 0.85rem;">Failed to load sensors: ' + e.message + '</div>';
'<div style="padding: 1rem; text-align: center; color: var(--danger); font-size: 0.85rem;">Failed to load sensors: ' + e.message +
' <button class="btn" onclick="loadSensors()" style="font-size: 0.75rem; padding: 0.25rem 0.6rem; margin-left: 0.5rem;">🔄 Retry</button></div>';
}
}

Expand Down Expand Up @@ -1359,8 +1425,8 @@ updateAuthUI = function() {
setInterval(loadTelemetry, 2000);
setInterval(loadLogs, 2000);

window.onload = function() {
loadTelemetry();
loadConfig();
loadSensors();
};
// The script is deferred, so the DOM is fully parsed at this point. Start the
// telemetry loop immediately instead of waiting for window.onload (which waits
// for every resource, including the async stylesheet). Config and sensor data
// are lazy-loaded on first tab activation (see switchTab).
loadTelemetry();
32 changes: 24 additions & 8 deletions data/web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -19,8 +19,24 @@
<link rel="icon" type="image/svg+xml" href="/icon.svg">
<link rel="mask-icon" href="/icon.svg" color="#00e5ff">

<!-- Styles -->
<link rel="stylesheet" href="/style.css">
<!-- Critical first-paint styles: the page renders themed immediately instead
of a white screen while /style.css is fetched from the device. The full
theme loads asynchronously below and overrides these base rules. -->
<style>
body {
margin: 0;
background: linear-gradient(180deg, #06121e 0%, #0a1e2f 50%, #06121e 100%);
color: #e2f0f7;
font-family: 'Inter', system-ui, sans-serif;
min-height: 100vh;
}
</style>

<!-- Styles — loaded asynchronously so it never blocks first paint.
The media="print" trick lets the browser download without blocking;
onload switches it to all media. -->
<link rel="stylesheet" href="/style.css" media="print" onload="this.onload=null;this.media='all'">
<noscript><link rel="stylesheet" href="/style.css"></noscript>
</head>
<body>
<h1 style="margin-bottom: 0.1rem;">Pool Controller</h1>
Expand Down Expand Up @@ -245,7 +261,7 @@ <h2>WLAN Configuration</h2>
<label for="wifiPass">WLAN Password</label>
<input type="password" id="wifiPass" placeholder="Enter WiFi password">
</div>
<button class="btn btn-primary" onclick="saveWiFi()">Save WiFi settings</button>
<button class="btn btn-primary" onclick="saveWiFi()" id="btnSaveWiFi">Save WiFi settings</button>
</div>

<!-- MQTT Tab -->
Expand All @@ -267,7 +283,7 @@ <h2>MQTT Settings</h2>
<label for="mqttPass">MQTT Password</label>
<input type="password" id="mqttPass" placeholder="Optional">
</div>
<button class="btn btn-primary" onclick="saveMqtt()" style="margin-top: 1.5rem;">Save MQTT configuration</button>
<button class="btn btn-primary" onclick="saveMqtt()" id="btnSaveMqtt" style="margin-top: 1.5rem;">Save MQTT configuration</button>
</div>

<!-- Pool Settings Tab -->
Expand Down Expand Up @@ -410,7 +426,7 @@ <h2>⏱️ Timer Schedule</h2>
</div>
</div>

<button class="btn btn-primary" onclick="saveControllerSettings()">Save Pool Settings</button>
<button class="btn btn-primary" onclick="saveControllerSettings()" id="btnSavePool">Save Pool Settings</button>
</div>

<!-- Time Settings Tab -->
Expand Down Expand Up @@ -456,7 +472,7 @@ <h2>⚠️ Time Sync Degradation</h2>
</div>
</div>

<button class="btn btn-primary" onclick="saveTimeSettings()">Save Time Settings</button>
<button class="btn btn-primary" onclick="saveTimeSettings()" id="btnSaveTime">Save Time Settings</button>
</div>

<!-- Sensors Tab (DS18B20 Mapping) -->
Expand Down Expand Up @@ -517,7 +533,7 @@ <h2>Security Settings</h2>
<label for="adminPassConfirm">Confirm New Password</label>
<input type="password" id="adminPassConfirm" placeholder="Confirm new password">
</div>
<button class="btn btn-primary" onclick="savePassword()" style="margin-bottom: 2rem;">Update Security Password</button>
<button class="btn btn-primary" onclick="savePassword()" id="btnSavePassword" style="margin-bottom: 2rem;">Update Security Password</button>

<h2>Firmware Version</h2>
<div class="telemetry-grid" style="grid-template-columns: repeat(2, 1fr);">
Expand Down Expand Up @@ -614,7 +630,7 @@ <h2>ℹ️ About</h2>
<div id="logConsoleEmpty" style="display:none; text-align:center; padding:2rem; color:var(--text-muted); font-size:0.85rem; opacity:0.5;">— No log entries yet —</div>
</div>

<script src="/app.js"></script>
<script src="/app.js" defer></script>

<!-- Service Worker Registration -->
<script>
Expand Down
84 changes: 56 additions & 28 deletions data/web/sw.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
// Pool Controller — Service Worker v1
// Cache name includes date for easy version bumps
const CACHE = 'pool-ctrl-v1';
// Pool Controller — Service Worker v2
// Strategy: stale-while-revalidate for static assets.
// - First visit: fetch from network, populate cache.
// - Repeat visits: serve instantly from cache, refresh in background.
// - API calls: always network (no stale data).
// Bump CACHE when deploying changed assets to force a clean slate.
const CACHE = 'pool-ctrl-v2';
const STATIC_ASSETS = [
'/',
'/style.css',
Expand All @@ -9,6 +13,8 @@ const STATIC_ASSETS = [
'/icon.svg'
];

const OFFLINE_HTML = '<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Pool Controller — Offline</title><style>body{background:#06121e;color:#8aadc4;font-family:system-ui,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center;line-height:1.6}h1{color:#00e5ff}p{color:#8aadc4}</style></head><body><h1>Pool Controller</h1><p>⚠️ Device is currently offline.<br>The dashboard will resume automatically when the connection is restored.</p></body></html>';

// ── Install: pre-cache critical files ──
self.addEventListener('install', (event) => {
event.waitUntil(
Expand All @@ -31,7 +37,7 @@ self.addEventListener('activate', (event) => {
);
});

// ── Fetch: network-first with cache fallback ──
// ── Fetch: stale-while-revalidate ──
self.addEventListener('fetch', (event) => {
// Only handle GET requests
if (event.request.method !== 'GET') return;
Expand All @@ -47,30 +53,52 @@ self.addEventListener('fetch', (event) => {
// Skip non-HTTP(S) schemes (e.g., chrome-extension://)
if (!url.protocol.startsWith('http')) return;

// Network-first strategy: try network, fall back to cache
event.respondWith(
fetch(event.request)
.then((response) => {
// Cache successful responses for future offline use
if (response && response.status === 200) {
const clone = response.clone();
caches.open(CACHE).then((cache) => cache.put(event.request, clone));
}
return response;
})
.catch(() => {
// Offline — serve from cache
return caches.match(event.request).then((cached) => {
if (cached) return cached;
// If not in cache and offline, return a minimal offline page
if (event.request.headers.get('Accept')?.includes('text/html')) {
return new Response(
'<!DOCTYPE html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Pool Controller — Offline</title><style>body{background:#06121e;color:#8aadc4;font-family:system-ui,sans-serif;display:flex;flex-direction:column;align-items:center;justify-content:center;min-height:100vh;padding:2rem;text-align:center;line-height:1.6}h1{color:#00e5ff}p{color:#8aadc4}</style></head><body><h1>Pool Controller</h1><p>⚠️ Device is currently offline.<br>The dashboard will resume automatically when the connection is restored.</p></body></html>',
{ headers: { 'Content-Type': 'text/html; charset=utf-8' } }
);
}
return new Response('Offline', { status: 503 });
});
})
caches.match(event.request).then((cached) => {
// Navigation fallback: serve the cached app shell for dashboard routes
// (/, /index.html) that are not cached under their exact URL. Other
// server-rendered routes (e.g. /login) must not be replaced by the
// dashboard shell — they fall through to the network fetch below.
// Promise.resolve() normalizes the cache hit (a Response) and the
// fallback lookup (a Promise) into one chainable promise.
const isDashboardRoute = url.pathname === '/' || url.pathname === '/index.html';
const cacheHit = cached || (
isDashboardRoute && event.request.headers.get('Accept')?.includes('text/html')
? caches.match('/')
Comment thread
stritti marked this conversation as resolved.
: undefined
);

return Promise.resolve(cacheHit).then((hit) => {
// Background refresh: fetch from network bypassing the HTTP cache
// (so freshly uploaded assets are picked up), then update the cache.
// The cache write is awaited so the promise below only settles once
// the refresh is fully persisted.
const networkFetch = fetch(event.request, { cache: 'no-store' })
Comment thread
stritti marked this conversation as resolved.
.then((response) => {
if (response && response.status === 200) {
const clone = response.clone();
return caches.open(CACHE)
.then((cache) => cache.put(event.request, clone))
.then(() => response);
}
return response;
})
.catch(() => {
// Offline — fall back to cache, then to a minimal offline page.
if (hit) return hit;
if (event.request.headers.get('Accept')?.includes('text/html')) {
return new Response(OFFLINE_HTML, { headers: { 'Content-Type': 'text/html; charset=utf-8' } });
}
return new Response('Offline', { status: 503 });
});

// Keep the worker alive until the background refresh completes so
// freshly uploaded assets are not left stale across reloads.
event.waitUntil(networkFetch);

// Serve the cached copy immediately when available.
return hit || networkFetch;
});
})
);
});
25 changes: 25 additions & 0 deletions scripts/gzip-web-assets.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#!/usr/bin/env bash
# Pre-compress web assets for LittleFS upload.
#
# The firmware serves the .gz variants with Content-Encoding: gzip when they
# are present (see WebPortal::serveWebFile), cutting the first-visit transfer
# from ~104 KB to ~35 KB. Run this before deploying web assets:
#
# scripts/gzip-web-assets.sh
# pio run --target uploadfs # serial
# # or the OTA /api/fs/upload flow # network
#
# Deployments without this step keep working — the firmware falls back to the
# uncompressed files. Generated .gz files are gitignored.
set -euo pipefail

cd "$(dirname "$0")/../data/web"

for f in index.html style.css app.js sw.js manifest.json icon.svg; do
if [ ! -f "$f" ]; then
echo "skip $f (missing)"
continue
fi
gzip -n -9 -c "$f" >"$f.gz"
echo "gzipped $f ($(wc -c <"$f") -> $(wc -c <"$f.gz") bytes)"
done
Loading
Loading