Add sync clock cards to dashboard and match FPS chart colors

Sync clocks now appear as compact cards on the dashboard with
pause/resume/reset controls and click-to-navigate. Dashboard FPS
sparkline charts use the same blue/green colors as target card charts.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-01 22:26:29 +03:00
parent 39e41dfce7
commit f08117eb7b
6 changed files with 123 additions and 26 deletions

View File

@@ -37,6 +37,7 @@ import {
import { import {
loadDashboard, stopUptimeTimer, loadDashboard, stopUptimeTimer,
dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardToggleAutoStart, dashboardStopAll, dashboardToggleAutomation, dashboardStartTarget, dashboardStopTarget, dashboardToggleAutoStart, dashboardStopAll,
dashboardPauseClock, dashboardResumeClock, dashboardResetClock,
toggleDashboardSection, changeDashboardPollInterval, toggleDashboardSection, changeDashboardPollInterval,
} from './features/dashboard.js'; } from './features/dashboard.js';
import { startEventsWS, stopEventsWS } from './core/events-ws.js'; import { startEventsWS, stopEventsWS } from './core/events-ws.js';
@@ -212,6 +213,9 @@ Object.assign(window, {
dashboardStopTarget, dashboardStopTarget,
dashboardToggleAutoStart, dashboardToggleAutoStart,
dashboardStopAll, dashboardStopAll,
dashboardPauseClock,
dashboardResumeClock,
dashboardResetClock,
toggleDashboardSection, toggleDashboardSection,
changeDashboardPollInterval, changeDashboardPollInterval,
stopUptimeTimer, stopUptimeTimer,

View File

@@ -9,9 +9,8 @@ import { showToast, formatUptime, setTabRefreshing } from '../core/ui.js';
import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js'; import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling } from './perf-charts.js';
import { startAutoRefresh, updateTabBadge } from './tabs.js'; import { startAutoRefresh, updateTabBadge } from './tabs.js';
import { import {
getTargetTypeIcon,
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK, ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_AUTOSTART, ICON_HELP, ICON_SCENE, ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_AUTOSTART, ICON_HELP, ICON_SCENE,
} from '../core/icons.js'; } from '../core/icons.js';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js'; import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
@@ -23,6 +22,7 @@ let _fpsCurrentHistory = {}; // { targetId: number[] } — fps_current
let _fpsCharts = {}; // { targetId: Chart } let _fpsCharts = {}; // { targetId: Chart }
let _lastRunningIds = []; // sorted target IDs from previous render let _lastRunningIds = []; // sorted target IDs from previous render
let _lastAutoStartIds = ''; // comma-joined sorted auto-start IDs let _lastAutoStartIds = ''; // comma-joined sorted auto-start IDs
let _lastSyncClockIds = ''; // comma-joined sorted sync clock IDs
let _uptimeBase = {}; // { targetId: { seconds, timestamp } } let _uptimeBase = {}; // { targetId: { seconds, timestamp } }
let _uptimeTimer = null; let _uptimeTimer = null;
let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs let _uptimeElements = {}; // { targetId: HTMLElement } — cached DOM refs
@@ -87,14 +87,9 @@ function _destroyFpsCharts() {
_fpsCharts = {}; _fpsCharts = {};
} }
function _getAccentColor() {
return getComputedStyle(document.documentElement).getPropertyValue('--primary-color').trim() || '#4CAF50';
}
function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) { function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) {
const canvas = document.getElementById(canvasId); const canvas = document.getElementById(canvasId);
if (!canvas) return null; if (!canvas) return null;
const accent = _getAccentColor();
return new Chart(canvas, { return new Chart(canvas, {
type: 'line', type: 'line',
data: { data: {
@@ -102,8 +97,8 @@ function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) {
datasets: [ datasets: [
{ {
data: [...actualHistory], data: [...actualHistory],
borderColor: accent, borderColor: '#2196F3',
backgroundColor: accent + '1f', backgroundColor: 'rgba(33,150,243,0.12)',
borderWidth: 1.5, borderWidth: 1.5,
tension: 0.3, tension: 0.3,
fill: true, fill: true,
@@ -111,7 +106,7 @@ function _createFpsChart(canvasId, actualHistory, currentHistory, fpsTarget) {
}, },
{ {
data: [...currentHistory], data: [...currentHistory],
borderColor: accent + '80', borderColor: '#4CAF50',
borderWidth: 1.5, borderWidth: 1.5,
tension: 0.3, tension: 0.3,
fill: false, fill: false,
@@ -278,6 +273,60 @@ function _updateAutomationsInPlace(automations) {
} }
} }
function _updateSyncClocksInPlace(syncClocks) {
for (const c of syncClocks) {
const card = document.querySelector(`[data-sync-clock-id="${c.id}"]`);
if (!card) continue;
const speedEl = card.querySelector('.dashboard-clock-speed');
if (speedEl) speedEl.textContent = `${c.speed}x`;
const badge = card.querySelector('.dashboard-badge-active, .dashboard-badge-stopped');
if (badge) {
badge.className = c.is_running ? 'dashboard-badge-active' : 'dashboard-badge-stopped';
badge.textContent = c.is_running ? t('sync_clock.status.running') : t('sync_clock.status.paused');
}
const btn = card.querySelector('.dashboard-target-actions .dashboard-action-btn');
if (btn) {
btn.className = `dashboard-action-btn ${c.is_running ? 'stop' : 'start'}`;
btn.setAttribute('onclick', c.is_running ? `dashboardPauseClock('${c.id}')` : `dashboardResumeClock('${c.id}')`);
btn.innerHTML = c.is_running ? ICON_PAUSE : ICON_START;
}
}
}
function renderDashboardSyncClock(clock) {
const statusBadge = clock.is_running
? `<span class="dashboard-badge-active">${t('sync_clock.status.running')}</span>`
: `<span class="dashboard-badge-stopped">${t('sync_clock.status.paused')}</span>`;
const toggleAction = clock.is_running
? `dashboardPauseClock('${clock.id}')`
: `dashboardResumeClock('${clock.id}')`;
const toggleTitle = clock.is_running ? t('sync_clock.action.pause') : t('sync_clock.action.resume');
const subtitle = [
`<span class="dashboard-clock-speed">${clock.speed}x</span>`,
clock.description ? escapeHtml(clock.description) : '',
].filter(Boolean).join(' · ');
return `<div class="dashboard-target dashboard-autostart dashboard-card-link" data-sync-clock-id="${clock.id}" onclick="if(!event.target.closest('button')){navigateToCard('streams','sync','sync-clocks','data-id','${clock.id}')}">
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_CLOCK}</span>
<div>
<div class="dashboard-target-name">${escapeHtml(clock.name)} ${statusBadge}</div>
${subtitle ? `<div class="dashboard-target-subtitle">${subtitle}</div>` : ''}
</div>
</div>
<div class="dashboard-target-actions">
<button class="dashboard-action-btn ${clock.is_running ? 'stop' : 'start'}" onclick="${toggleAction}" title="${toggleTitle}">
${clock.is_running ? ICON_PAUSE : ICON_START}
</button>
<button class="dashboard-action-btn" onclick="dashboardResetClock('${clock.id}')" title="${t('sync_clock.action.reset')}">
${ICON_CLOCK}
</button>
</div>
</div>`;
}
function _renderPollIntervalSelect() { function _renderPollIntervalSelect() {
const sec = Math.round(dashboardPollInterval / 1000); 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>`; 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>`;
@@ -368,7 +417,7 @@ export async function loadDashboard(forceFullRender = false) {
try { try {
// Fire all requests in a single batch to avoid sequential RTTs // Fire all requests in a single batch to avoid sequential RTTs
const [targetsResp, automationsResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets] = await Promise.all([ const [targetsResp, automationsResp, devicesResp, cssResp, batchStatesResp, batchMetricsResp, scenePresets, syncClocksResp] = await Promise.all([
fetchWithAuth('/picture-targets'), fetchWithAuth('/picture-targets'),
fetchWithAuth('/automations').catch(() => null), fetchWithAuth('/automations').catch(() => null),
fetchWithAuth('/devices').catch(() => null), fetchWithAuth('/devices').catch(() => null),
@@ -376,6 +425,7 @@ export async function loadDashboard(forceFullRender = false) {
fetchWithAuth('/picture-targets/batch/states').catch(() => null), fetchWithAuth('/picture-targets/batch/states').catch(() => null),
fetchWithAuth('/picture-targets/batch/metrics').catch(() => null), fetchWithAuth('/picture-targets/batch/metrics').catch(() => null),
loadScenePresets(), loadScenePresets(),
fetchWithAuth('/sync-clocks').catch(() => null),
]); ]);
const targetsData = await targetsResp.json(); const targetsData = await targetsResp.json();
@@ -388,6 +438,8 @@ export async function loadDashboard(forceFullRender = false) {
const cssData = cssResp && cssResp.ok ? await cssResp.json() : { sources: [] }; const cssData = cssResp && cssResp.ok ? await cssResp.json() : { sources: [] };
const cssSourceMap = {}; const cssSourceMap = {};
for (const s of (cssData.sources || [])) { cssSourceMap[s.id] = s; } for (const s of (cssData.sources || [])) { cssSourceMap[s.id] = s; }
const syncClocksData = syncClocksResp && syncClocksResp.ok ? await syncClocksResp.json() : { clocks: [] };
const syncClocks = syncClocksData.clocks || [];
const allStates = batchStatesResp && batchStatesResp.ok ? (await batchStatesResp.json()).states : {}; const allStates = batchStatesResp && batchStatesResp.ok ? (await batchStatesResp.json()).states : {};
const allMetrics = batchMetricsResp && batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {}; const allMetrics = batchMetricsResp && batchMetricsResp.ok ? (await batchMetricsResp.json()).metrics : {};
@@ -397,7 +449,7 @@ export async function loadDashboard(forceFullRender = false) {
let runningIds = []; let runningIds = [];
let newAutoStartIds = ''; let newAutoStartIds = '';
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0) { if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`; dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} else { } else {
const enriched = targets.map(target => ({ const enriched = targets.map(target => ({
@@ -414,10 +466,12 @@ export async function loadDashboard(forceFullRender = false) {
const newRunningIds = running.map(t => t.id).sort().join(','); const newRunningIds = running.map(t => t.id).sort().join(',');
const prevRunningIds = [..._lastRunningIds].sort().join(','); const prevRunningIds = [..._lastRunningIds].sort().join(',');
newAutoStartIds = enriched.filter(t => t.auto_start).map(t => t.id).sort().join(','); newAutoStartIds = enriched.filter(t => t.auto_start).map(t => t.id).sort().join(',');
const newSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent'); const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newAutoStartIds === _lastAutoStartIds; const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newAutoStartIds === _lastAutoStartIds && newSyncClockIds === _lastSyncClockIds;
if (structureUnchanged && !forceFullRender && running.length > 0) { if (structureUnchanged && !forceFullRender && running.length > 0) {
_updateRunningMetrics(running); _updateRunningMetrics(running);
_updateSyncClocksInPlace(syncClocks);
_cacheUptimeElements(); _cacheUptimeElements();
_startUptimeTimer(); _startUptimeTimer();
startPerfPolling(); startPerfPolling();
@@ -427,6 +481,7 @@ export async function loadDashboard(forceFullRender = false) {
if (structureUnchanged && forceFullRender) { if (structureUnchanged && forceFullRender) {
if (running.length > 0) _updateRunningMetrics(running); if (running.length > 0) _updateRunningMetrics(running);
_updateAutomationsInPlace(automations); _updateAutomationsInPlace(automations);
_updateSyncClocksInPlace(syncClocks);
_cacheUptimeElements(); _cacheUptimeElements();
_startUptimeTimer(); _startUptimeTimer();
startPerfPolling(); startPerfPolling();
@@ -504,6 +559,16 @@ export async function loadDashboard(forceFullRender = false) {
} }
} }
// Sync Clocks section
if (syncClocks.length > 0) {
const clockCards = syncClocks.map(c => renderDashboardSyncClock(c)).join('');
const clockGrid = `<div class="dashboard-autostart-grid">${clockCards}</div>`;
dynamicHtml += `<div class="dashboard-section">
${_sectionHeader('sync-clocks', t('dashboard.section.sync_clocks'), syncClocks.length)}
${_sectionContent('sync-clocks', clockGrid)}
</div>`;
}
if (targets.length > 0) { if (targets.length > 0) {
let targetsInner = ''; let targetsInner = '';
@@ -553,6 +618,7 @@ export async function loadDashboard(forceFullRender = false) {
} }
_lastRunningIds = runningIds; _lastRunningIds = runningIds;
_lastAutoStartIds = newAutoStartIds; _lastAutoStartIds = newAutoStartIds;
_lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
_cacheUptimeElements(); _cacheUptimeElements();
await _initFpsCharts(runningIds); await _initFpsCharts(runningIds);
_startUptimeTimer(); _startUptimeTimer();
@@ -800,6 +866,42 @@ export async function dashboardStopAll() {
} }
} }
export async function dashboardPauseClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/pause`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.paused'), 'success');
loadDashboard(true);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function dashboardResumeClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/resume`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.resumed'), 'success');
loadDashboard(true);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export async function dashboardResetClock(clockId) {
try {
const resp = await fetchWithAuth(`/sync-clocks/${clockId}/reset`, { method: 'POST' });
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
showToast(t('sync_clock.reset_done'), 'success');
loadDashboard(true);
} catch (e) {
if (e.isAuth) return;
showToast(e.message, 'error');
}
}
export function stopUptimeTimer() { export function stopUptimeTimer() {
_stopUptimeTimer(); _stopUptimeTimer();
} }
@@ -828,18 +930,6 @@ document.addEventListener('languageChanged', () => {
loadDashboard(); 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 // Pause uptime timer when browser tab is hidden, resume when visible
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.hidden) { if (document.hidden) {

View File

@@ -556,6 +556,7 @@
"dashboard.failed": "Failed to load dashboard", "dashboard.failed": "Failed to load dashboard",
"dashboard.section.automations": "Automations", "dashboard.section.automations": "Automations",
"dashboard.section.scenes": "Scene Presets", "dashboard.section.scenes": "Scene Presets",
"dashboard.section.sync_clocks": "Sync Clocks",
"dashboard.targets": "Targets", "dashboard.targets": "Targets",
"dashboard.section.performance": "System Performance", "dashboard.section.performance": "System Performance",
"dashboard.perf.cpu": "CPU", "dashboard.perf.cpu": "CPU",

View File

@@ -556,6 +556,7 @@
"dashboard.failed": "Не удалось загрузить обзор", "dashboard.failed": "Не удалось загрузить обзор",
"dashboard.section.automations": "Автоматизации", "dashboard.section.automations": "Автоматизации",
"dashboard.section.scenes": "Пресеты сцен", "dashboard.section.scenes": "Пресеты сцен",
"dashboard.section.sync_clocks": "Синхронные часы",
"dashboard.targets": "Цели", "dashboard.targets": "Цели",
"dashboard.section.performance": "Производительность системы", "dashboard.section.performance": "Производительность системы",
"dashboard.perf.cpu": "ЦП", "dashboard.perf.cpu": "ЦП",

View File

@@ -556,6 +556,7 @@
"dashboard.failed": "加载仪表盘失败", "dashboard.failed": "加载仪表盘失败",
"dashboard.section.automations": "自动化", "dashboard.section.automations": "自动化",
"dashboard.section.scenes": "场景预设", "dashboard.section.scenes": "场景预设",
"dashboard.section.sync_clocks": "同步时钟",
"dashboard.targets": "目标", "dashboard.targets": "目标",
"dashboard.section.performance": "系统性能", "dashboard.section.performance": "系统性能",
"dashboard.perf.cpu": "CPU", "dashboard.perf.cpu": "CPU",

View File

@@ -7,7 +7,7 @@
* - Navigation: network-first with offline fallback * - Navigation: network-first with offline fallback
*/ */
const CACHE_NAME = 'ledgrab-v8'; const CACHE_NAME = 'ledgrab-v9';
// Only pre-cache static assets (no auth required). // Only pre-cache static assets (no auth required).
// Do NOT pre-cache '/' — it requires API key auth and would cache an error page. // Do NOT pre-cache '/' — it requires API key auth and would cache an error page.