From f6ddb14ec1b02f74ac4f7f53077cd37a213ff2b6 Mon Sep 17 00:00:00 2001 From: Shadowtrance Date: Thu, 29 Jan 2026 06:15:36 +1000 Subject: [PATCH] Dashboard auto refresh toggle (#465) asset sync now works (AssetVersion)...could probably do with some improvements. disable auth username and password if not enabled. --- Data/data/webserver/dashboard.html | 218 +++++++++++++++++- Data/data/webserver/version.json | 2 +- .../webserversettings/WebServerSettings.cpp | 31 ++- .../Source/service/webserver/AssetVersion.cpp | 48 ++-- 4 files changed, 263 insertions(+), 36 deletions(-) diff --git a/Data/data/webserver/dashboard.html b/Data/data/webserver/dashboard.html index 54ec3b02..d964ae23 100644 --- a/Data/data/webserver/dashboard.html +++ b/Data/data/webserver/dashboard.html @@ -14,9 +14,14 @@ } .container { max-width: 1000px; margin: 0 auto; padding: 20px; } header { - text-align: center; + display: flex; + justify-content: space-between; + align-items: center; padding: 20px 0; } + .header-left { + text-align: left; + } header h1 { font-size: 1.8em; color: #fff; @@ -26,6 +31,95 @@ color: #7f8c8d; font-size: 0.9em; } + .header-controls { + display: flex; + align-items: center; + gap: 12px; + } + .refresh-btn { + background: rgba(255, 255, 255, 0.1); + border: none; + color: #fff; + width: 36px; + height: 36px; + border-radius: 8px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s; + font-size: 1.1em; + } + .refresh-btn:hover { + background: rgba(255, 255, 255, 0.15); + } + .refresh-btn:active { + transform: scale(0.95); + } + .refresh-btn.spinning svg { + animation: spin 0.8s linear infinite; + } + .auto-refresh-toggle { + display: flex; + align-items: center; + gap: 8px; + background: rgba(255, 255, 255, 0.05); + padding: 6px 12px; + border-radius: 8px; + font-size: 0.85em; + } + .auto-refresh-toggle label { + color: #95a5a6; + cursor: pointer; + user-select: none; + } + .toggle-switch { + position: relative; + width: 40px; + height: 22px; + } + .toggle-switch input { + opacity: 0; + position: absolute; + width: 100%; + height: 100%; + cursor: pointer; + z-index: 1; + } + .toggle-slider { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(255, 255, 255, 0.1); + border-radius: 22px; + transition: 0.3s; + } + .toggle-slider:before { + position: absolute; + content: ""; + height: 16px; + width: 16px; + left: 3px; + bottom: 3px; + background: #fff; + border-radius: 50%; + transition: 0.3s; + } + .toggle-switch input:checked + .toggle-slider { + background: #3498db; + } + .toggle-switch input:checked + .toggle-slider:before { + transform: translateX(18px); + } + .countdown { + color: #7f8c8d; + font-size: 0.8em; + min-width: 24px; + text-align: center; + } /* Tab Navigation */ .tabs { @@ -330,7 +424,10 @@ } @media (max-width: 600px) { + header { flex-direction: column; gap: 15px; } + .header-left { text-align: center; } header h1 { font-size: 1.4em; } + .header-controls { width: 100%; justify-content: center; } .grid { grid-template-columns: 1fr; } .tabs { flex-wrap: wrap; } .tab { padding: 10px 16px; font-size: 0.85em; } @@ -344,8 +441,25 @@
-

Tactility Dashboard

-

Loading...

+
+

Tactility Dashboard

+

Loading...

+
+
+
+ +
+ + +
+ 30s +
+ +
@@ -408,7 +522,7 @@
- Tactility WebServer - Auto-refreshes every 30 seconds + Tactility WebServer
@@ -420,6 +534,20 @@ function showTab(tabName) { document.querySelector(`.tab[onclick="showTab('${tabName}')"]`).classList.add('active'); document.getElementById(`${tabName}-tab`).classList.add('active'); + // Pause/resume auto-refresh based on active tab to avoid misleading countdown + if (tabName === 'dashboard') { + // Resume auto-refresh if toggle is on + if (autoRefreshToggle.checked && !refreshInterval) { + startAutoRefresh(); + } + } else { + // Stop auto-refresh when leaving dashboard (countdown would be misleading) + // Toggle state is preserved, so it will resume when returning to dashboard + if (refreshInterval) { + stopAutoRefresh(); + } + } + if (tabName === 'files' && !filesLoaded) { refreshFiles(); filesLoaded = true; @@ -438,7 +566,10 @@ function handleHashChange() { } } window.addEventListener('hashchange', handleHashChange); -if (window.location.hash) handleHashChange(); +if (window.location.hash) { + // Defer until auto-refresh elements are initialized to avoid ReferenceError + setTimeout(handleHashChange, 0); +} // Utility functions function formatBytes(bytes) { @@ -607,7 +738,6 @@ function renderDashboard(data) {

Quick Actions

- ${data.features_enabled?.screenshot ? '' : ''}
@@ -733,12 +863,80 @@ async function captureScreenshot(btn) { btn.textContent = 'Screenshot'; } let refreshInterval; +let countdownInterval; +let countdownValue = 30; +const REFRESH_INTERVAL_SECONDS = 30; + +const autoRefreshToggle = document.getElementById('autoRefreshToggle'); +const countdownEl = document.getElementById('countdown'); + +function updateCountdown() { + countdownValue--; + if (countdownValue <= 0) { + countdownValue = REFRESH_INTERVAL_SECONDS; + } + countdownEl.textContent = countdownValue + 's'; +} + +function startAutoRefresh() { + countdownValue = REFRESH_INTERVAL_SECONDS; + countdownEl.textContent = countdownValue + 's'; + countdownEl.style.display = ''; + + countdownInterval = setInterval(updateCountdown, 1000); + refreshInterval = setInterval(() => { + if (document.getElementById('dashboard-tab').classList.contains('active')) { + loadDashboard(); + countdownValue = REFRESH_INTERVAL_SECONDS; + } + }, REFRESH_INTERVAL_SECONDS * 1000); +} + +function stopAutoRefresh() { + if (refreshInterval) clearInterval(refreshInterval); + if (countdownInterval) clearInterval(countdownInterval); + refreshInterval = null; + countdownInterval = null; + countdownEl.style.display = 'none'; +} + +autoRefreshToggle.addEventListener('change', () => { + if (autoRefreshToggle.checked) { + // Only start if on dashboard tab to avoid misleading countdown + if (document.getElementById('dashboard-tab').classList.contains('active') && !refreshInterval) { + startAutoRefresh(); + } + } else { + stopAutoRefresh(); + } +}); + +async function manualRefresh() { + const btn = document.querySelector('.refresh-btn'); + btn.classList.add('spinning'); + + const activeTab = document.querySelector('.tab-content.active'); + if (activeTab.id === 'dashboard-tab') { + await loadDashboard(); + if (autoRefreshToggle.checked) { + countdownValue = REFRESH_INTERVAL_SECONDS; + } + } else if (activeTab.id === 'files-tab') { + await refreshFiles(); + } else if (activeTab.id === 'apps-tab') { + await loadApps(); + } + + btn.classList.remove('spinning'); +} + async function rebootDevice(btn) { if (!confirm('Reboot device now?')) return; const status = document.getElementById('actionStatus'); btn.disabled = true; btn.textContent = 'Rebooting...'; - if (refreshInterval) clearInterval(refreshInterval); + stopAutoRefresh(); + autoRefreshToggle.checked = false; try { await fetch('/admin/reboot', { method: 'POST' }); } catch (e) { } @@ -1137,11 +1335,7 @@ async function installAppFile(file) { // Initial load loadDashboard(); -refreshInterval = setInterval(() => { - if (document.getElementById('dashboard-tab').classList.contains('active')) { - loadDashboard(); - } -}, 30000); +startAutoRefresh(); diff --git a/Data/data/webserver/version.json b/Data/data/webserver/version.json index 5dfe44db..61a2092b 100644 --- a/Data/data/webserver/version.json +++ b/Data/data/webserver/version.json @@ -1,3 +1,3 @@ { - "version": 0 + "version": 1 } diff --git a/Tactility/Source/app/webserversettings/WebServerSettings.cpp b/Tactility/Source/app/webserversettings/WebServerSettings.cpp index e4ac9309..aaf36e30 100644 --- a/Tactility/Source/app/webserversettings/WebServerSettings.cpp +++ b/Tactility/Source/app/webserversettings/WebServerSettings.cpp @@ -8,6 +8,7 @@ #include #include #include +#include #include #include @@ -46,7 +47,10 @@ class WebServerSettingsApp final : public App { app->wsSettings.wifiMode = static_cast(index); app->updated = true; app->wifiSettingsChanged = true; - app->updateUrlDisplay(); + if (lvgl::lock(100)) { + app->updateUrlDisplay(); + lvgl::unlock(); + } }); } @@ -57,7 +61,10 @@ class WebServerSettingsApp final : public App { app->wsSettings.webServerEnabled = enabled; app->updated = true; app->webServerEnabledChanged = true; - app->updateUrlDisplay(); + if (lvgl::lock(100)) { + app->updateUrlDisplay(); + lvgl::unlock(); + } }); } @@ -128,8 +135,8 @@ class WebServerSettingsApp final : public App { auto* btn = static_cast(lv_event_get_target_obj(e)); lv_obj_add_state(btn, LV_STATE_DISABLED); LOGGER.info("Manual asset sync triggered"); - - getMainDispatcher().dispatch([app, btn]{ + + getMainDispatcher().dispatch([app, btn]{ bool success = service::webserver::syncAssets(); if (success) { LOGGER.info("Asset sync completed successfully"); @@ -137,8 +144,12 @@ class WebServerSettingsApp final : public App { LOGGER.error("Asset sync failed"); } // Only re-enable if button still exists (user hasn't navigated away) - if (lv_obj_is_valid(btn)) { - lv_obj_remove_state(btn, LV_STATE_DISABLED); + // Must acquire LVGL lock since we're not in an LVGL event callback context + if (lvgl::lock(1000)) { + if (lv_obj_is_valid(btn)) { + lv_obj_remove_state(btn, LV_STATE_DISABLED); + } + lvgl::unlock(); } }); } @@ -277,6 +288,10 @@ public: lv_label_set_text(ws_user_label, "Username"); lv_obj_align(ws_user_label, LV_ALIGN_LEFT_MID, 0, 0); textAreaWebServerUsername = lv_textarea_create(ws_user_wrapper); + if (!wsSettings.webServerAuthEnabled) { + lv_obj_add_state(textAreaWebServerUsername, LV_STATE_DISABLED); + lv_obj_remove_flag(textAreaWebServerUsername, LV_OBJ_FLAG_CLICKABLE); + } lv_obj_set_width(textAreaWebServerUsername, 120); lv_obj_align(textAreaWebServerUsername, LV_ALIGN_RIGHT_MID, 0, 0); lv_textarea_set_one_line(textAreaWebServerUsername, true); @@ -293,6 +308,10 @@ public: lv_label_set_text(ws_pass_label, "Password"); lv_obj_align(ws_pass_label, LV_ALIGN_LEFT_MID, 0, 0); textAreaWebServerPassword = lv_textarea_create(ws_pass_wrapper); + if (!wsSettings.webServerAuthEnabled) { + lv_obj_add_state(textAreaWebServerPassword, LV_STATE_DISABLED); + lv_obj_remove_flag(textAreaWebServerPassword, LV_OBJ_FLAG_CLICKABLE); + } lv_obj_set_width(textAreaWebServerPassword, 120); lv_obj_align(textAreaWebServerPassword, LV_ALIGN_RIGHT_MID, 0, 0); lv_textarea_set_one_line(textAreaWebServerPassword, true); diff --git a/Tactility/Source/service/webserver/AssetVersion.cpp b/Tactility/Source/service/webserver/AssetVersion.cpp index a9f39719..e06d41e7 100644 --- a/Tactility/Source/service/webserver/AssetVersion.cpp +++ b/Tactility/Source/service/webserver/AssetVersion.cpp @@ -9,6 +9,7 @@ #include #include #include +#include #include namespace tt::service::webserver { @@ -202,24 +203,37 @@ static bool copyDirectory(const char* src, const char* dst, int depth = 0) { std::string srcPath = file::getChildPath(src, entry.d_name); std::string dstPath = file::getChildPath(dst, entry.d_name); - - if (entry.d_type == file::TT_DT_DIR) { + + // Determine entry type - use stat() directly for unknown/unexpected d_type values + // (FAT/SD card filesystems often return non-standard d_type values) + // Note: We use stat() directly here instead of file::isDirectory/isFile to avoid + // deadlock, since listDirectory already holds a lock on the parent directory. + bool isDir = (entry.d_type == file::TT_DT_DIR); + bool isReg = (entry.d_type == file::TT_DT_REG); + if (!isDir && !isReg) { + struct stat st; + if (stat(srcPath.c_str(), &st) == 0) { + isDir = S_ISDIR(st.st_mode); + isReg = S_ISREG(st.st_mode); + } else { + LOGGER.warn("Failed to stat entry, skipping: {}", srcPath); + return; + } + } + + if (isDir) { // Recursively copy subdirectory if (!copyDirectory(srcPath.c_str(), dstPath.c_str(), depth + 1)) { copySuccess = false; } - } else if (entry.d_type == file::TT_DT_REG) { - // Copy file using atomic temp file approach - auto lock = file::getLock(srcPath); - lock->lock(portMAX_DELAY); - - // Generate unique temp file path + } else if (isReg) { + // Copy file - no additional locking needed since listDirectory already holds a lock + // and we're the only accessor during sync std::string tempPath = std::format("{}.tmp.{}", dstPath, esp_random()); FILE* srcFile = fopen(srcPath.c_str(), "rb"); if (!srcFile) { LOGGER.error("Failed to open source file: {}", srcPath); - lock->unlock(); copySuccess = false; return; } @@ -228,17 +242,17 @@ static bool copyDirectory(const char* src, const char* dst, int depth = 0) { if (!tempFile) { LOGGER.error("Failed to create temp file: {}", tempPath); fclose(srcFile); - lock->unlock(); copySuccess = false; return; } - // Copy in chunks - char buffer[512]; + // Copy in chunks (heap-allocated buffer to avoid stack overflow) + constexpr size_t COPY_BUF_SIZE = 4096; + auto buffer = std::make_unique(COPY_BUF_SIZE); size_t bytesRead; bool fileCopySuccess = true; - while ((bytesRead = fread(buffer, 1, sizeof(buffer), srcFile)) > 0) { - size_t bytesWritten = fwrite(buffer, 1, bytesRead, tempFile); + while ((bytesRead = fread(buffer.get(), 1, COPY_BUF_SIZE, srcFile)) > 0) { + size_t bytesWritten = fwrite(buffer.get(), 1, bytesRead, tempFile); if (bytesWritten != bytesRead) { LOGGER.error("Failed to write to temp file: {}", tempPath); fileCopySuccess = false; @@ -274,7 +288,9 @@ static bool copyDirectory(const char* src, const char* dst, int depth = 0) { fclose(tempFile); if (fileCopySuccess) { - // Atomically rename temp file to destination + // Remove destination if it exists (rename may not overwrite on some filesystems) + remove(dstPath.c_str()); + // Rename temp file to destination if (rename(tempPath.c_str(), dstPath.c_str()) != 0) { LOGGER.error("Failed to rename temp file {} to {}", tempPath, dstPath); remove(tempPath.c_str()); @@ -286,8 +302,6 @@ static bool copyDirectory(const char* src, const char* dst, int depth = 0) { remove(tempPath.c_str()); } - lock->unlock(); - if (fileCopySuccess) { LOGGER.info("Copied file: {}", entry.d_name); }