Profiles now activate scene presets instead of individual targets, with configurable deactivation behavior (none/revert/fallback scene). The target checklist UI is replaced by a searchable combobox for scene selection that scales well with many scenes. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
852 lines
38 KiB
JavaScript
852 lines
38 KiB
JavaScript
/**
|
|
* Dashboard — real-time target status overview.
|
|
*/
|
|
|
|
import { apiKey, _dashboardLoading, set_dashboardLoading, dashboardPollInterval, setDashboardPollInterval } from '../core/state.js';
|
|
import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js';
|
|
import { t } from '../core/i18n.js';
|
|
import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js';
|
|
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
|
|
import { startAutoRefresh, updateTabBadge } from './tabs.js';
|
|
import {
|
|
getTargetTypeIcon,
|
|
ICON_TARGET, ICON_PROFILE, ICON_CLOCK, ICON_WARNING, ICON_OK,
|
|
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP, ICON_SCENE,
|
|
} from '../core/icons.js';
|
|
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
|
|
|
|
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
|
|
const MAX_FPS_SAMPLES = 120;
|
|
|
|
let _fpsHistory = {}; // { targetId: number[] } — fps_actual
|
|
let _fpsCurrentHistory = {}; // { targetId: number[] } — fps_current
|
|
let _fpsCharts = {}; // { targetId: Chart }
|
|
let _lastRunningIds = []; // sorted target IDs from previous render
|
|
let _lastAutoStartIds = ''; // comma-joined sorted auto-start IDs
|
|
let _uptimeBase = {}; // { targetId: { seconds, timestamp } }
|
|
let _uptimeTimer = null;
|
|
let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs
|
|
let _metricsElements = new Map();
|
|
|
|
function _pushFps(targetId, actual, current) {
|
|
if (!_fpsHistory[targetId]) _fpsHistory[targetId] = [];
|
|
_fpsHistory[targetId].push(actual);
|
|
if (_fpsHistory[targetId].length > MAX_FPS_SAMPLES) _fpsHistory[targetId].shift();
|
|
|
|
if (!_fpsCurrentHistory[targetId]) _fpsCurrentHistory[targetId] = [];
|
|
_fpsCurrentHistory[targetId].push(current);
|
|
if (_fpsCurrentHistory[targetId].length > MAX_FPS_SAMPLES) _fpsCurrentHistory[targetId].shift();
|
|
}
|
|
|
|
function _setUptimeBase(targetId, seconds) {
|
|
_uptimeBase[targetId] = { seconds, timestamp: Date.now() };
|
|
}
|
|
|
|
function _getInterpolatedUptime(targetId) {
|
|
const base = _uptimeBase[targetId];
|
|
if (!base) return null;
|
|
const elapsed = (Date.now() - base.timestamp) / 1000;
|
|
return base.seconds + elapsed;
|
|
}
|
|
|
|
function _cacheUptimeElements() {
|
|
_uptimeElements = {};
|
|
for (const id of _lastRunningIds) {
|
|
const el = document.querySelector(`[data-uptime-text="${id}"]`);
|
|
if (el) _uptimeElements[id] = el;
|
|
}
|
|
}
|
|
|
|
function _startUptimeTimer() {
|
|
if (_uptimeTimer) return;
|
|
_uptimeTimer = setInterval(() => {
|
|
for (const id of _lastRunningIds) {
|
|
const el = _uptimeElements[id];
|
|
if (!el) continue;
|
|
const seconds = _getInterpolatedUptime(id);
|
|
if (seconds != null) {
|
|
el.innerHTML = `${ICON_CLOCK} ${formatUptime(seconds)}`;
|
|
}
|
|
}
|
|
}, 1000);
|
|
}
|
|
|
|
function _stopUptimeTimer() {
|
|
if (_uptimeTimer) {
|
|
clearInterval(_uptimeTimer);
|
|
_uptimeTimer = null;
|
|
}
|
|
_uptimeBase = {};
|
|
_uptimeElements = {};
|
|
}
|
|
|
|
function _destroyFpsCharts() {
|
|
for (const id of Object.keys(_fpsCharts)) {
|
|
if (_fpsCharts[id]) { _fpsCharts[id].destroy(); }
|
|
}
|
|
_fpsCharts = {};
|
|
}
|
|
|
|
function _getAccentColor() {
|
|
return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#4CAF50';
|
|
}
|
|
|
|
function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) {
|
|
const canvas = document.getElementById(canvasId);
|
|
if (!canvas) return null;
|
|
const accent = _getAccentColor();
|
|
return new Chart(canvas, {
|
|
type: 'line',
|
|
data: {
|
|
labels: actualHistory.map(() => ''),
|
|
datasets: [
|
|
{
|
|
data: [...actualHistory],
|
|
borderColor: accent,
|
|
backgroundColor: accent + '1f',
|
|
borderWidth: 1.5,
|
|
tension: 0.3,
|
|
fill: true,
|
|
pointRadius: 0,
|
|
},
|
|
{
|
|
data: [...currentHistory],
|
|
borderColor: accent + '80',
|
|
borderWidth: 1.5,
|
|
tension: 0.3,
|
|
fill: false,
|
|
pointRadius: 0,
|
|
},
|
|
],
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
maintainAspectRatio: false,
|
|
animation: false,
|
|
plugins: { legend: { display: false }, tooltip: { enabled: false } },
|
|
scales: {
|
|
x: { display: false },
|
|
y: { min: 0, max: fpsTarget * 1.15, display: false },
|
|
},
|
|
layout: { padding: 0 },
|
|
},
|
|
});
|
|
}
|
|
|
|
async function _initFpsCharts(runningTargetIds) {
|
|
_destroyFpsCharts();
|
|
|
|
// Seed FPS history from server ring buffer on first load
|
|
if (Object.keys(_fpsHistory).length === 0 && runningTargetIds.length > 0) {
|
|
try {
|
|
const resp = await fetch(`${API_BASE}/system/metrics-history`, { headers: getHeaders() });
|
|
if (resp.ok) {
|
|
const data = await resp.json();
|
|
const serverTargets = data.targets || {};
|
|
for (const id of runningTargetIds) {
|
|
const samples = serverTargets[id] || [];
|
|
_fpsHistory[id] = samples.map(s => s.fps).filter(v => v != null);
|
|
_fpsCurrentHistory[id] = samples.map(s => s.fps_current).filter(v => v != null);
|
|
}
|
|
}
|
|
} catch {
|
|
// Silently ignore — charts will fill from polling
|
|
}
|
|
}
|
|
|
|
// Clean up history for targets that are no longer running
|
|
for (const id of Object.keys(_fpsHistory)) {
|
|
if (!runningTargetIds.includes(id)) { delete _fpsHistory[id]; delete _fpsCurrentHistory[id]; }
|
|
}
|
|
for (const id of runningTargetIds) {
|
|
const canvas = document.getElementById(`dashboard-fps-${id}`);
|
|
if (!canvas) continue;
|
|
const actualH = _fpsHistory[id] || [];
|
|
const currentH = _fpsCurrentHistory[id] || [];
|
|
const fpsTarget = parseFloat(canvas.dataset.fpsTarget) || 30;
|
|
_fpsCharts[id] = _createFpsChart(`dashboard-fps-${id}`, actualH, currentH, fpsTarget);
|
|
}
|
|
|
|
_cacheMetricsElements(runningTargetIds);
|
|
}
|
|
|
|
function _cacheMetricsElements(runningIds) {
|
|
_metricsElements.clear();
|
|
for (const id of runningIds) {
|
|
_metricsElements.set(id, {
|
|
fps: document.querySelector(`[data-fps-text="${id}"]`),
|
|
errors: document.querySelector(`[data-errors-text="${id}"]`),
|
|
row: document.querySelector(`[data-target-id="${id}"]`),
|
|
});
|
|
}
|
|
}
|
|
|
|
/** Update running target metrics in-place (no HTML rebuild). */
|
|
function _updateRunningMetrics(enrichedRunning) {
|
|
for (const target of enrichedRunning) {
|
|
const state = target.state || {};
|
|
const metrics = target.metrics || {};
|
|
const fpsCurrent = state.fps_current ?? 0;
|
|
const fpsActual = state.fps_actual != null ? state.fps_actual.toFixed(1) : '-';
|
|
const fpsTarget = state.fps_target || (target.settings || target.key_colors_settings || {}).fps || '-';
|
|
const errors = metrics.errors_count || 0;
|
|
|
|
// Push FPS and update chart
|
|
if (state.fps_actual != null) {
|
|
_pushFps(target.id, state.fps_actual, fpsCurrent);
|
|
}
|
|
const chart = _fpsCharts[target.id];
|
|
if (chart) {
|
|
const actualH = _fpsHistory[target.id] || [];
|
|
const currentH = _fpsCurrentHistory[target.id] || [];
|
|
// Mutate in-place to avoid array copies
|
|
const ds0 = chart.data.datasets[0].data;
|
|
ds0.length = 0;
|
|
ds0.push(...actualH);
|
|
const ds1 = chart.data.datasets[1].data;
|
|
ds1.length = 0;
|
|
ds1.push(...currentH);
|
|
while (chart.data.labels.length < ds0.length) chart.data.labels.push('');
|
|
chart.data.labels.length = ds0.length;
|
|
chart.update('none');
|
|
}
|
|
|
|
// Refresh uptime base for interpolation
|
|
if (metrics.uptime_seconds != null) {
|
|
_setUptimeBase(target.id, metrics.uptime_seconds);
|
|
}
|
|
|
|
// Update text values (use cached refs, fallback to querySelector)
|
|
const cached = _metricsElements.get(target.id);
|
|
const fpsEl = cached?.fps || document.querySelector(`[data-fps-text="${target.id}"]`);
|
|
if (fpsEl) {
|
|
const effFps = state.fps_effective;
|
|
const fpsTargetLabel = (effFps != null && effFps < fpsTarget)
|
|
? `${fpsCurrent}<span class="dashboard-fps-target">/${effFps}↓${fpsTarget}</span>`
|
|
: `${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span>`;
|
|
const unreachableClass = state.device_streaming_reachable === false ? ' fps-unreachable' : '';
|
|
fpsEl.innerHTML = `<span class="${unreachableClass}">${fpsTargetLabel}</span>`
|
|
+ `<span class="dashboard-fps-avg">avg ${fpsActual}</span>`;
|
|
}
|
|
|
|
const errorsEl = cached?.errors || document.querySelector(`[data-errors-text="${target.id}"]`);
|
|
if (errorsEl) errorsEl.innerHTML = `${errors > 0 ? ICON_WARNING : ICON_OK} ${errors}`;
|
|
|
|
// Update health dot — prefer streaming reachability when processing
|
|
const isLed = target.target_type === 'led' || target.target_type === 'wled';
|
|
if (isLed) {
|
|
const row = cached?.row || document.querySelector(`[data-target-id="${target.id}"]`);
|
|
if (row) {
|
|
const dot = row.querySelector('.health-dot');
|
|
if (dot) {
|
|
const streamReachable = state.device_streaming_reachable;
|
|
if (state.processing && streamReachable != null) {
|
|
dot.className = `health-dot ${streamReachable ? 'health-online' : 'health-offline'}`;
|
|
} else if (state.device_last_checked != null) {
|
|
dot.className = `health-dot ${state.device_online ? 'health-online' : 'health-offline'}`;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
function _updateProfilesInPlace(profiles) {
|
|
for (const p of profiles) {
|
|
const card = document.querySelector(`[data-profile-id="${p.id}"]`);
|
|
if (!card) continue;
|
|
const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped');
|
|
if (badge) {
|
|
if (!p.enabled) {
|
|
badge.className = 'dashboard-badge-stopped';
|
|
badge.textContent = t('profiles.status.disabled');
|
|
} else if (p.is_active) {
|
|
badge.className = 'dashboard-badge-active';
|
|
badge.textContent = t('profiles.status.active');
|
|
} else {
|
|
badge.className = 'dashboard-badge-stopped';
|
|
badge.textContent = t('profiles.status.inactive');
|
|
}
|
|
}
|
|
const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn');
|
|
if (btn) {
|
|
btn.className = `dashboard-action-btn ${p.enabled ? 'stop' : 'start'}`;
|
|
btn.setAttribute('onclick', `dashboardToggleProfile('${p.id}', ${!p.enabled})`);
|
|
btn.innerHTML = p.enabled ? ICON_STOP_PLAIN : ICON_START;
|
|
}
|
|
}
|
|
}
|
|
|
|
function _renderPollIntervalSelect() {
|
|
const sec = Math.round(dashboardPollInterval / 1000);
|
|
return `<span class="dashboard-poll-wrap"><input type="range" class="dashboard-poll-slider" min="1" max="10" value="${sec}" oninput="changeDashboardPollInterval(this.value)" title="${t('dashboard.poll_interval')}"><span class="dashboard-poll-value">${sec}s</span></span>`;
|
|
}
|
|
|
|
let _pollDebounce = null;
|
|
export function changeDashboardPollInterval(value) {
|
|
const label = document.querySelector('.dashboard-poll-value');
|
|
if (label) label.textContent = `${value}s`;
|
|
clearTimeout(_pollDebounce);
|
|
_pollDebounce = setTimeout(() => {
|
|
const ms = parseInt(value, 10) * 1000;
|
|
setDashboardPollInterval(ms);
|
|
startAutoRefresh();
|
|
stopPerfPolling();
|
|
startPerfPolling();
|
|
}, 300);
|
|
}
|
|
|
|
function _getCollapsedSections() {
|
|
try { return JSON.parse(localStorage.getItem(DASHBOARD_COLLAPSED_KEY)) || {}; }
|
|
catch { return {}; }
|
|
}
|
|
|
|
export function toggleDashboardSection(sectionKey) {
|
|
const collapsed = _getCollapsedSections();
|
|
collapsed[sectionKey] = !collapsed[sectionKey];
|
|
localStorage.setItem(DASHBOARD_COLLAPSED_KEY, JSON.stringify(collapsed));
|
|
const header = document.querySelector(`[data-dashboard-section="${sectionKey}"]`);
|
|
if (!header) return;
|
|
const content = header.nextElementSibling;
|
|
const chevron = header.querySelector('.dashboard-section-chevron');
|
|
const nowCollapsed = collapsed[sectionKey];
|
|
if (chevron) chevron.style.transform = nowCollapsed ? '' : 'rotate(90deg)';
|
|
|
|
// Animate collapse/expand unless reduced motion
|
|
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
|
content.style.display = nowCollapsed ? 'none' : '';
|
|
return;
|
|
}
|
|
if (content._dsAnim) { content._dsAnim.cancel(); content._dsAnim = null; }
|
|
if (nowCollapsed) {
|
|
const h = content.offsetHeight;
|
|
content.style.overflow = 'hidden';
|
|
const anim = content.animate(
|
|
[{ height: h + 'px', opacity: 1 }, { height: '0px', opacity: 0 }],
|
|
{ duration: 200, easing: 'ease-in-out' }
|
|
);
|
|
content._dsAnim = anim;
|
|
anim.onfinish = () => { content.style.display = 'none'; content.style.overflow = ''; content._dsAnim = null; };
|
|
} else {
|
|
content.style.display = '';
|
|
content.style.overflow = 'hidden';
|
|
const h = content.scrollHeight;
|
|
const anim = content.animate(
|
|
[{ height: '0px', opacity: 0 }, { height: h + 'px', opacity: 1 }],
|
|
{ duration: 200, easing: 'ease-in-out' }
|
|
);
|
|
content._dsAnim = anim;
|
|
anim.onfinish = () => { content.style.overflow = ''; content._dsAnim = null; };
|
|
}
|
|
}
|
|
|
|
function _sectionHeader(sectionKey, label, count, extraHtml = '') {
|
|
const collapsed = _getCollapsedSections();
|
|
const isCollapsed = !!collapsed[sectionKey];
|
|
const chevronStyle = isCollapsed ? '' : ' style="transform:rotate(90deg)"';
|
|
return `<div class="dashboard-section-header" data-dashboard-section="${sectionKey}" onclick="toggleDashboardSection('${sectionKey}')">
|
|
<span class="dashboard-section-chevron"${chevronStyle}>▶</span>
|
|
${label}
|
|
<span class="dashboard-section-count">${count}</span>
|
|
${extraHtml}
|
|
</div>`;
|
|
}
|
|
|
|
function _sectionContent(sectionKey, itemsHtml) {
|
|
const collapsed = _getCollapsedSections();
|
|
const isCollapsed = !!collapsed[sectionKey];
|
|
return `<div class="dashboard-section-content"${isCollapsed ? ' style="display:none"' : ''}>${itemsHtml}</div>`;
|
|
}
|
|
|
|
export async function loadDashboard(forceFullRender = false) {
|
|
if (_dashboardLoading) return;
|
|
set_dashboardLoading(true);
|
|
const container = document.getElementById('dashboard-content');
|
|
if (!container) { set_dashboardLoading(false); return; }
|
|
setTabRefreshing('dashboard-content', true);
|
|
|
|
try {
|
|
// Fire all requests in a single batch to avoid sequential RTTs
|
|
const [targetsResp, profilesResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets] = await Promise.all([
|
|
fetchWithAuth('/picture-targets'),
|
|
fetchWithAuth('/profiles').catch(() => null),
|
|
fetchWithAuth('/devices').catch(() => null),
|
|
fetchWithAuth('/color-strip-sources').catch(() => null),
|
|
fetchWithAuth('/picture-targets/batch/states').catch(() => null),
|
|
fetchWithAuth('/picture-targets/batch/metrics').catch(() => null),
|
|
loadScenePresets(),
|
|
]);
|
|
|
|
const targetsData = await targetsResp.json();
|
|
const targets = targetsData.targets || [];
|
|
const profilesData = profilesResp && profilesResp.ok ? await profilesResp.json() : { profiles: [] };
|
|
const profiles = profilesData.profiles || [];
|
|
const devicesData = devicesResp && devicesResp.ok ? await devicesResp.json() : { devices: [] };
|
|
const devicesMap = {};
|
|
for (const d of (devicesData.devices || [])) { devicesMap[d.id] = d; }
|
|
const cssData = cssResp && cssResp.ok ? await cssResp.json() : { sources: [] };
|
|
const cssSourceMap = {};
|
|
for (const s of (cssData.sources || [])) { cssSourceMap[s.id] = s; }
|
|
|
|
const allStates = batchStatesResp && batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
|
|
const allMetrics = batchMetricsResp && batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {};
|
|
|
|
// Build dynamic HTML (targets, profiles)
|
|
let dynamicHtml = '';
|
|
let runningIds = [];
|
|
let newAutoStartIds = '';
|
|
|
|
if (targets.length === 0 && profiles.length === 0 && scenePresets.length === 0) {
|
|
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
|
|
} else {
|
|
const enriched = targets.map(target => ({
|
|
...target,
|
|
state: allStates[target.id] || {},
|
|
metrics: allMetrics[target.id] || {},
|
|
}));
|
|
|
|
const running = enriched.filter(t => t.state && t.state.processing);
|
|
const stopped = enriched.filter(t => !t.state || !t.state.processing);
|
|
updateTabBadge('targets', running.length);
|
|
|
|
// Check if we can do an in-place metrics update (same targets, not first load)
|
|
const newRunningIds = running.map(t => t.id).sort().join(',');
|
|
const prevRunningIds = [..._lastRunningIds].sort().join(',');
|
|
newAutoStartIds = enriched.filter(t => t.auto_start).map(t => t.id).sort().join(',');
|
|
const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
|
|
const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newAutoStartIds === _lastAutoStartIds;
|
|
if (structureUnchanged && !forceFullRender && running.length > 0) {
|
|
_updateRunningMetrics(running);
|
|
_cacheUptimeElements();
|
|
_startUptimeTimer();
|
|
startPerfPolling();
|
|
set_dashboardLoading(false);
|
|
return;
|
|
}
|
|
if (structureUnchanged && forceFullRender) {
|
|
if (running.length > 0) _updateRunningMetrics(running);
|
|
_updateProfilesInPlace(profiles);
|
|
_cacheUptimeElements();
|
|
_startUptimeTimer();
|
|
startPerfPolling();
|
|
set_dashboardLoading(false);
|
|
return;
|
|
}
|
|
|
|
const autoStartTargets = enriched.filter(t => t.auto_start);
|
|
if (autoStartTargets.length > 0) {
|
|
const autoStartCards = autoStartTargets.map(target => {
|
|
const isRunning = !!(target.state && target.state.processing);
|
|
const isLed = target.target_type !== 'key_colors';
|
|
const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc');
|
|
const subtitleParts = [typeLabel];
|
|
if (isLed) {
|
|
const device = target.device_id ? devicesMap[target.device_id] : null;
|
|
if (device) subtitleParts.push((device.device_type || '').toUpperCase());
|
|
const cssId = target.color_strip_source_id || '';
|
|
if (cssId) {
|
|
const css = cssSourceMap[cssId];
|
|
if (css) subtitleParts.push(t(`color_strip.type.${css.source_type}`) || css.source_type);
|
|
}
|
|
}
|
|
const statusBadge = isRunning
|
|
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
|
|
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
|
|
const subtitle = subtitleParts.length ? `<div class="dashboard-target-subtitle">${escapeHtml(subtitleParts.join(' · '))}</div>` : '';
|
|
const asNavSub = isLed ? 'led' : 'key_colors';
|
|
const asNavSec = isLed ? 'led-targets' : 'kc-targets';
|
|
const asNavAttr = isLed ? 'data-target-id' : 'data-kc-target-id';
|
|
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-target-id="${target.id}" onclick="if(!event.target.closest('button')){navigateToCard('targets','${asNavSub}','${asNavSec}','${asNavAttr}','${target.id}')}">
|
|
<div class="dashboard-target-info">
|
|
<span class="dashboard-target-icon">${ICON_AUTOSTART}</span>
|
|
<div>
|
|
<div class="dashboard-target-name">${escapeHtml(target.name)} ${statusBadge}</div>
|
|
${subtitle}
|
|
</div>
|
|
</div>
|
|
<div class="dashboard-target-actions">
|
|
<button class="dashboard-action-btn ${isRunning ? 'stop' : 'start'}" onclick="${isRunning ? `dashboardStopTarget('${target.id}')` : `dashboardStartTarget('${target.id}')`}" title="${isRunning ? t('device.stop') : t('device.start')}">
|
|
${isRunning ? ICON_STOP_PLAIN : ICON_START}
|
|
</button>
|
|
</div>
|
|
</div>`;
|
|
}).join('');
|
|
const autoStartItems = `<div class="dashboard-autostart-grid">${autoStartCards}</div>`;
|
|
|
|
dynamicHtml += `<div class="dashboard-section">
|
|
${_sectionHeader('autostart', t('autostart.title'), autoStartTargets.length)}
|
|
${_sectionContent('autostart', autoStartItems)}
|
|
</div>`;
|
|
}
|
|
|
|
if (profiles.length > 0) {
|
|
const activeProfiles = profiles.filter(p => p.is_active);
|
|
const inactiveProfiles = profiles.filter(p => !p.is_active);
|
|
updateTabBadge('profiles', activeProfiles.length);
|
|
const sceneMap = new Map(scenePresets.map(s => [s.id, s]));
|
|
const profileItems = [...activeProfiles, ...inactiveProfiles].map(p => renderDashboardProfile(p, sceneMap)).join('');
|
|
|
|
dynamicHtml += `<div class="dashboard-section">
|
|
${_sectionHeader('profiles', t('dashboard.section.profiles'), profiles.length)}
|
|
${_sectionContent('profiles', profileItems)}
|
|
</div>`;
|
|
}
|
|
|
|
// Scene Presets section
|
|
if (scenePresets.length > 0) {
|
|
const sceneSec = renderScenePresetsSection(scenePresets);
|
|
if (sceneSec) {
|
|
dynamicHtml += `<div class="dashboard-section">
|
|
${_sectionHeader('scenes', t('dashboard.section.scenes'), scenePresets.length, sceneSec.headerExtra)}
|
|
${_sectionContent('scenes', sceneSec.content)}
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
if (targets.length > 0) {
|
|
let targetsInner = '';
|
|
|
|
if (running.length > 0) {
|
|
runningIds = running.map(t => t.id);
|
|
const stopAllBtn = `<button class="btn btn-sm btn-primary dashboard-stop-all" onclick="event.stopPropagation(); dashboardStopAll()" title="${t('dashboard.stop_all')}">${ICON_STOP} ${t('dashboard.stop_all')}</button>`;
|
|
const runningItems = running.map(target => renderDashboardTarget(target, true, devicesMap, cssSourceMap)).join('');
|
|
|
|
targetsInner += `<div class="dashboard-subsection">
|
|
${_sectionHeader('running', t('dashboard.section.running'), running.length, stopAllBtn)}
|
|
${_sectionContent('running', runningItems)}
|
|
</div>`;
|
|
}
|
|
|
|
if (stopped.length > 0) {
|
|
const stoppedItems = stopped.map(target => renderDashboardTarget(target, false, devicesMap, cssSourceMap)).join('');
|
|
|
|
targetsInner += `<div class="dashboard-subsection">
|
|
${_sectionHeader('stopped', t('dashboard.section.stopped'), stopped.length)}
|
|
${_sectionContent('stopped', stoppedItems)}
|
|
</div>`;
|
|
}
|
|
|
|
dynamicHtml += `<div class="dashboard-section">
|
|
${_sectionHeader('targets', t('dashboard.section.targets'), targets.length)}
|
|
${_sectionContent('targets', targetsInner)}
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
// First load: build everything in one innerHTML to avoid flicker
|
|
const isFirstLoad = !container.querySelector('.dashboard-perf-persistent');
|
|
const pollSelect = _renderPollIntervalSelect();
|
|
const toolbar = `<div class="stream-tab-bar"><span class="cs-expand-collapse-group">${pollSelect}<button class="tutorial-trigger-btn" onclick="startDashboardTutorial()" title="${t('tour.restart')}">${ICON_HELP}</button></span></div>`;
|
|
if (isFirstLoad) {
|
|
container.innerHTML = `${toolbar}<div class="dashboard-perf-persistent dashboard-section">
|
|
${_sectionHeader('perf', t('dashboard.section.performance'), '')}
|
|
${_sectionContent('perf', renderPerfSection())}
|
|
</div>
|
|
<div class="dashboard-dynamic">${dynamicHtml}</div>`;
|
|
await initPerfCharts();
|
|
} else {
|
|
const dynamic = container.querySelector('.dashboard-dynamic');
|
|
if (dynamic.innerHTML !== dynamicHtml) {
|
|
dynamic.innerHTML = dynamicHtml;
|
|
}
|
|
}
|
|
_lastRunningIds = runningIds;
|
|
_lastAutoStartIds = newAutoStartIds;
|
|
_cacheUptimeElements();
|
|
await _initFpsCharts(runningIds);
|
|
_startUptimeTimer();
|
|
startPerfPolling();
|
|
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
console.error('Failed to load dashboard:', error);
|
|
container.innerHTML = `<div class="dashboard-no-targets">${t('dashboard.failed')}</div>`;
|
|
} finally {
|
|
set_dashboardLoading(false);
|
|
setTabRefreshing('dashboard-content', false);
|
|
}
|
|
}
|
|
|
|
function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap = {}) {
|
|
const state = target.state || {};
|
|
const metrics = target.metrics || {};
|
|
const isLed = target.target_type === 'led' || target.target_type === 'wled';
|
|
const icon = ICON_TARGET;
|
|
const typeLabel = isLed ? t('dashboard.type.led') : t('dashboard.type.kc');
|
|
const navSubTab = isLed ? 'led' : 'key_colors';
|
|
const navSection = isLed ? 'led-targets' : 'kc-targets';
|
|
const navAttr = isLed ? 'data-target-id' : 'data-kc-target-id';
|
|
const navOnclick = `if(!event.target.closest('button')){navigateToCard('targets','${navSubTab}','${navSection}','${navAttr}','${target.id}')}`;
|
|
|
|
let subtitleParts = [typeLabel];
|
|
if (isLed) {
|
|
const device = target.device_id ? devicesMap[target.device_id] : null;
|
|
if (device) {
|
|
subtitleParts.push((device.device_type || '').toUpperCase());
|
|
}
|
|
const cssId = target.color_strip_source_id || '';
|
|
if (cssId) {
|
|
const css = cssSourceMap[cssId];
|
|
if (css) {
|
|
subtitleParts.push(t(`color_strip.type.${css.source_type}`) || css.source_type);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (isRunning) {
|
|
const fpsCurrent = state.fps_current ?? 0;
|
|
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;
|
|
|
|
// Set uptime base for interpolation
|
|
if (metrics.uptime_seconds != null) {
|
|
_setUptimeBase(target.id, metrics.uptime_seconds);
|
|
}
|
|
|
|
// Push FPS samples to history
|
|
if (state.fps_actual != null) {
|
|
_pushFps(target.id, state.fps_actual, fpsCurrent);
|
|
}
|
|
|
|
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 dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}">
|
|
<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 dashboard-fps-metric">
|
|
<div class="dashboard-fps-sparkline">
|
|
<canvas id="dashboard-fps-${target.id}" data-fps-target="${fpsTarget}"></canvas>
|
|
</div>
|
|
<div class="dashboard-fps-label">
|
|
<span class="dashboard-metric-value" data-fps-text="${target.id}">${fpsCurrent}<span class="dashboard-fps-target">/${fpsTarget}</span><span class="dashboard-fps-avg">avg ${fpsActual}</span></span>
|
|
</div>
|
|
</div>
|
|
<div class="dashboard-metric" title="${t('dashboard.uptime')}">
|
|
<div class="dashboard-metric-value" data-uptime-text="${target.id}">${ICON_CLOCK} ${uptime}</div>
|
|
</div>
|
|
<div class="dashboard-metric" title="${t('dashboard.errors')}">
|
|
<div class="dashboard-metric-value" data-errors-text="${target.id}">${errors > 0 ? ICON_WARNING : ICON_OK} ${errors}</div>
|
|
</div>
|
|
</div>
|
|
<div class="dashboard-target-actions">
|
|
<button class="dashboard-autostart-btn${target.auto_start ? ' active' : ''}" onclick="dashboardToggleAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">★</button>
|
|
<button class="dashboard-action-btn stop" onclick="dashboardStopTarget('${target.id}')" title="${t('device.button.stop')}">${ICON_STOP_PLAIN}</button>
|
|
</div>
|
|
</div>`;
|
|
} else {
|
|
return `<div class="dashboard-target dashboard-card-link" onclick="${navOnclick}">
|
|
<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>
|
|
</div>
|
|
<div class="dashboard-target-metrics"></div>
|
|
<div class="dashboard-target-actions">
|
|
<button class="dashboard-autostart-btn${target.auto_start ? ' active' : ''}" onclick="dashboardToggleAutoStart('${target.id}', ${!target.auto_start})" title="${target.auto_start ? t('autostart.toggle.enabled') : t('autostart.toggle.disabled')}">★</button>
|
|
<button class="dashboard-action-btn start" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START}</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
}
|
|
|
|
function renderDashboardProfile(profile, sceneMap = new Map()) {
|
|
const isActive = profile.is_active;
|
|
const isDisabled = !profile.enabled;
|
|
|
|
let condSummary = '';
|
|
if (profile.conditions.length > 0) {
|
|
const parts = profile.conditions.map(c => {
|
|
if (c.condition_type === 'application') {
|
|
const apps = (c.apps || []).join(', ');
|
|
const matchLabel = c.match_type === 'topmost' ? t('profiles.condition.application.match_type.topmost') : t('profiles.condition.application.match_type.running');
|
|
return `${apps} (${matchLabel})`;
|
|
}
|
|
return c.condition_type;
|
|
});
|
|
const logic = profile.condition_logic === 'and' ? ' & ' : ' | ';
|
|
condSummary = parts.join(logic);
|
|
}
|
|
|
|
const statusBadge = isDisabled
|
|
? `<span class="dashboard-badge-stopped">${t('profiles.status.disabled')}</span>`
|
|
: isActive
|
|
? `<span class="dashboard-badge-active">${t('profiles.status.active')}</span>`
|
|
: `<span class="dashboard-badge-stopped">${t('profiles.status.inactive')}</span>`;
|
|
|
|
// Scene info
|
|
const scene = profile.scene_preset_id ? sceneMap.get(profile.scene_preset_id) : null;
|
|
const sceneName = scene ? escapeHtml(scene.name) : t('profiles.scene.none_selected');
|
|
|
|
return `<div class="dashboard-target dashboard-profile dashboard-card-link" data-profile-id="${profile.id}" onclick="if(!event.target.closest('button')){navigateToCard('profiles',null,'profiles','data-profile-id','${profile.id}')}">
|
|
<div class="dashboard-target-info">
|
|
<span class="dashboard-target-icon">${ICON_PROFILE}</span>
|
|
<div>
|
|
<div class="dashboard-target-name">${escapeHtml(profile.name)}</div>
|
|
${condSummary ? `<div class="dashboard-target-subtitle">${escapeHtml(condSummary)}</div>` : ''}
|
|
<div class="dashboard-target-subtitle">${ICON_SCENE} ${sceneName}</div>
|
|
</div>
|
|
${statusBadge}
|
|
</div>
|
|
<div class="dashboard-target-actions">
|
|
<button class="dashboard-action-btn ${profile.enabled ? 'stop' : 'start'}" onclick="dashboardToggleProfile('${profile.id}', ${!profile.enabled})" title="${profile.enabled ? t('profiles.action.disable') : t('profiles.status.active')}">
|
|
${profile.enabled ? ICON_STOP_PLAIN : ICON_START}
|
|
</button>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
export async function dashboardToggleProfile(profileId, enable) {
|
|
try {
|
|
const endpoint = enable ? 'enable' : 'disable';
|
|
const response = await fetchWithAuth(`/profiles/${profileId}/${endpoint}`, {
|
|
method: 'POST',
|
|
});
|
|
if (response.ok) {
|
|
loadDashboard();
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('dashboard.error.profile_toggle_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
export async function dashboardStartTarget(targetId) {
|
|
try {
|
|
const response = await fetchWithAuth(`/picture-targets/${targetId}/start`, {
|
|
method: 'POST',
|
|
});
|
|
if (response.ok) {
|
|
showToast(t('device.started'), 'success');
|
|
loadDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(t('dashboard.error.start_failed'), 'error');
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('dashboard.error.start_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
export async function dashboardStopTarget(targetId) {
|
|
try {
|
|
const response = await fetchWithAuth(`/picture-targets/${targetId}/stop`, {
|
|
method: 'POST',
|
|
});
|
|
if (response.ok) {
|
|
showToast(t('device.stopped'), 'success');
|
|
loadDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(t('dashboard.error.stop_failed'), 'error');
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('dashboard.error.stop_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
export async function dashboardToggleAutoStart(targetId, enable) {
|
|
try {
|
|
const response = await fetchWithAuth(`/picture-targets/${targetId}`, {
|
|
method: 'PUT',
|
|
body: JSON.stringify({ auto_start: enable }),
|
|
});
|
|
if (response.ok) {
|
|
showToast(t(enable ? 'autostart.toggle.enabled' : 'autostart.toggle.disabled'), 'success');
|
|
loadDashboard();
|
|
} else {
|
|
const error = await response.json();
|
|
showToast(t('dashboard.error.autostart_toggle_failed'), 'error');
|
|
}
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('dashboard.error.autostart_toggle_failed'), 'error');
|
|
}
|
|
}
|
|
|
|
export async function dashboardStopAll() {
|
|
try {
|
|
const [targetsResp, statesResp] = await Promise.all([
|
|
fetchWithAuth('/picture-targets'),
|
|
fetchWithAuth('/picture-targets/batch/states'),
|
|
]);
|
|
const data = await targetsResp.json();
|
|
const statesData = statesResp.ok ? await statesResp.json() : { states: {} };
|
|
const states = statesData.states || {};
|
|
const running = (data.targets || []).filter(t => states[t.id]?.processing);
|
|
await Promise.all(running.map(t =>
|
|
fetchWithAuth(`/picture-targets/${t.id}/stop`, { method: 'POST' }).catch(() => {})
|
|
));
|
|
loadDashboard();
|
|
} catch (error) {
|
|
if (error.isAuth) return;
|
|
showToast(t('dashboard.error.stop_all'), 'error');
|
|
}
|
|
}
|
|
|
|
export function stopUptimeTimer() {
|
|
_stopUptimeTimer();
|
|
}
|
|
|
|
// React to global server events when dashboard tab is active
|
|
function _isDashboardActive() {
|
|
return (localStorage.getItem('activeTab') || 'dashboard') === 'dashboard';
|
|
}
|
|
|
|
let _eventDebounceTimer = null;
|
|
function _debouncedDashboardReload(forceFullRender = false) {
|
|
if (!_isDashboardActive()) return;
|
|
clearTimeout(_eventDebounceTimer);
|
|
_eventDebounceTimer = setTimeout(() => loadDashboard(forceFullRender), 300);
|
|
}
|
|
|
|
document.addEventListener('server:state_change', () => _debouncedDashboardReload());
|
|
document.addEventListener('server:profile_state_changed', () => _debouncedDashboardReload(true));
|
|
|
|
// Re-render dashboard when language changes
|
|
document.addEventListener('languageChanged', () => {
|
|
if (!apiKey) return;
|
|
// Force perf section rebuild with new locale
|
|
const perfEl = document.querySelector('.dashboard-perf-persistent');
|
|
if (perfEl) perfEl.remove();
|
|
loadDashboard();
|
|
});
|
|
|
|
// Update FPS chart colors when accent color changes
|
|
document.addEventListener('accentColorChanged', () => {
|
|
const accent = _getAccentColor();
|
|
for (const chart of Object.values(_fpsCharts)) {
|
|
if (!chart) continue;
|
|
chart.data.datasets[0].borderColor = accent;
|
|
chart.data.datasets[0].backgroundColor = accent + '1f';
|
|
chart.data.datasets[1].borderColor = accent + '80';
|
|
chart.update();
|
|
}
|
|
});
|
|
|
|
// Pause uptime timer when browser tab is hidden, resume when visible
|
|
document.addEventListener('visibilitychange', () => {
|
|
if (document.hidden) {
|
|
_stopUptimeTimer();
|
|
} else if (_isDashboardActive() && _lastRunningIds.length > 0) {
|
|
_cacheUptimeElements();
|
|
_startUptimeTimer();
|
|
}
|
|
});
|