Tactility/Data/data/webserver/dashboard.html
Shadowtrance f6ddb14ec1
Dashboard auto refresh toggle (#465)
asset sync now works (AssetVersion)...could probably do with some improvements.
disable auth username and password if not enabled.
2026-01-28 21:15:36 +01:00

1342 lines
48 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Tactility Dashboard</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%);
min-height: 100vh;
color: #e0e0e0;
}
.container { max-width: 1000px; margin: 0 auto; padding: 20px; }
header {
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;
margin-bottom: 5px;
}
header .subtitle {
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 {
display: flex;
justify-content: center;
gap: 5px;
margin-bottom: 25px;
background: rgba(255, 255, 255, 0.05);
padding: 8px;
border-radius: 10px;
}
.tab {
padding: 12px 24px;
border: none;
background: transparent;
color: #95a5a6;
cursor: pointer;
border-radius: 8px;
font-size: 0.95em;
font-weight: 500;
transition: all 0.3s;
}
.tab:hover {
color: #fff;
background: rgba(255, 255, 255, 0.05);
}
.tab.active {
background: #3498db;
color: #fff;
}
.tab-content {
display: none;
}
.tab-content.active {
display: block;
}
/* Cards */
.grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
gap: 20px;
}
.card {
background: rgba(255, 255, 255, 0.05);
border-radius: 12px;
padding: 20px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.card h2 {
font-size: 0.85em;
text-transform: uppercase;
letter-spacing: 1px;
color: #7f8c8d;
margin-bottom: 15px;
display: flex;
align-items: center;
gap: 8px;
}
.card h2::before {
content: '';
width: 4px;
height: 16px;
background: #3498db;
border-radius: 2px;
}
.stat {
display: flex;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.stat:last-child { border-bottom: none; }
.stat-label { color: #95a5a6; font-size: 0.9em; }
.stat-value { color: #fff; font-weight: 500; }
.stat-value.good { color: #2ecc71; }
.stat-value.warn { color: #f39c12; }
.stat-value.bad { color: #e74c3c; }
.progress-bar {
height: 8px;
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.progress-fill {
height: 100%;
border-radius: 4px;
transition: width 0.5s ease;
}
.progress-fill.good { background: linear-gradient(90deg, #27ae60, #2ecc71); }
.progress-fill.warn { background: linear-gradient(90deg, #f39c12, #f1c40f); }
.progress-fill.bad { background: linear-gradient(90deg, #c0392b, #e74c3c); }
/* Feature badges */
.features {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 5px;
}
.feature-badge {
background: rgba(52, 152, 219, 0.2);
color: #3498db;
padding: 4px 10px;
border-radius: 12px;
font-size: 0.75em;
font-weight: 500;
}
/* Buttons */
.actions {
display: flex;
gap: 10px;
margin-top: 15px;
flex-wrap: wrap;
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 0.9em;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.btn-primary { background: #3498db; color: #fff; }
.btn-primary:hover { background: #2980b9; }
.btn-success { background: #27ae60; color: #fff; }
.btn-success:hover { background: #1e8449; }
.btn-danger { background: #e74c3c; color: #fff; }
.btn-danger:hover { background: #c0392b; }
.btn-secondary { background: rgba(255, 255, 255, 0.1); color: #fff; }
.btn-secondary:hover { background: rgba(255, 255, 255, 0.15); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.btn-sm { padding: 6px 12px; font-size: 0.8em; }
/* Loading & Error */
.loading {
text-align: center;
padding: 40px;
color: #7f8c8d;
}
.loading::after {
content: '';
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #3498db;
border-top-color: transparent;
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-left: 10px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.error {
background: rgba(231, 76, 60, 0.2);
color: #e74c3c;
padding: 15px;
border-radius: 8px;
text-align: center;
}
/* File Browser Styles */
.breadcrumb {
background: rgba(255, 255, 255, 0.05);
padding: 12px 15px;
border-radius: 8px;
margin-bottom: 15px;
}
.breadcrumb a { color: #3498db; text-decoration: none; }
.breadcrumb a:hover { text-decoration: underline; }
.breadcrumb span { color: #7f8c8d; margin: 0 5px; }
.toolbar {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 15px;
background: rgba(255, 255, 255, 0.05);
padding: 12px;
border-radius: 8px;
align-items: center;
}
.toolbar-group { display: flex; gap: 5px; }
.toolbar-sep { width: 1px; height: 24px; background: rgba(255, 255, 255, 0.1); margin: 0 8px; }
#pathInput {
flex: 1;
min-width: 200px;
padding: 8px 12px;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
background: rgba(255, 255, 255, 0.05);
color: #fff;
font-size: 0.9em;
}
#pathInput:focus { outline: none; border-color: #3498db; }
input[type=file] { display: none; }
.file-table {
width: 100%;
border-collapse: collapse;
background: rgba(255, 255, 255, 0.03);
border-radius: 8px;
overflow: hidden;
}
.file-table th {
background: rgba(255, 255, 255, 0.05);
padding: 12px 15px;
text-align: left;
font-weight: 600;
color: #95a5a6;
font-size: 0.8em;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.file-table td { padding: 10px 15px; border-bottom: 1px solid rgba(255, 255, 255, 0.05); }
.file-table tr:last-child td { border-bottom: none; }
.file-table tr:hover { background: rgba(255, 255, 255, 0.03); }
.file-table tr.selected { background: rgba(52, 152, 219, 0.2); }
.file-name { cursor: pointer; display: flex; align-items: center; gap: 8px; }
.file-name:hover { color: #3498db; }
.file-icon { font-size: 1.1em; }
.file-size { color: #7f8c8d; font-size: 0.85em; }
.file-type { color: #95a5a6; font-size: 0.75em; text-transform: uppercase; }
.dl-link { color: #3498db; text-decoration: none; font-size: 0.85em; }
.dl-link:hover { text-decoration: underline; }
.empty { text-align: center; padding: 40px; color: #7f8c8d; }
/* Apps Tab Styles */
.upload-zone {
border: 2px dashed rgba(255, 255, 255, 0.2);
border-radius: 12px;
padding: 40px;
text-align: center;
margin-bottom: 20px;
transition: all 0.3s;
}
.upload-zone.dragover {
border-color: #3498db;
background: rgba(52, 152, 219, 0.1);
}
.upload-zone p { color: #7f8c8d; margin-bottom: 15px; }
.app-list { display: flex; flex-direction: column; gap: 10px; }
.app-item {
display: flex;
align-items: center;
gap: 15px;
padding: 15px;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.app-icon {
width: 40px;
height: 40px;
background: rgba(52, 152, 219, 0.2);
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-size: 1.2em;
}
.app-info { flex: 1; }
.app-name { font-weight: 600; color: #fff; }
.app-meta { font-size: 0.8em; color: #7f8c8d; margin-top: 3px; }
.app-actions { display: flex; gap: 8px; }
/* Screenshot preview */
.screenshot-container {
text-align: center;
margin-top: 15px;
}
.screenshot-img {
max-width: 100%;
border-radius: 8px;
border: 1px solid rgba(255, 255, 255, 0.1);
}
/* Status message */
.status {
padding: 12px 15px;
border-radius: 8px;
margin-bottom: 15px;
display: none;
}
.status.show { display: block; }
.status.error { background: rgba(231, 76, 60, 0.2); color: #e74c3c; }
.status.success { background: rgba(39, 174, 96, 0.2); color: #2ecc71; }
.status.info { background: rgba(52, 152, 219, 0.2); color: #3498db; }
footer {
text-align: center;
padding: 20px 0;
color: #555;
font-size: 0.8em;
}
@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; }
.toolbar { flex-direction: column; }
.toolbar-group { width: 100%; }
.toolbar-sep { display: none; }
#pathInput { width: 100%; }
}
</style>
</head>
<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">
<button class="tab active" onclick="showTab('dashboard')">Dashboard</button>
<button class="tab" onclick="showTab('files')">Files</button>
<button class="tab" onclick="showTab('apps')">Apps</button>
</div>
<!-- Dashboard Tab -->
<div id="dashboard-tab" class="tab-content active">
<div id="dashboard-content">
<div class="loading">Loading system information...</div>
</div>
</div>
<!-- Files Tab -->
<div id="files-tab" class="tab-content">
<div id="files-status" class="status"></div>
<div class="breadcrumb" id="breadcrumb"></div>
<div class="toolbar">
<div class="toolbar-group">
<button class="btn btn-secondary btn-sm" onclick="goHome()">Home</button>
<button class="btn btn-secondary btn-sm" onclick="goUp()" id="upBtn">Up</button>
</div>
<div class="toolbar-sep"></div>
<input type="text" id="pathInput" value="/data" onkeydown="if(event.key==='Enter')refreshFiles()">
<button class="btn btn-primary btn-sm" onclick="refreshFiles()">Go</button>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
<button class="btn btn-secondary btn-sm" onclick="createFolder()">New Folder</button>
<button class="btn btn-secondary btn-sm" onclick="triggerUpload()">Upload</button>
<input type="file" id="uploadInput" multiple />
</div>
<div class="toolbar-sep"></div>
<div class="toolbar-group">
<button class="btn btn-secondary btn-sm" onclick="renameSelected()" id="renameBtn" disabled>Rename</button>
<button class="btn btn-danger btn-sm" onclick="deleteSelected()" id="deleteBtn" disabled>Delete</button>
</div>
</div>
<table class="file-table">
<thead><tr><th>Name</th><th>Type</th><th>Size</th><th>Action</th></tr></thead>
<tbody id="file-list"><tr><td colspan="4" class="empty">Select the Files tab to browse</td></tr></tbody>
</table>
</div>
<!-- Apps Tab -->
<div id="apps-tab" class="tab-content">
<div id="apps-status" class="status"></div>
<div class="upload-zone" id="uploadZone">
<p>Drag & drop an .app file here, or click to select</p>
<button class="btn btn-primary" onclick="document.getElementById('appFileInput').click()">Select App File</button>
<input type="file" id="appFileInput" accept=".app" />
</div>
<div class="card">
<h2>Installed Apps</h2>
<div id="app-list" class="app-list">
<div class="loading">Loading apps...</div>
</div>
</div>
</div>
<footer>
Tactility WebServer
</footer>
</div>
<script>
// Tab management
function showTab(tabName) {
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
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;
}
if (tabName === 'apps' && !appsLoaded) {
loadApps();
appsLoaded = true;
}
}
// Handle URL hash for direct tab access
function handleHashChange() {
const hash = window.location.hash.slice(1);
if (hash === 'files' || hash === 'apps' || hash === 'dashboard') {
showTab(hash);
}
}
window.addEventListener('hashchange', handleHashChange);
if (window.location.hash) {
// Defer until auto-refresh elements are initialized to avoid ReferenceError
setTimeout(handleHashChange, 0);
}
// Utility functions
function formatBytes(bytes) {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), sizes.length - 1);
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
function formatUptime(seconds) {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (days > 0) return `${days}d ${hours}h ${mins}m`;
if (hours > 0) return `${hours}h ${mins}m ${secs}s`;
if (mins > 0) return `${mins}m ${secs}s`;
return `${secs}s`;
}
function getUsageClass(percent) {
if (percent < 60) return 'good';
if (percent < 85) return 'warn';
return 'bad';
}
function escapeHtml(str) {
const div = document.createElement('div');
div.textContent = str;
return div.innerHTML;
}
// ==================== DASHBOARD ====================
const dashboardContent = document.getElementById('dashboard-content');
const versionEl = document.getElementById('version');
function renderDashboard(data) {
const heapUsed = data.heap.total - data.heap.free;
const heapPercent = data.heap.total > 0 ? (heapUsed / data.heap.total) * 100 : 0;
const psramUsed = data.psram.total - data.psram.free;
const psramPercent = data.psram.total > 0 ? (psramUsed / data.psram.total) * 100 : 0;
const hasPsram = data.psram.total > 0;
const dataUsed = data.storage.data.mounted ? (data.storage.data.total - data.storage.data.free) : 0;
const dataPercent = data.storage.data.mounted && data.storage.data.total > 0 ? (dataUsed / data.storage.data.total) * 100 : 0;
const sdUsed = data.storage.sdcard.mounted ? (data.storage.sdcard.total - data.storage.sdcard.free) : 0;
const sdPercent = data.storage.sdcard.mounted && data.storage.sdcard.total > 0 ? (sdUsed / data.storage.sdcard.total) * 100 : 0;
versionEl.textContent = `Tactility v${data.firmware.version} | ESP-IDF v${data.firmware.idf_version}`;
// Build features HTML
const featuresHtml = data.chip.features && data.chip.features.length > 0
? `<div class="features">${data.chip.features.map(f => `<span class="feature-badge">${escapeHtml(f)}</span>`).join('')}</div>`
: '';
dashboardContent.innerHTML = `
<div class="grid">
<div class="card">
<h2>System Overview</h2>
<div class="stat">
<span class="stat-label">Chip</span>
<span class="stat-value">${escapeHtml(data.chip.model)}</span>
</div>
<div class="stat">
<span class="stat-label">CPU Cores</span>
<span class="stat-value">${data.chip.cores}</span>
</div>
<div class="stat">
<span class="stat-label">Chip Revision</span>
<span class="stat-value">${data.chip.revision}</span>
</div>
<div class="stat">
<span class="stat-label">Flash Size</span>
<span class="stat-value">${formatBytes(data.chip.flash_size || 0)}</span>
</div>
<div class="stat">
<span class="stat-label">Active Tasks</span>
<span class="stat-value">${data.task_count}</span>
</div>
<div class="stat">
<span class="stat-label">Uptime</span>
<span class="stat-value">${formatUptime(data.uptime)}</span>
</div>
${featuresHtml ? `<div class="stat" style="flex-direction: column; align-items: flex-start;">
<span class="stat-label" style="margin-bottom: 8px;">Features</span>
${featuresHtml}
</div>` : ''}
</div>
<div class="card">
<h2>Internal Memory (Heap)</h2>
<div class="stat">
<span class="stat-label">Used</span>
<span class="stat-value ${getUsageClass(heapPercent)}">${formatBytes(heapUsed)} / ${formatBytes(data.heap.total)}</span>
</div>
<div class="progress-bar">
<div class="progress-fill ${getUsageClass(heapPercent)}" style="width: ${heapPercent}%"></div>
</div>
<div class="stat">
<span class="stat-label">Free</span>
<span class="stat-value">${formatBytes(data.heap.free)}</span>
</div>
<div class="stat">
<span class="stat-label">Min Free Ever</span>
<span class="stat-value">${formatBytes(data.heap.min_free)}</span>
</div>
<div class="stat">
<span class="stat-label">Largest Block</span>
<span class="stat-value">${formatBytes(data.heap.largest_block)}</span>
</div>
</div>
${hasPsram ? `
<div class="card">
<h2>External Memory (PSRAM)</h2>
<div class="stat">
<span class="stat-label">Used</span>
<span class="stat-value ${getUsageClass(psramPercent)}">${formatBytes(psramUsed)} / ${formatBytes(data.psram.total)}</span>
</div>
<div class="progress-bar">
<div class="progress-fill ${getUsageClass(psramPercent)}" style="width: ${psramPercent}%"></div>
</div>
<div class="stat">
<span class="stat-label">Free</span>
<span class="stat-value">${formatBytes(data.psram.free)}</span>
</div>
<div class="stat">
<span class="stat-label">Min Free Ever</span>
<span class="stat-value">${formatBytes(data.psram.min_free)}</span>
</div>
<div class="stat">
<span class="stat-label">Largest Block</span>
<span class="stat-value">${formatBytes(data.psram.largest_block)}</span>
</div>
</div>
` : ''}
<div class="card">
<h2>Storage</h2>
<div class="stat">
<span class="stat-label">Data Partition</span>
<span class="stat-value ${data.storage.data.mounted ? getUsageClass(dataPercent) : ''}">${
data.storage.data.mounted ? formatBytes(data.storage.data.free) + ' free' : 'Not mounted'
}</span>
</div>
${data.storage.data.mounted ? `<div class="progress-bar">
<div class="progress-fill ${getUsageClass(dataPercent)}" style="width: ${dataPercent}%"></div>
</div>` : ''}
<div class="stat" style="margin-top: 15px">
<span class="stat-label">SD Card</span>
<span class="stat-value ${data.storage.sdcard.mounted ? getUsageClass(sdPercent) : ''}">${
data.storage.sdcard.mounted ? formatBytes(data.storage.sdcard.free) + ' free' : 'Not mounted'
}</span>
</div>
${data.storage.sdcard.mounted ? `<div class="progress-bar">
<div class="progress-fill ${getUsageClass(sdPercent)}" style="width: ${sdPercent}%"></div>
</div>` : ''}
</div>
<div class="card" id="wifi-card">
<h2>WiFi Status</h2>
<div id="wifi-content"><div class="loading">Loading...</div></div>
</div>
<div class="card">
<h2>Quick Actions</h2>
<div class="actions">
<button class="btn btn-primary" onclick="syncAssets(this)">Sync Assets</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>
<div id="actionStatus" style="margin-top: 15px; font-size: 0.9em;"></div>
<div class="screenshot-container" id="screenshotContainer" style="display: none;">
<img id="screenshotImg" class="screenshot-img" alt="Screenshot">
</div>
</div>
</div>
`;
// Load WiFi status
loadWifiStatus();
}
async function loadDashboard() {
try {
const r = await fetch('/api/sysinfo');
if (!r.ok) throw new Error('Failed to fetch: ' + r.status);
const data = await r.json();
renderDashboard(data);
} catch (e) {
dashboardContent.innerHTML = `<div class="error">Failed to load system info: ${escapeHtml(e.message)}</div>`;
versionEl.textContent = 'Error loading data';
}
}
async function loadWifiStatus() {
const wifiContent = document.getElementById('wifi-content');
if (!wifiContent) return;
try {
const r = await fetch('/api/wifi');
if (!r.ok) throw new Error('Failed to fetch');
const data = await r.json();
const stateColors = {
'connected': 'good',
'connecting': 'warn',
'on': '',
'off': 'bad'
};
const capitalize = s => s ? s.charAt(0).toUpperCase() + s.slice(1) : '';
wifiContent.innerHTML = `
<div class="stat">
<span class="stat-label">Status</span>
<span class="stat-value ${stateColors[data.state] || ''}">${escapeHtml(capitalize(data.state))}</span>
</div>
${data.ssid ? `<div class="stat">
<span class="stat-label">Network</span>
<span class="stat-value">${escapeHtml(data.ssid)}</span>
</div>` : ''}
${data.ip ? `<div class="stat">
<span class="stat-label">IP Address</span>
<span class="stat-value">${escapeHtml(data.ip)}</span>
</div>` : ''}
${data.rssi && data.rssi < 0 ? `<div class="stat">
<span class="stat-label">Signal (RSSI)</span>
<span class="stat-value">${data.rssi} dBm</span>
</div>` : ''}
${data.secure !== undefined ? `<div class="stat">
<span class="stat-label">Security</span>
<span class="stat-value">${data.secure ? 'Encrypted' : 'Open'}</span>
</div>` : ''}
`;
} catch (e) {
wifiContent.innerHTML = `<div class="stat"><span class="stat-label">Status</span><span class="stat-value">Unknown</span></div>`;
}
}
async function syncAssets(btn) {
const status = document.getElementById('actionStatus');
btn.disabled = true;
btn.textContent = 'Syncing...';
try {
const r = await fetch('/admin/sync', { method: 'POST' });
if (status) {
status.textContent = r.ok ? 'Assets synchronized!' : 'Sync failed';
status.style.color = r.ok ? '#2ecc71' : '#e74c3c';
}
} catch (e) {
if (status) {
status.textContent = 'Error: ' + e.message;
status.style.color = '#e74c3c';
}
}
btn.disabled = false;
btn.textContent = 'Sync Assets';
}
async function captureScreenshot(btn) {
const status = document.getElementById('actionStatus');
const container = document.getElementById('screenshotContainer');
const img = document.getElementById('screenshotImg');
btn.disabled = true;
btn.textContent = 'Capturing...';
status.textContent = '';
try {
const r = await fetch('/api/screenshot');
if (!r.ok) {
const text = await r.text();
throw new Error(text || r.status);
}
const blob = await r.blob();
// Revoke previous object URL to prevent memory leak
if (img.src && img.src.startsWith('blob:')) {
URL.revokeObjectURL(img.src);
}
img.src = URL.createObjectURL(blob);
container.style.display = 'block';
status.textContent = 'Screenshot captured!';
status.style.color = '#2ecc71';
} catch (e) {
status.textContent = 'Screenshot failed: ' + e.message;
status.style.color = '#e74c3c';
container.style.display = 'none';
}
btn.disabled = false;
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...';
stopAutoRefresh();
autoRefreshToggle.checked = false;
try {
await fetch('/admin/reboot', { method: 'POST' });
} catch (e) { }
status.textContent = 'Device is rebooting...';
status.style.color = '#3498db';
}
// ==================== FILE BROWSER ====================
let currentPath = '/';
let selectedName = '';
let selectedType = '';
let filesLoaded = false;
const fileListEl = document.getElementById('file-list');
const pathInput = document.getElementById('pathInput');
const breadcrumbEl = document.getElementById('breadcrumb');
const renameBtn = document.getElementById('renameBtn');
const deleteBtn = document.getElementById('deleteBtn');
const upBtn = document.getElementById('upBtn');
function setFilesStatus(msg, type) {
const el = document.getElementById('files-status');
el.textContent = msg;
el.className = 'status' + (type ? ' show ' + type : '');
if (type !== 'error') setTimeout(() => el.className = '', 3000);
}
function updateBreadcrumb(path) {
const parts = path.split('/').filter(Boolean);
breadcrumbEl.innerHTML = '';
const rootLink = document.createElement('a');
rootLink.href = '#';
rootLink.textContent = 'Root';
rootLink.addEventListener('click', e => { e.preventDefault(); navigateTo('/'); });
breadcrumbEl.appendChild(rootLink);
let cumulative = '';
parts.forEach((p, i) => {
cumulative += '/' + p;
const cp = cumulative;
const sep = document.createElement('span');
sep.textContent = '/';
breadcrumbEl.appendChild(sep);
if (i === parts.length - 1) {
const strong = document.createElement('strong');
strong.textContent = p;
breadcrumbEl.appendChild(strong);
} else {
const link = document.createElement('a');
link.href = '#';
link.textContent = p;
link.addEventListener('click', e => { e.preventDefault(); navigateTo(cp); });
breadcrumbEl.appendChild(link);
}
});
}
function updateSelection() {
renameBtn.disabled = !selectedName;
deleteBtn.disabled = !selectedName;
}
function selectRow(name, tr, type) {
document.querySelectorAll('#file-list tr').forEach(r => r.classList.remove('selected'));
if (name && tr) {
tr.classList.add('selected');
selectedName = name;
selectedType = type;
} else {
selectedName = '';
selectedType = '';
}
updateSelection();
}
function navigateTo(path) {
pathInput.value = path;
refreshFiles();
}
function goHome() { navigateTo('/'); }
function goUp() {
const parts = currentPath.split('/').filter(Boolean);
if (parts.length <= 1) return;
parts.pop();
navigateTo('/' + parts.join('/'));
}
function getFileIcon(name, type) {
if (type === 'dir') return '\uD83D\uDCC1';
const ext = name.split('.').pop().toLowerCase();
const icons = {
txt: '\uD83D\uDCC4', json: '\uD83D\uDCCB', html: '\uD83C\uDF10', css: '\uD83C\uDFA8', js: '\u26A1',
png: '\uD83D\uDDBC\uFE0F', jpg: '\uD83D\uDDBC\uFE0F', jpeg: '\uD83D\uDDBC\uFE0F', gif: '\uD83D\uDDBC\uFE0F', svg: '\uD83D\uDDBC\uFE0F',
mp3: '\uD83C\uDFB5', wav: '\uD83C\uDFB5', mp4: '\uD83C\uDFAC', zip: '\uD83D\uDCE6', gz: '\uD83D\uDCE6', app: '\uD83D\uDCE6'
};
return icons[ext] || '\uD83D\uDCC4';
}
async function refreshFiles() {
const path = pathInput.value || '/';
currentPath = path;
selectedName = '';
updateSelection();
updateBreadcrumb(path);
upBtn.disabled = path === '/' || path.split('/').filter(Boolean).length <= 1;
fileListEl.innerHTML = '<tr><td colspan="4" class="empty">Loading...</td></tr>';
try {
const r = await fetch('/fs/list?path=' + encodeURIComponent(path));
if (!r.ok) {
const text = await r.text();
throw new Error(text || 'HTTP ' + r.status);
}
const data = await r.json();
if (data.error) {
setFilesStatus(data.error, 'error');
fileListEl.innerHTML = '<tr><td colspan="4" class="empty">Error loading directory</td></tr>';
return;
}
renderFileList(data);
setFilesStatus('Loaded ' + data.entries.length + ' item(s)', 'info');
} catch (e) {
setFilesStatus('Error: ' + e.message, 'error');
fileListEl.innerHTML = '<tr><td colspan="4" class="empty">Failed to load</td></tr>';
}
}
function renderFileList(data) {
fileListEl.innerHTML = '';
const basePath = data.path === '/' ? '' : data.path;
// Parent directory
if (data.path && data.path !== '/' && data.path.split('/').filter(Boolean).length > 1) {
const tr = document.createElement('tr');
tr.innerHTML = `<td class="file-name" onclick="goUp()" style="cursor:pointer"><span class="file-icon">\uD83D\uDCC1</span>..</td>
<td class="file-type">dir</td><td></td><td></td>`;
fileListEl.appendChild(tr);
}
if (data.entries.length === 0) {
const tr = document.createElement('tr');
tr.innerHTML = '<td colspan="4" class="empty">Empty directory</td>';
fileListEl.appendChild(tr);
return;
}
// Sort: directories first, then files
data.entries.sort((a, b) => {
if (a.type === 'dir' && b.type !== 'dir') return -1;
if (a.type !== 'dir' && b.type === 'dir') return 1;
return a.name.localeCompare(b.name);
});
data.entries.forEach(e => {
const tr = document.createElement('tr');
const fullPath = basePath + '/' + e.name;
const dlUrl = '/fs/download?path=' + encodeURIComponent(fullPath);
const nameTd = document.createElement('td');
nameTd.className = 'file-name';
nameTd.innerHTML = `<span class="file-icon">${getFileIcon(e.name, e.type)}</span>${escapeHtml(e.name)}`;
if (e.type === 'dir') {
// Single click to enter directories
nameTd.onclick = () => navigateTo(fullPath);
nameTd.style.cursor = 'pointer';
} else {
// Single click to select files, double-click to download
nameTd.onclick = () => selectRow(e.name, tr, e.type);
nameTd.ondblclick = () => window.location = dlUrl;
}
tr.appendChild(nameTd);
const typeTd = document.createElement('td');
typeTd.className = 'file-type';
typeTd.textContent = e.type;
tr.appendChild(typeTd);
const sizeTd = document.createElement('td');
sizeTd.className = 'file-size';
sizeTd.textContent = e.type === 'file' ? formatBytes(e.size) : '';
tr.appendChild(sizeTd);
const actTd = document.createElement('td');
if (e.type === 'file') {
const a = document.createElement('a');
a.className = 'dl-link';
a.href = dlUrl;
a.textContent = 'Download';
actTd.appendChild(a);
}
tr.appendChild(actTd);
fileListEl.appendChild(tr);
});
}
function triggerUpload() { document.getElementById('uploadInput').click(); }
document.getElementById('uploadInput').addEventListener('change', async ev => {
const files = ev.target.files;
if (!files.length) return;
for (const file of files) {
const target = currentPath + (currentPath.endsWith('/') ? '' : '/') + file.name;
setFilesStatus('Uploading ' + file.name + '...', 'info');
try {
const r = await fetch('/fs/upload?path=' + encodeURIComponent(target), {
method: 'POST', body: file
});
if (!r.ok) throw new Error(await r.text());
} catch (e) {
setFilesStatus('Upload failed: ' + e, 'error');
return;
}
}
setFilesStatus('Upload complete', 'success');
ev.target.value = '';
refreshFiles();
});
function createFolder() {
const name = prompt('New folder name:');
if (!name) return;
const target = currentPath + (currentPath.endsWith('/') ? '' : '/') + name;
fetch('/fs/mkdir?path=' + encodeURIComponent(target), { method: 'POST' })
.then(r => r.ok ? r.text() : Promise.reject(r.statusText))
.then(() => { setFilesStatus('Folder created', 'success'); refreshFiles(); })
.catch(e => setFilesStatus('Error: ' + e, 'error'));
}
function deleteSelected() {
if (!selectedName) { setFilesStatus('Select a file or folder first', 'error'); return; }
if (!confirm('Delete "' + selectedName + '"?')) return;
const target = currentPath + (currentPath.endsWith('/') ? '' : '/') + selectedName;
fetch('/fs/delete?path=' + encodeURIComponent(target), { method: 'POST' })
.then(r => r.ok ? r.text() : Promise.reject(r.statusText))
.then(() => { setFilesStatus('Deleted', 'success'); refreshFiles(); })
.catch(e => setFilesStatus('Delete failed: ' + e, 'error'));
}
function renameSelected() {
if (!selectedName) { setFilesStatus('Select a file or folder first', 'error'); return; }
const newName = prompt('Rename to:', selectedName);
if (!newName || newName === selectedName) return;
const target = currentPath + (currentPath.endsWith('/') ? '' : '/') + selectedName;
fetch('/fs/rename?path=' + encodeURIComponent(target) + '&newName=' + encodeURIComponent(newName), { method: 'POST' })
.then(r => r.ok ? r.text() : Promise.reject(r.statusText))
.then(() => { setFilesStatus('Renamed', 'success'); refreshFiles(); })
.catch(e => setFilesStatus('Rename failed: ' + e, 'error'));
}
// ==================== APPS ====================
let appsLoaded = false;
const appListEl = document.getElementById('app-list');
function setAppsStatus(msg, type) {
const el = document.getElementById('apps-status');
el.textContent = msg;
el.className = 'status' + (type ? ' show ' + type : '');
if (type !== 'error') setTimeout(() => el.className = '', 3000);
}
async function loadApps() {
appListEl.innerHTML = '<div class="loading">Loading apps...</div>';
try {
const r = await fetch('/api/apps');
if (!r.ok) throw new Error('Failed to fetch');
const data = await r.json();
renderAppList(data.apps);
} catch (e) {
appListEl.innerHTML = `<div class="error">Failed to load apps: ${escapeHtml(e.message)}</div>`;
}
}
function escapeAttr(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
function renderAppList(apps) {
if (!apps || apps.length === 0) {
appListEl.innerHTML = '<div class="empty">No apps installed</div>';
return;
}
// Sort: user apps first, then by name
apps.sort((a, b) => {
if (a.category === 'user' && b.category !== 'user') return -1;
if (a.category !== 'user' && b.category === 'user') return 1;
return a.name.localeCompare(b.name);
});
appListEl.innerHTML = apps.filter(app => !app.hidden).map(app => `
<div class="app-item">
<div class="app-icon">${app.isExternal ? '\uD83D\uDCE6' : '\u2699\uFE0F'}</div>
<div class="app-info">
<div class="app-name">${escapeHtml(app.name)}</div>
<div class="app-meta">${escapeHtml(app.id)} ${app.version ? 'v' + escapeHtml(app.version) : ''} | ${app.category}</div>
</div>
<div class="app-actions" data-app-id="${escapeAttr(app.id)}">
<button class="btn btn-primary btn-sm" onclick="runApp(this.parentElement.dataset.appId, this)">Run</button>
${app.isExternal ? `<button class="btn btn-danger btn-sm" onclick="uninstallApp(this.parentElement.dataset.appId, this)">Uninstall</button>` : ''}
</div>
</div>
`).join('');
}
async function runApp(appId, btn) {
btn.disabled = true;
btn.textContent = 'Starting...';
try {
const r = await fetch('/api/apps/run?id=' + encodeURIComponent(appId), { method: 'POST' });
if (!r.ok) throw new Error(await r.text());
setAppsStatus('App started: ' + appId, 'success');
} catch (e) {
setAppsStatus('Failed to start app: ' + e.message, 'error');
}
btn.disabled = false;
btn.textContent = 'Run';
}
async function uninstallApp(appId, btn) {
if (!confirm('Uninstall ' + appId + '?')) return;
btn.disabled = true;
btn.textContent = 'Removing...';
try {
const r = await fetch('/api/apps/uninstall?id=' + encodeURIComponent(appId), { method: 'POST' });
if (!r.ok) throw new Error(await r.text());
setAppsStatus('App uninstalled', 'success');
loadApps();
} catch (e) {
setAppsStatus('Failed to uninstall: ' + e.message, 'error');
btn.disabled = false;
btn.textContent = 'Uninstall';
}
}
// App upload handling
const uploadZone = document.getElementById('uploadZone');
const appFileInput = document.getElementById('appFileInput');
uploadZone.addEventListener('dragover', e => {
e.preventDefault();
uploadZone.classList.add('dragover');
});
uploadZone.addEventListener('dragleave', () => {
uploadZone.classList.remove('dragover');
});
uploadZone.addEventListener('drop', async e => {
e.preventDefault();
uploadZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0 && files[0].name.toLowerCase().endsWith('.app')) {
await installAppFile(files[0]);
} else if (files.length > 0) {
setAppsStatus('Only .app files are supported', 'error');
}
});
appFileInput.addEventListener('change', async e => {
if (e.target.files.length > 0) {
await installAppFile(e.target.files[0]);
e.target.value = '';
}
});
async function installAppFile(file) {
setAppsStatus('Installing ' + file.name + '...', 'info');
const formData = new FormData();
formData.append('file', file, file.name);
try {
const r = await fetch('/api/apps/install', {
method: 'PUT',
body: formData
});
if (!r.ok) throw new Error(await r.text());
setAppsStatus('App installed successfully!', 'success');
loadApps();
} catch (e) {
setAppsStatus('Installation failed: ' + e.message, 'error');
}
}
// Initial load
loadDashboard();
startAutoRefresh();
</script>
</body>
</html>