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

View File

@@ -3,6 +3,7 @@ let refreshInterval = null;
let apiKey = null; let apiKey = null;
let kcTestAutoRefresh = null; // interval ID for KC test auto-refresh let kcTestAutoRefresh = null; // interval ID for KC test auto-refresh
let kcTestTargetId = null; // currently testing KC target let kcTestTargetId = null; // currently testing KC target
let _dashboardWS = null; // WebSocket for dashboard live updates
// Toggle hint description visibility next to a label // Toggle hint description visibility next to a label
function toggleHint(btn) { 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-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}`)); document.querySelectorAll('.tab-panel').forEach(panel => panel.classList.toggle('active', panel.id === `tab-${name}`));
localStorage.setItem('activeTab', name); localStorage.setItem('activeTab', name);
if (name === 'streams') { if (name === 'dashboard') {
loadPictureSources(); loadDashboard();
} else if (name === 'targets') { startDashboardWS();
loadTargetsTab(); } else {
stopDashboardWS();
if (name === 'streams') {
loadPictureSources();
} else if (name === 'targets') {
loadTargetsTab();
}
} }
} }
@@ -599,9 +606,8 @@ function initTabs() {
let saved = localStorage.getItem('activeTab'); let saved = localStorage.getItem('activeTab');
// Migrate legacy 'devices' tab to 'targets' (devices now live inside targets) // Migrate legacy 'devices' tab to 'targets' (devices now live inside targets)
if (saved === 'devices') saved = 'targets'; if (saved === 'devices') saved = 'targets';
if (saved && document.getElementById(`tab-${saved}`)) { if (!saved || !document.getElementById(`tab-${saved}`)) saved = 'dashboard';
switchTab(saved); switchTab(saved);
}
} }
@@ -1333,14 +1339,251 @@ function startAutoRefresh() {
refreshInterval = setInterval(() => { refreshInterval = setInterval(() => {
// Only refresh if user is authenticated // Only refresh if user is authenticated
if (apiKey) { if (apiKey) {
const activeTab = localStorage.getItem('activeTab') || 'targets'; const activeTab = localStorage.getItem('activeTab') || 'dashboard';
if (activeTab === 'targets') { if (activeTab === 'targets') {
loadTargetsTab(); loadTargetsTab();
} else if (activeTab === 'dashboard') {
loadDashboard();
} }
} }
}, 2000); // Refresh every 2 seconds }, 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 // Toast notifications
function showToast(message, type = 'info') { function showToast(message, type = 'info') {
const toast = document.getElementById('toast'); const toast = document.getElementById('toast');

View File

@@ -35,10 +35,17 @@
<div class="tabs"> <div class="tabs">
<div class="tab-bar"> <div class="tab-bar">
<button class="tab-btn" data-tab="dashboard" onclick="switchTab('dashboard')"><span data-i18n="dashboard.title">📊 Dashboard</span></button>
<button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button> <button class="tab-btn" data-tab="targets" onclick="switchTab('targets')"><span data-i18n="targets.title">⚡ Targets</span></button>
<button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button> <button class="tab-btn" data-tab="streams" onclick="switchTab('streams')"><span data-i18n="streams.title">📺 Sources</span></button>
</div> </div>
<div class="tab-panel" id="tab-dashboard">
<div id="dashboard-content">
<div class="loading-spinner"></div>
</div>
</div>
<div class="tab-panel" id="tab-targets"> <div class="tab-panel" id="tab-targets">
<div id="targets-panel-content"> <div id="targets-panel-content">
<div class="loading-spinner"></div> <div class="loading-spinner"></div>
@@ -56,7 +63,7 @@
(function() { (function() {
var saved = localStorage.getItem('activeTab'); var saved = localStorage.getItem('activeTab');
if (saved === 'devices') saved = 'targets'; 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-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); }); document.querySelectorAll('.tab-panel').forEach(function(panel) { panel.classList.toggle('active', panel.id === 'tab-' + saved); });
})(); })();

View File

@@ -445,5 +445,15 @@
"overlay.started": "Overlay visualization started", "overlay.started": "Overlay visualization started",
"overlay.stopped": "Overlay visualization stopped", "overlay.stopped": "Overlay visualization stopped",
"overlay.error.start": "Failed to start overlay", "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"
} }

View File

@@ -445,5 +445,15 @@
"overlay.started": "Визуализация наложения запущена", "overlay.started": "Визуализация наложения запущена",
"overlay.stopped": "Визуализация наложения остановлена", "overlay.stopped": "Визуализация наложения остановлена",
"overlay.error.start": "Не удалось запустить наложение", "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": "Не удалось загрузить обзор"
} }

View File

@@ -3105,3 +3105,151 @@ input:-webkit-autofill:focus {
flex: 0 0 24px; 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;
}
}