mirror of
https://github.com/ByteWelder/Tactility.git
synced 2026-02-18 10:53:17 +00:00
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:
parent
d551e467b8
commit
f6ddb14ec1
@ -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>
|
||||
<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>
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
{
|
||||
"version": 0
|
||||
"version": 1
|
||||
}
|
||||
|
||||
@ -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;
|
||||
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;
|
||||
if (lvgl::lock(100)) {
|
||||
app->updateUrlDisplay();
|
||||
lvgl::unlock();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -137,9 +144,13 @@ class WebServerSettingsApp final : public App {
|
||||
LOGGER.error("Asset sync failed");
|
||||
}
|
||||
// Only re-enable if button still exists (user hasn't navigated away)
|
||||
// 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);
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user