Add dashboard tab with real-time target status overview

Dashboard is the new default tab showing running/stopped targets
with FPS, uptime, errors metrics. Updates live via events WebSocket.
Includes Stop All button and Start/Stop per target.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-17 14:54:11 +03:00
parent 3ee17ed083
commit f4503d36b4
5 changed files with 429 additions and 11 deletions
+251 -8
View File
@@ -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 = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
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 += `<div class="dashboard-section">
<div class="dashboard-section-header">
${t('dashboard.section.running')}
<span class="dashboard-section-count">${running.length}</span>
<button class="btn btn-sm btn-danger dashboard-stop-all" onclick="dashboardStopAll()" title="${t('dashboard.stop_all')}">⏹️ ${t('dashboard.stop_all')}</button>
</div>
${running.map(target => renderDashboardTarget(target, true)).join('')}
</div>`;
}
// Stopped section
if (stopped.length > 0) {
html += `<div class="dashboard-section">
<div class="dashboard-section-header">
${t('dashboard.section.stopped')}
<span class="dashboard-section-count">${stopped.length}</span>
</div>
${stopped.map(target => renderDashboardTarget(target, false)).join('')}
</div>`;
}
container.innerHTML = html;
} catch (error) {
console.error('Failed to load dashboard:', error);
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.failed')}</div>`;
} 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 = `<span class="health-dot ${cls}"></span>`;
}
return `<div class="dashboard-target">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(target.name)}${healthDot}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div>
</div>
<div class="dashboard-target-metrics">
<div class="dashboard-metric">
<div class="dashboard-metric-value">${fpsActual}/${fpsTarget}</div>
<div class="dashboard-metric-label">${t('dashboard.fps')}</div>
</div>
<div class="dashboard-metric">
<div class="dashboard-metric-value">${uptime}</div>
<div class="dashboard-metric-label">${t('dashboard.uptime')}</div>
</div>
<div class="dashboard-metric">
<div class="dashboard-metric-value">${errors}</div>
<div class="dashboard-metric-label">${t('dashboard.errors')}</div>
</div>
</div>
<div class="dashboard-target-actions">
<span class="dashboard-status-dot active" title="${t('device.status.processing')}">●</span>
<button class="btn btn-icon btn-danger" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">⏹️</button>
</div>
</div>`;
} else {
return `<div class="dashboard-target">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(target.name)}</div>
${subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : ''}
</div>
<span class="dashboard-badge-stopped">${t('dashboard.section.stopped')}</span>
</div>
<div class="dashboard-target-metrics"></div>
<div class="dashboard-target-actions">
<button class="btn btn-icon btn-primary" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">▶️</button>
</div>
</div>`;
}
}
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');