diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 473b8d4..76c19cc 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -3,6 +3,7 @@ let refreshInterval = null; let apiKey = null; let kcTestAutoRefresh = null; // interval ID for KC test auto-refresh let kcTestTargetId = null; // currently testing KC target +let _dashboardWS = null; // WebSocket for dashboard live updates // Toggle hint description visibility next to a label function toggleHint(btn) { @@ -588,10 +589,16 @@ function switchTab(name) { document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.tab === name)); document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`)); localStorage.setItem('activeTab', name); - if (name === 'streams') { - loadPictureSources(); - } else if (name === 'targets') { - loadTargetsTab(); + if (name === 'dashboard') { + loadDashboard(); + startDashboardWS(); + } else { + stopDashboardWS(); + if (name === 'streams') { + loadPictureSources(); + } else if (name === 'targets') { + loadTargetsTab(); + } } } @@ -599,9 +606,8 @@ function initTabs() { let saved = localStorage.getItem('activeTab'); // Migrate legacy 'devices' tab to 'targets' (devices now live inside targets) if (saved === 'devices') saved = 'targets'; - if (saved && document.getElementById(`tab-${saved}`)) { - switchTab(saved); - } + if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard'; + switchTab(saved); } @@ -1333,14 +1339,251 @@ function startAutoRefresh() { refreshInterval = setInterval(() => { // Only refresh if user is authenticated if (apiKey) { - const activeTab = localStorage.getItem('activeTab') || 'targets'; + const activeTab = localStorage.getItem('activeTab') || 'dashboard'; if (activeTab === 'targets') { loadTargetsTab(); + } else if (activeTab === 'dashboard') { + loadDashboard(); } } }, 2000); // Refresh every 2 seconds } +// ── Dashboard ── + +function formatUptime(seconds) { + if (!seconds || seconds <= 0) return '-'; + const h = Math.floor(seconds / 3600); + const m = Math.floor((seconds % 3600) / 60); + const s = Math.floor(seconds % 60); + if (h > 0) return `${h}h ${m}m`; + if (m > 0) return `${m}m ${s}s`; + return `${s}s`; +} + +let _dashboardLoading = false; + +async function loadDashboard() { + if (_dashboardLoading) return; + _dashboardLoading = true; + const container = document.getElementById('dashboard-content'); + if (!container) { _dashboardLoading = false; return; } + + try { + const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }); + if (targetsResp.status === 401) { handle401Error(); return; } + + const targetsData = await targetsResp.json(); + const targets = targetsData.targets || []; + + if (targets.length === 0) { + container.innerHTML = `
${t('dashboard.no_targets')}
`; + return; + } + + // Fetch state + metrics for each target in parallel + const enriched = await Promise.all(targets.map(async (target) => { + try { + const [stateResp, metricsResp] = await Promise.all([ + fetch(`${API_BASE}/picture-targets/${target.id}/state`, { headers: getHeaders() }), + fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() }), + ]); + const state = stateResp.ok ? await stateResp.json() : {}; + const metrics = metricsResp.ok ? await metricsResp.json() : {}; + return { ...target, state, metrics }; + } catch { + return target; + } + })); + + const running = enriched.filter(t => t.state && t.state.processing); + const stopped = enriched.filter(t => !t.state || !t.state.processing); + + let html = ''; + + // Running section + if (running.length > 0) { + html += `
+
+ ${t('dashboard.section.running')} + ${running.length} + +
+ ${running.map(target => renderDashboardTarget(target, true)).join('')} +
`; + } + + // Stopped section + if (stopped.length > 0) { + html += `
+
+ ${t('dashboard.section.stopped')} + ${stopped.length} +
+ ${stopped.map(target => renderDashboardTarget(target, false)).join('')} +
`; + } + + container.innerHTML = html; + + } catch (error) { + console.error('Failed to load dashboard:', error); + container.innerHTML = `
${t('dashboard.failed')}
`; + } finally { + _dashboardLoading = false; + } +} + +function renderDashboardTarget(target, isRunning) { + const state = target.state || {}; + const metrics = target.metrics || {}; + const isLed = target.target_type === 'led' || target.target_type === 'wled'; + const icon = isLed ? '💡' : '🎨'; + + let subtitleParts = []; + if (isLed && state.device_name) { + subtitleParts.push(state.device_name); + } + + if (isRunning) { + const fpsActual = state.fps_actual != null ? state.fps_actual.toFixed(1) : '-'; + const fpsTarget = state.fps_target || (target.settings || target.key_colors_settings || {}).fps || '-'; + const uptime = formatUptime(metrics.uptime_seconds); + const errors = metrics.errors_count || 0; + + let healthDot = ''; + if (isLed && state.device_last_checked != null) { + const cls = state.device_online ? 'health-online' : 'health-offline'; + healthDot = ``; + } + + return `
+
+ ${icon} +
+
${escapeHtml(target.name)}${healthDot}
+ ${subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''} +
+
+
+
+
${fpsActual}/${fpsTarget}
+
${t('dashboard.fps')}
+
+
+
${uptime}
+
${t('dashboard.uptime')}
+
+
+
${errors}
+
${t('dashboard.errors')}
+
+
+
+ + +
+
`; + } else { + return `
+
+ ${icon} +
+
${escapeHtml(target.name)}
+ ${subtitleParts.length ? `
${escapeHtml(subtitleParts.join(' · '))}
` : ''} +
+ ${t('dashboard.section.stopped')} +
+
+
+ +
+
`; + } +} + +async function dashboardStartTarget(targetId) { + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/start`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('device.started'), 'success'); + loadDashboard(); + } else { + const error = await response.json(); + showToast(`Failed to start: ${error.detail}`, 'error'); + } + } catch (error) { + showToast('Failed to start processing', 'error'); + } +} + +async function dashboardStopTarget(targetId) { + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/stop`, { + method: 'POST', + headers: getHeaders() + }); + if (response.status === 401) { handle401Error(); return; } + if (response.ok) { + showToast(t('device.stopped'), 'success'); + loadDashboard(); + } else { + const error = await response.json(); + showToast(`Failed to stop: ${error.detail}`, 'error'); + } + } catch (error) { + showToast('Failed to stop processing', 'error'); + } +} + +async function dashboardStopAll() { + try { + const targetsResp = await fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }); + if (targetsResp.status === 401) { handle401Error(); return; } + const data = await targetsResp.json(); + const running = (data.targets || []).filter(t => t.id); + await Promise.all(running.map(t => + fetch(`${API_BASE}/picture-targets/${t.id}/stop`, { method: 'POST', headers: getHeaders() }).catch(() => {}) + )); + loadDashboard(); + } catch (error) { + showToast('Failed to stop all targets', 'error'); + } +} + +function startDashboardWS() { + stopDashboardWS(); + if (!apiKey) return; + const wsProto = location.protocol === 'https:' ? 'wss:' : 'ws:'; + const url = `${wsProto}//${location.host}/api/v1/events/ws?token=${apiKey}`; + try { + _dashboardWS = new WebSocket(url); + _dashboardWS.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + if (data.type === 'state_change') { + loadDashboard(); + } + } catch {} + }; + _dashboardWS.onclose = () => { _dashboardWS = null; }; + _dashboardWS.onerror = () => { _dashboardWS = null; }; + } catch { + _dashboardWS = null; + } +} + +function stopDashboardWS() { + if (_dashboardWS) { + _dashboardWS.close(); + _dashboardWS = null; + } +} + // Toast notifications function showToast(message, type = 'info') { const toast = document.getElementById('toast'); diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index c0ecd5e..24b735b 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -35,10 +35,17 @@
+
+
+
+
+
+
+
@@ -56,7 +63,7 @@ (function() { var saved = localStorage.getItem('activeTab'); if (saved === 'devices') saved = 'targets'; - if (!saved || !document.getElementById('tab-' + saved)) saved = 'targets'; + if (!saved || !document.getElementById('tab-' + saved)) saved = 'dashboard'; document.querySelectorAll('.tab-btn').forEach(function(btn) { btn.classList.toggle('active', btn.dataset.tab === saved); }); document.querySelectorAll('.tab-panel').forEach(function(panel) { panel.classList.toggle('active', panel.id === 'tab-' + saved); }); })(); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 8770eca..e0cf8c3 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -445,5 +445,15 @@ "overlay.started": "Overlay visualization started", "overlay.stopped": "Overlay visualization stopped", "overlay.error.start": "Failed to start overlay", - "overlay.error.stop": "Failed to stop overlay" + "overlay.error.stop": "Failed to stop overlay", + "dashboard.title": "📊 Dashboard", + "dashboard.section.running": "Running", + "dashboard.section.stopped": "Stopped", + "dashboard.no_targets": "No targets configured", + "dashboard.uptime": "Uptime", + "dashboard.fps": "FPS", + "dashboard.errors": "Errors", + "dashboard.device": "Device", + "dashboard.stop_all": "Stop All", + "dashboard.failed": "Failed to load dashboard" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 00527e7..e78ed5e 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -445,5 +445,15 @@ "overlay.started": "Визуализация наложения запущена", "overlay.stopped": "Визуализация наложения остановлена", "overlay.error.start": "Не удалось запустить наложение", - "overlay.error.stop": "Не удалось остановить наложение" + "overlay.error.stop": "Не удалось остановить наложение", + "dashboard.title": "📊 Обзор", + "dashboard.section.running": "Запущенные", + "dashboard.section.stopped": "Остановленные", + "dashboard.no_targets": "Нет настроенных целей", + "dashboard.uptime": "Время работы", + "dashboard.fps": "FPS", + "dashboard.errors": "Ошибки", + "dashboard.device": "Устройство", + "dashboard.stop_all": "Остановить все", + "dashboard.failed": "Не удалось загрузить обзор" } diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 9f7fc6f..8319f67 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -3105,3 +3105,151 @@ input:-webkit-autofill:focus { flex: 0 0 24px; } +/* ── Dashboard ── */ + +.dashboard-section { + margin-bottom: 24px; +} + +.dashboard-section-header { + font-size: 1rem; + font-weight: 600; + margin-bottom: 10px; + color: var(--text-secondary); + display: flex; + align-items: center; + gap: 8px; +} + +.dashboard-section-count { + background: var(--border-color); + color: var(--text-secondary); + border-radius: 10px; + padding: 1px 8px; + font-size: 0.8rem; + font-weight: 600; +} + +.dashboard-stop-all { + margin-left: auto; + padding: 3px 10px; + font-size: 0.75rem; + white-space: nowrap; + flex: 0 0 auto; +} + +.dashboard-target { + display: grid; + grid-template-columns: 1fr auto auto; + align-items: center; + gap: 16px; + padding: 12px 16px; + background: var(--card-bg); + border: 1px solid var(--border-color); + border-radius: 8px; + margin-bottom: 8px; +} + +.dashboard-target-info { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + overflow: hidden; +} + +.dashboard-target-icon { + font-size: 1.2rem; + flex-shrink: 0; +} + +.dashboard-target-name { + font-weight: 600; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + display: flex; + align-items: center; + gap: 6px; +} + +.dashboard-target-name .health-dot { + margin-right: 0; + flex-shrink: 0; +} + +.dashboard-target-subtitle { + font-size: 0.75rem; + color: var(--text-secondary); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.dashboard-target-metrics { + display: grid; + grid-template-columns: 90px 80px 60px; + gap: 12px; + align-items: center; +} + +.dashboard-metric { + text-align: center; +} + +.dashboard-metric-value { + font-size: 1.05rem; + font-weight: 700; + color: var(--primary-color); +} + +.dashboard-metric-label { + font-size: 0.7rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.3px; +} + +.dashboard-target-actions { + display: flex; + align-items: center; + gap: 8px; +} + +.dashboard-status-dot { + font-size: 1.2rem; + line-height: 1; +} + +.dashboard-status-dot.active { + color: #4CAF50; + animation: pulse 2s infinite; +} + +.dashboard-no-targets { + text-align: center; + padding: 40px 20px; + color: var(--text-secondary); + font-size: 1rem; +} + +.dashboard-badge-stopped { + padding: 3px 10px; + border-radius: 10px; + font-size: 0.75rem; + font-weight: 600; + background: var(--border-color); + color: var(--text-secondary); +} + +@media (max-width: 768px) { + .dashboard-target { + grid-template-columns: 1fr; + gap: 10px; + } + + .dashboard-target-actions { + justify-content: flex-end; + } +} +