From 6395709bb8dc6463882f5e79c1ba30d124d41ef9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 14 Mar 2026 21:12:12 +0300 Subject: [PATCH] Unify graph docking, fix device hot-switch, and compact UI cards - Unify minimap/toolbar/legend drag+dock into shared _makeDraggable() helper - Persist legend visibility and position, add active state to toggle buttons - Show custom colors only on graph cards (entity defaults remain in legend) - Replace emoji overlay buttons with SVG path icons - Fix stale is_running blocking target start (auto-clear if task is done) - Resolve device/target IDs to names in conflict error messages - Hot-switch LED device on running target via async stop-swap-start cycle - Compact automation dashboard cards and fix time_of_day localization - Inline CSS source pill on target cards to save vertical space Co-Authored-By: Claude Opus 4.6 --- .../api/routes/output_targets.py | 15 +- .../core/processing/processor_manager.py | 43 +++- .../static/css/graph-editor.css | 30 ++- .../static/js/core/graph-nodes.js | 24 +- .../static/js/features/automations.js | 7 +- .../static/js/features/graph-editor.js | 233 +++++++++--------- .../static/js/features/targets.js | 2 +- .../storage/wled_output_target.py | 9 +- 8 files changed, 224 insertions(+), 139 deletions(-) diff --git a/server/src/wled_controller/api/routes/output_targets.py b/server/src/wled_controller/api/routes/output_targets.py index d83360e..b57d99f 100644 --- a/server/src/wled_controller/api/routes/output_targets.py +++ b/server/src/wled_controller/api/routes/output_targets.py @@ -315,12 +315,18 @@ async def update_target( data.adaptive_fps is not None or data.key_colors_settings is not None), css_changed=data.color_strip_source_id is not None, - device_changed=data.device_id is not None, brightness_vs_changed=(data.brightness_value_source_id is not None or kc_brightness_vs_changed), ) except ValueError: pass + # Device change requires async stop → swap → start cycle + if data.device_id is not None: + try: + await manager.update_target_device(target_id, target.device_id) + except ValueError: + pass + fire_entity_event("output_target", "updated", target_id) return _target_to_response(target) @@ -389,7 +395,12 @@ async def start_processing( except ValueError as e: raise HTTPException(status_code=404, detail=str(e)) except RuntimeError as e: - raise HTTPException(status_code=409, detail=str(e)) + # Resolve target IDs to human-readable names in error messages + msg = str(e) + for t in target_store.get_all_targets(): + if t.id in msg: + msg = msg.replace(t.id, f"'{t.name}'") + raise HTTPException(status_code=409, detail=msg) except Exception as e: logger.error(f"Failed to start processing: {e}") raise HTTPException(status_code=500, detail=str(e)) diff --git a/server/src/wled_controller/core/processing/processor_manager.py b/server/src/wled_controller/core/processing/processor_manager.py index f4dcf1a..3aff4e2 100644 --- a/server/src/wled_controller/core/processing/processor_manager.py +++ b/server/src/wled_controller/core/processing/processor_manager.py @@ -465,13 +465,33 @@ class ProcessorManager: proc = self._get_processor(target_id) proc.update_css_source(color_strip_source_id) - def update_target_device(self, target_id: str, device_id: str): - """Update the device for a target.""" + async def update_target_device(self, target_id: str, device_id: str): + """Update the device for a target. + + If the target is currently running, performs a stop → swap → start + cycle so the new device connection is established properly. + """ proc = self._get_processor(target_id) if device_id not in self._devices: raise ValueError(f"Device {device_id} not registered") + + was_running = proc.is_running + if was_running: + logger.info( + "Hot-switching device for running target %s: stopping first", + target_id, + ) + await self.stop_processing(target_id) + proc.update_device(device_id) + if was_running: + await self.start_processing(target_id) + logger.info( + "Hot-switch complete for target %s → device %s", + target_id, device_id, + ) + def update_target_brightness_vs(self, target_id: str, vs_id: str): """Update the brightness value source for a WLED target.""" proc = self._get_processor(target_id) @@ -495,8 +515,25 @@ class ProcessorManager: and other.device_id == proc.device_id and other.is_running ): + # Stale state guard: if the task is actually finished, + # clean up and allow starting instead of blocking. + task = getattr(other, "_task", None) + if task is not None and task.done(): + logger.warning( + "Processor %s had stale is_running=True (task done) — clearing", + other_id, + ) + other._is_running = False + continue + + dev_name = proc.device_id + tgt_name = other_id + if self._device_store: + dev = self._device_store.get_device(proc.device_id) + if dev: + dev_name = dev.name raise RuntimeError( - f"Device {proc.device_id} is already being processed by target {other_id}" + f"Device '{dev_name}' is already being processed by target {tgt_name}" ) # Close cached idle client — processor creates its own connection diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index 59dc12b..f563e54 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -83,27 +83,44 @@ .graph-legend { position: absolute; top: 12px; - right: 12px; z-index: 20; background: var(--card-bg); border: 1px solid var(--border-color); border-radius: 8px; - padding: 10px 14px; box-shadow: 0 2px 8px var(--shadow-color); font-size: 0.8rem; display: none; - max-height: 60vh; - overflow-y: auto; + overflow: hidden; } .graph-legend.visible { display: block; } +.graph-legend-header { + display: flex; + align-items: center; + padding: 4px 10px; + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + cursor: grab; + user-select: none; +} + +.graph-legend-header.dragging { + cursor: grabbing; +} + .graph-legend-title { font-weight: 600; - margin-bottom: 6px; - color: var(--text-color); + font-size: 0.7rem; + color: var(--text-muted); +} + +.graph-legend-body { + padding: 6px 12px; + max-height: 55vh; + overflow-y: auto; } .graph-legend-item { @@ -112,6 +129,7 @@ gap: 8px; padding: 2px 0; color: var(--text-secondary); + white-space: nowrap; } .graph-legend-dot { diff --git a/server/src/wled_controller/static/js/core/graph-nodes.js b/server/src/wled_controller/static/js/core/graph-nodes.js index d4c007c..67e86c4 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.js +++ b/server/src/wled_controller/static/js/core/graph-nodes.js @@ -91,14 +91,20 @@ function _saveNodeColor(nodeId, color) { localStorage.setItem(_NC_KEY, JSON.stringify(map)); } -export function getNodeColor(nodeId, kind) { +/** Return custom color for a node, or null if none set. */ +export function getNodeColor(nodeId) { const map = _loadNodeColors(); - return map[nodeId] || ENTITY_COLORS[kind] || '#666'; + return map[nodeId] || null; +} + +/** Return color for a node: custom if set, else entity-type default. Used by minimap/search. */ +export function getNodeDisplayColor(nodeId, kind) { + return getNodeColor(nodeId) || ENTITY_COLORS[kind] || '#666'; } function renderNode(node, callbacks) { const { id, kind, name, subtype, x, y, width, height, running } = node; - const color = getNodeColor(id, kind); + let color = getNodeColor(id); const g = svgEl('g', { class: `graph-node${running ? ' running' : ''}`, @@ -116,21 +122,23 @@ function renderNode(node, callbacks) { }); g.appendChild(body); - // Color bar (left strip) + // Color bar (left strip) — only visible when user has set a custom color const bar = svgEl('rect', { class: 'graph-node-color-bar', x: 0, y: 0, width: 6, height, rx: 8, ry: 8, - fill: color, + fill: color || 'transparent', }); + if (!color) bar.style.display = 'none'; g.appendChild(bar); // Cover the right side rounded corners of the bar const barCover = svgEl('rect', { x: 3, y: 0, width: 4, height, - fill: color, + fill: color || 'transparent', }); + if (!color) barCover.style.display = 'none'; g.appendChild(barCover); // Clickable color bar overlay (wider hit area) @@ -165,7 +173,7 @@ function renderNode(node, callbacks) { overlay.style.cssText = `position:absolute; left:${px}px; top:${py}px; z-index:100;`; overlay.innerHTML = createColorPicker({ id: pickerId, - currentColor: color, + currentColor: color || ENTITY_COLORS[kind] || '#666', anchor: 'left', }); container.appendChild(overlay); @@ -175,6 +183,8 @@ function renderNode(node, callbacks) { color = hex; bar.setAttribute('fill', hex); barCover.setAttribute('fill', hex); + bar.style.display = ''; + barCover.style.display = ''; _saveNodeColor(id, hex); overlay.remove(); }); diff --git a/server/src/wled_controller/static/js/features/automations.js b/server/src/wled_controller/static/js/features/automations.js index 09e5b78..28c27ef 100644 --- a/server/src/wled_controller/static/js/features/automations.js +++ b/server/src/wled_controller/static/js/features/automations.js @@ -151,7 +151,7 @@ function createAutomationCard(automation, sceneMap = new Map()) { return `${t('automations.condition.application')}: ${apps} (${matchLabel})`; } if (c.condition_type === 'time_of_day') { - return `${ICON_CLOCK} ${c.start_time || '00:00'} – ${c.end_time || '23:59'}`; + return `${ICON_CLOCK} ${t('automations.condition.time_of_day')}: ${c.start_time || '00:00'} – ${c.end_time || '23:59'}`; } if (c.condition_type === 'system_idle') { const mode = c.when_idle !== false ? t('automations.condition.system_idle.when_idle') : t('automations.condition.system_idle.when_active'); @@ -207,12 +207,9 @@ function createAutomationCard(automation, sceneMap = new Map()) {
- ${automation.condition_logic === 'and' ? t('automations.logic.all') : t('automations.logic.any')} + ${condPills} ${ICON_SCENE} ${sceneName} - ${deactivationLabel ? `${deactivationLabel}` : ''} - ${lastActivityMeta}
-
${condPills}
${renderTagChips(automation.tags)}`, actions: ` diff --git a/server/src/wled_controller/static/js/features/graph-editor.js b/server/src/wled_controller/static/js/features/graph-editor.js index cc61de2..6ec7af2 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -4,7 +4,7 @@ import { GraphCanvas } from '../core/graph-canvas.js'; import { computeLayout, computePorts, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js'; -import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeColor } from '../core/graph-nodes.js'; +import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection, getNodeDisplayColor } from '../core/graph-nodes.js'; import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js'; import { devicesCache, captureTemplatesCache, ppTemplatesCache, @@ -24,7 +24,7 @@ let _edges = null; let _bounds = null; let _selectedIds = new Set(); let _initialized = false; -let _legendVisible = false; +let _legendVisible = (() => { try { return localStorage.getItem('graph_legend_visible') === '1'; } catch { return false; } })(); let _minimapVisible = true; let _searchVisible = false; let _searchIndex = -1; @@ -103,16 +103,6 @@ function _applyAnchor(el, container, saved) { /** True when the graph container is in fullscreen — suppress anchor persistence. */ function _isFullscreen() { return !!document.fullscreenElement; } -/** Shorthand wrappers for minimap / toolbar (skip save in fullscreen). */ -function _saveMinimapAnchored(el, container) { - if (_isFullscreen()) return; - return _saveAnchored(el, container, _saveMinimapRect); -} -function _saveToolbarAnchored(el, container) { - if (_isFullscreen()) return; - return _saveAnchored(el, container, _saveToolbarPos); -} - // Toolbar position persisted in localStorage const _TB_KEY = 'graph_toolbar'; function _loadToolbarPos() { @@ -122,6 +112,63 @@ function _saveToolbarPos(r) { localStorage.setItem(_TB_KEY, JSON.stringify(r)); } +// Legend position persisted in localStorage +const _LG_KEY = 'graph_legend'; +function _loadLegendPos() { + try { return JSON.parse(localStorage.getItem(_LG_KEY)); } catch { return null; } +} +function _saveLegendPos(r) { + localStorage.setItem(_LG_KEY, JSON.stringify(r)); +} + +/** + * Generic draggable panel setup. + * @param {HTMLElement} el - The panel element + * @param {HTMLElement} handle - The drag handle element + * @param {object} opts - { loadFn, saveFn } + */ +function _makeDraggable(el, handle, { loadFn, saveFn }) { + if (!el || !handle) return; + const container = el.closest('.graph-container'); + if (!container) return; + + // Apply saved anchor position or clamp + const saved = loadFn(); + if (saved?.anchor) { + _applyAnchor(el, container, saved); + } else { + _clampElementInContainer(el, container); + } + + let dragStart = null, dragStartPos = null; + + handle.addEventListener('pointerdown', (e) => { + e.preventDefault(); + dragStart = { x: e.clientX, y: e.clientY }; + dragStartPos = { left: el.offsetLeft, top: el.offsetTop }; + handle.classList.add('dragging'); + handle.setPointerCapture(e.pointerId); + }); + handle.addEventListener('pointermove', (e) => { + if (!dragStart) return; + const cr = container.getBoundingClientRect(); + const ew = el.offsetWidth, eh = el.offsetHeight; + let l = dragStartPos.left + (e.clientX - dragStart.x); + let t = dragStartPos.top + (e.clientY - dragStart.y); + l = Math.max(0, Math.min(cr.width - ew, l)); + t = Math.max(0, Math.min(cr.height - eh, t)); + el.style.left = l + 'px'; + el.style.top = t + 'px'; + }); + handle.addEventListener('pointerup', () => { + if (dragStart) { + dragStart = null; + handle.classList.remove('dragging'); + if (!_isFullscreen()) _saveAnchored(el, container, saveFn); + } + }); +} + /* ── Public API ── */ export async function loadGraphEditor() { @@ -183,14 +230,34 @@ export function closeGraphSearch() { export function toggleGraphLegend() { _legendVisible = !_legendVisible; + try { localStorage.setItem('graph_legend_visible', _legendVisible ? '1' : '0'); } catch {} const legend = document.querySelector('.graph-legend'); - if (legend) legend.classList.toggle('visible', _legendVisible); + if (!legend) return; + legend.classList.toggle('visible', _legendVisible); + const legendBtn = document.getElementById('graph-legend-toggle'); + if (legendBtn) legendBtn.classList.toggle('active', _legendVisible); + if (_legendVisible) { + const container = legend.closest('.graph-container'); + if (container) { + const saved = _loadLegendPos(); + if (saved?.anchor) { + _applyAnchor(legend, container, saved); + } else if (!legend.style.left) { + // Default to top-right + const cr = container.getBoundingClientRect(); + legend.style.left = (cr.width - legend.offsetWidth - 12) + 'px'; + legend.style.top = '12px'; + } + } + } } export function toggleGraphMinimap() { _minimapVisible = !_minimapVisible; const mm = document.querySelector('.graph-minimap'); if (mm) mm.classList.toggle('visible', _minimapVisible); + const mmBtn = document.getElementById('graph-minimap-toggle'); + if (mmBtn) mmBtn.classList.toggle('active', _minimapVisible); } export function toggleGraphFilter() { @@ -507,7 +574,9 @@ function _renderGraph(container) { _updateMinimapViewport(container.querySelector('.graph-minimap'), vp); }; - _renderLegend(container.querySelector('.graph-legend')); + const legendEl = container.querySelector('.graph-legend'); + _renderLegend(legendEl); + _initLegendDrag(legendEl); _initMinimap(container.querySelector('.graph-minimap')); _initToolbarDrag(container.querySelector('.graph-toolbar')); _initResizeClamp(container); @@ -627,10 +696,10 @@ function _graphHTML() { - - @@ -646,8 +715,11 @@ function _graphHTML() { -
-
${t('graph.legend')}
+
+
+ ${t('graph.legend')} +
+
@@ -691,7 +763,9 @@ function _graphHTML() { function _renderLegend(legendEl) { if (!legendEl) return; - let html = `
${t('graph.legend')}
`; + const body = legendEl.querySelector('.graph-legend-body'); + if (!body) return; + let html = ''; for (const [kind, color] of Object.entries(ENTITY_COLORS)) { const label = ENTITY_LABELS[kind] || kind; html += `
@@ -699,7 +773,13 @@ function _renderLegend(legendEl) { ${label}
`; } - legendEl.innerHTML = html; + body.innerHTML = html; +} + +function _initLegendDrag(legendEl) { + if (!legendEl) return; + const handle = legendEl.querySelector('.graph-legend-header'); + _makeDraggable(legendEl, handle, { loadFn: _loadLegendPos, saveFn: _saveLegendPos }); } /* ── Minimap (draggable header & resize handle) ── */ @@ -716,7 +796,7 @@ function _initMinimap(mmEl) { let html = ''; for (const node of _nodeMap.values()) { - const color = getNodeColor(node.id, node.kind); + const color = getNodeDisplayColor(node.id, node.kind); html += ``; } // Add viewport rect (updated live via _updateMinimapViewport) @@ -763,31 +843,9 @@ function _initMinimap(mmEl) { }); svg.addEventListener('pointerup', () => { mmDraggingViewport = false; }); - // ── Drag via header ── + // ── Drag via header (uses shared _makeDraggable) ── const header = mmEl.querySelector('.graph-minimap-header'); - let dragStart = null, dragStartPos = null; - - header.addEventListener('pointerdown', (e) => { - e.preventDefault(); - dragStart = { x: e.clientX, y: e.clientY }; - dragStartPos = { left: mmEl.offsetLeft, top: mmEl.offsetTop }; - header.classList.add('dragging'); - header.setPointerCapture(e.pointerId); - }); - header.addEventListener('pointermove', (e) => { - if (!dragStart) return; - const cr = container.getBoundingClientRect(); - const mw = mmEl.offsetWidth, mh = mmEl.offsetHeight; - let l = dragStartPos.left + (e.clientX - dragStart.x); - let t = dragStartPos.top + (e.clientY - dragStart.y); - l = Math.max(0, Math.min(cr.width - mw, l)); - t = Math.max(0, Math.min(cr.height - mh, t)); - mmEl.style.left = l + 'px'; - mmEl.style.top = t + 'px'; - }); - header.addEventListener('pointerup', () => { - if (dragStart) { dragStart = null; header.classList.remove('dragging'); _saveMinimapAnchored(mmEl, container); } - }); + _makeDraggable(mmEl, header, { loadFn: () => null, saveFn: _saveMinimapRect }); // ── Resize handles ── _initResizeHandle(mmEl.querySelector('.graph-minimap-resize-br'), 'br'); @@ -824,7 +882,7 @@ function _initMinimap(mmEl) { } _clampMinimap(); }); - rh.addEventListener('pointerup', () => { if (rs) { rs = null; _saveMinimapAnchored(mmEl, container); } }); + rh.addEventListener('pointerup', () => { if (rs) { rs = null; if (!_isFullscreen()) _saveAnchored(mmEl, container, _saveMinimapRect); } }); } } @@ -873,37 +931,26 @@ function _clampElementInContainer(el, container) { let _resizeObserver = null; +function _reanchorPanel(el, container, loadFn) { + if (!el) return; + if (_isFullscreen()) { + _clampElementInContainer(el, container); + } else { + const saved = loadFn(); + if (saved?.anchor) { + _applyAnchor(el, container, saved); + } else { + _clampElementInContainer(el, container); + } + } +} + function _initResizeClamp(container) { if (_resizeObserver) _resizeObserver.disconnect(); _resizeObserver = new ResizeObserver(() => { - // In fullscreen, just clamp — don't re-anchor from normal-mode saved data - const fs = _isFullscreen(); - const mm = container.querySelector('.graph-minimap'); - const tb = container.querySelector('.graph-toolbar'); - if (mm) { - if (fs) { - _clampElementInContainer(mm, container); - } else { - const saved = _loadMinimapRect(); - if (saved?.anchor) { - _applyAnchor(mm, container, saved); - } else { - _clampElementInContainer(mm, container); - } - } - } - if (tb) { - if (fs) { - _clampElementInContainer(tb, container); - } else { - const saved = _loadToolbarPos(); - if (saved?.anchor) { - _applyAnchor(tb, container, saved); - } else { - _clampElementInContainer(tb, container); - } - } - } + _reanchorPanel(container.querySelector('.graph-minimap'), container, _loadMinimapRect); + _reanchorPanel(container.querySelector('.graph-toolbar'), container, _loadToolbarPos); + _reanchorPanel(container.querySelector('.graph-legend.visible'), container, _loadLegendPos); }); _resizeObserver.observe(container); } @@ -912,44 +959,8 @@ function _initResizeClamp(container) { function _initToolbarDrag(tbEl) { if (!tbEl) return; - const container = tbEl.closest('.graph-container'); - - // Apply saved anchor position or clamp - const saved = _loadToolbarPos(); - if (saved?.anchor) { - _applyAnchor(tbEl, container, saved); - } else { - _clampElementInContainer(tbEl, container); - } - const handle = tbEl.querySelector('.graph-toolbar-drag'); - if (!handle) return; - - let dragStart = null, dragStartPos = null; - - handle.addEventListener('pointerdown', (e) => { - e.preventDefault(); - dragStart = { x: e.clientX, y: e.clientY }; - dragStartPos = { left: tbEl.offsetLeft, top: tbEl.offsetTop }; - handle.setPointerCapture(e.pointerId); - }); - handle.addEventListener('pointermove', (e) => { - if (!dragStart) return; - const cr = container.getBoundingClientRect(); - const tw = tbEl.offsetWidth, th = tbEl.offsetHeight; - let l = dragStartPos.left + (e.clientX - dragStart.x); - let t = dragStartPos.top + (e.clientY - dragStart.y); - l = Math.max(0, Math.min(cr.width - tw, l)); - t = Math.max(0, Math.min(cr.height - th, t)); - tbEl.style.left = l + 'px'; - tbEl.style.top = t + 'px'; - }); - handle.addEventListener('pointerup', () => { - if (dragStart) { - dragStart = null; - _saveToolbarAnchored(tbEl, container); - } - }); + _makeDraggable(tbEl, handle, { loadFn: _loadToolbarPos, saveFn: _saveToolbarPos }); } /* ── Search ── */ diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index 81af8a0..90e4412 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -1011,7 +1011,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo ${ICON_LED} ${escapeHtml(deviceName)} ${ICON_FPS} ${target.fps || 30} ${_protocolBadge(device, target)} - ${ICON_FILM} ${cssSummary} + ${ICON_FILM} ${cssSummary} ${bvs ? `${getValueSourceIcon(bvs.source_type)} ${escapeHtml(bvs.name)}` : ''} ${target.min_brightness_threshold > 0 ? `${ICON_SUN_DIM} <${target.min_brightness_threshold} → off` : ''}
diff --git a/server/src/wled_controller/storage/wled_output_target.py b/server/src/wled_controller/storage/wled_output_target.py index 5268f50..a218a05 100644 --- a/server/src/wled_controller/storage/wled_output_target.py +++ b/server/src/wled_controller/storage/wled_output_target.py @@ -42,9 +42,12 @@ class WledOutputTarget(OutputTarget): def sync_with_manager(self, manager, *, settings_changed: bool, css_changed: bool = False, - device_changed: bool = False, brightness_vs_changed: bool = False) -> None: - """Push changed fields to the processor manager.""" + """Push changed fields to the processor manager. + + NOTE: device_changed is handled separately in the route because + update_target_device is async (stop → swap → start cycle). + """ if settings_changed: manager.update_target_settings(self.id, { "fps": self.fps, @@ -55,8 +58,6 @@ class WledOutputTarget(OutputTarget): }) if css_changed: manager.update_target_css(self.id, self.color_strip_source_id) - if device_changed: - manager.update_target_device(self.id, self.device_id) if brightness_vs_changed: manager.update_target_brightness_vs(self.id, self.brightness_value_source_id)