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:
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user