Replace auto-start with startup automation, add card colors to dashboard

- Add `startup` automation condition type that activates on server boot,
  replacing the per-target `auto_start` flag
- Remove `auto_start` field from targets, scene snapshots, and all API layers
- Remove auto-start UI section and star buttons from dashboard and target cards
- Remove `color` field from scene presets (backend, API, modal, frontend)
- Add card color support to scene preset cards (color picker + border style)
- Show localStorage-backed card colors on all dashboard cards (targets,
  automations, sync clocks, scene presets)
- Fix card color picker updating wrong card when duplicate data attributes
  exist by using closest() from picker wrapper instead of global querySelector
- Add sync clocks step to Sources tab tutorial
- Bump SW cache v9 → v10

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 01:09:27 +03:00
parent f08117eb7b
commit fddbd771f2
28 changed files with 78 additions and 211 deletions

View File

@@ -10,9 +10,10 @@ import { renderPerfSection, initPerfCharts, startPerfPolling, stopPerfPolling }
import { startAutoRefresh, updateTabBadge } from './tabs.js';
import {
ICON_TARGET, ICON_AUTOMATION, ICON_CLOCK, ICON_WARNING, ICON_OK,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_AUTOSTART, ICON_HELP, ICON_SCENE,
ICON_STOP, ICON_STOP_PLAIN, ICON_START, ICON_PAUSE, ICON_HELP, ICON_SCENE,
} from '../core/icons.js';
import { loadScenePresets, renderScenePresetsSection } from './scene-presets.js';
import { cardColorStyle } from '../core/card-colors.js';
const DASHBOARD_COLLAPSED_KEY = 'dashboard_collapsed';
const MAX_FPS_SAMPLES = 120;
@@ -21,7 +22,6 @@ 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 _lastSyncClockIds = ''; // comma-joined sorted sync clock IDs
let _uptimeBase = {}; // { targetId: { seconds, timestamp } }
let _uptimeTimer = null;
@@ -308,7 +308,8 @@ function renderDashboardSyncClock(clock) {
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}')}">
const scStyle = cardColorStyle(clock.id);
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}')}"${scStyle ? ` style="${scStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_CLOCK}</span>
<div>
@@ -447,8 +448,6 @@ export async function loadDashboard(forceFullRender = false) {
// Build dynamic HTML (targets, automations)
let dynamicHtml = '';
let runningIds = [];
let newAutoStartIds = '';
if (targets.length === 0 && automations.length === 0 && scenePresets.length === 0 && syncClocks.length === 0) {
dynamicHtml = `<div class="dashboard-no-targets">${t('dashboard.no_targets')}</div>`;
} else {
@@ -465,10 +464,9 @@ export async function loadDashboard(forceFullRender = false) {
// 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 newSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
const hasExistingDom = !!container.querySelector('.dashboard-perf-persistent');
const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newAutoStartIds === _lastAutoStartIds && newSyncClockIds === _lastSyncClockIds;
const structureUnchanged = hasExistingDom && newRunningIds === prevRunningIds && newSyncClockIds === _lastSyncClockIds;
if (structureUnchanged && !forceFullRender && running.length > 0) {
_updateRunningMetrics(running);
_updateSyncClocksInPlace(syncClocks);
@@ -489,52 +487,6 @@ export async function loadDashboard(forceFullRender = 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('automations.status.active')}</span>`
: `<span class="dashboard-badge-stopped">${t('automations.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 (automations.length > 0) {
const activeAutomations = automations.filter(a => a.is_active);
const inactiveAutomations = automations.filter(a => !a.is_active);
@@ -617,7 +569,6 @@ export async function loadDashboard(forceFullRender = false) {
}
}
_lastRunningIds = runningIds;
_lastAutoStartIds = newAutoStartIds;
_lastSyncClockIds = syncClocks.map(c => `${c.id}:${c.is_running}`).sort().join(',');
_cacheUptimeElements();
await _initFpsCharts(runningIds);
@@ -683,7 +634,8 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
healthDot = `<span class="health-dot ${cls}"></span>`;
}
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}">
const cStyle = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" data-target-id="${target.id}" onclick="${navOnclick}"${cStyle ? ` style="${cStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
@@ -708,12 +660,12 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
</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')}">&#x2605;</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}">
const cStyle2 = cardColorStyle(target.id);
return `<div class="dashboard-target dashboard-card-link" onclick="${navOnclick}"${cStyle2 ? ` style="${cStyle2}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${icon}</span>
<div>
@@ -723,7 +675,6 @@ function renderDashboardTarget(target, isRunning, devicesMap = {}, cssSourceMap
</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')}">&#x2605;</button>
<button class="dashboard-action-btn start" onclick="dashboardStartTarget('${target.id}')" title="${t('device.button.start')}">${ICON_START}</button>
</div>
</div>`;
@@ -742,6 +693,7 @@ function renderDashboardAutomation(automation, sceneMap = new Map()) {
const matchLabel = c.match_type === 'topmost' ? t('automations.condition.application.match_type.topmost') : t('automations.condition.application.match_type.running');
return `${apps} (${matchLabel})`;
}
if (c.condition_type === 'startup') return t('automations.condition.startup');
return c.condition_type;
});
const logic = automation.condition_logic === 'and' ? ' & ' : ' | ';
@@ -758,7 +710,8 @@ function renderDashboardAutomation(automation, sceneMap = new Map()) {
const scene = automation.scene_preset_id ? sceneMap.get(automation.scene_preset_id) : null;
const sceneName = scene ? escapeHtml(scene.name) : t('automations.scene.none_selected');
return `<div class="dashboard-target dashboard-automation dashboard-card-link" data-automation-id="${automation.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'automations','data-automation-id','${automation.id}')}">
const aStyle = cardColorStyle(automation.id);
return `<div class="dashboard-target dashboard-automation dashboard-card-link" data-automation-id="${automation.id}" onclick="if(!event.target.closest('button')){navigateToCard('automations',null,'automations','data-automation-id','${automation.id}')}"${aStyle ? ` style="${aStyle}"` : ''}>
<div class="dashboard-target-info">
<span class="dashboard-target-icon">${ICON_AUTOMATION}</span>
<div>
@@ -827,25 +780,6 @@ export async function dashboardStopTarget(targetId) {
}
}
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(error.detail || 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([