Dashboard auto refresh toggle (#465)

asset sync now works (AssetVersion)...could probably do with some improvements.
disable auth username and password if not enabled.
This commit is contained in:
Shadowtrance 2026-01-29 06:15:36 +10:00 committed by GitHub
parent d551e467b8
commit f6ddb14ec1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 263 additions and 36 deletions

View File

@ -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 @@
<body>
<div class="container">
<header>
<h1>Tactility Dashboard</h1>
<p class="subtitle" id="version">Loading...</p>
<div class="header-left">
<h1>Tactility Dashboard</h1>
<p class="subtitle" id="version">Loading...</p>
</div>
<div class="header-controls">
<div class="auto-refresh-toggle">
<label for="autoRefreshToggle">Auto</label>
<div class="toggle-switch">
<input type="checkbox" id="autoRefreshToggle" checked>
<span class="toggle-slider"></span>
</div>
<span class="countdown" id="countdown">30s</span>
</div>
<button class="refresh-btn" onclick="manualRefresh()" title="Refresh now">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 2v6h-6M3 12a9 9 0 0 1 15-6.7L21 8M3 22v-6h6M21 12a9 9 0 0 1-15 6.7L3 16"/>
</svg>
</button>
</div>
</header>
<div class="tabs">
@ -408,7 +522,7 @@
</div>
<footer>
Tactility WebServer - Auto-refreshes every 30 seconds
Tactility WebServer
</footer>
</div>
@ -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) {
<h2>Quick Actions</h2>
<div class="actions">
<button class="btn btn-primary" onclick="syncAssets(this)">Sync Assets</button>
<button class="btn btn-secondary" onclick="location.reload()">Refresh</button>
${data.features_enabled?.screenshot ? '<button class="btn btn-secondary" onclick="captureScreenshot(this)">Screenshot</button>' : ''}
<button class="btn btn-danger" onclick="rebootDevice(this)">Reboot</button>
</div>
@ -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();
</script>
</body>
</html>

View File

@ -1,3 +1,3 @@
{
"version": 0
"version": 1
}

View File

@ -8,6 +8,7 @@
#include <Tactility/service/webserver/WebServerService.h>
#include <Tactility/Assets.h>
#include <Tactility/lvgl/Toolbar.h>
#include <Tactility/lvgl/LvglSync.h>
#include <Tactility/Logger.h>
#include <lvgl.h>
@ -46,7 +47,10 @@ class WebServerSettingsApp final : public App {
app->wsSettings.wifiMode = static_cast<settings::webserver::WiFiMode>(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();
}
});
}
@ -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);

View File

@ -9,6 +9,7 @@
#include <cstdio>
#include <cstring>
#include <format>
#include <memory>
#include <esp_random.h>
namespace tt::service::webserver {
@ -203,23 +204,36 @@ 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<char[]>(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);
}