From 7902d2e1f9b9afe124a6f6e82ddd8806c8650d09 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 13 Mar 2026 15:56:19 +0300 Subject: [PATCH] Add start/stop, test, and notification buttons to graph editor node overlays MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Output targets and sync clocks get start/stop (▶/■) with optimistic UI update - Test/preview button for templates, sources, and KC targets - Notification test button (🔔) for notification color strip sources - Fetch batch states to show correct running status for output targets - Sync clocks show running state from API (is_running) - Surgical DOM patching (patchNodeRunning) preserves hover state on toggle - Success button hover style (green) for start action Co-Authored-By: Claude Opus 4.6 --- .../static/css/graph-editor.css | 8 ++ .../static/js/core/graph-layout.js | 2 +- .../static/js/core/graph-nodes.js | 86 ++++++++++++++++- .../static/js/features/graph-editor.js | 92 ++++++++++++++++++- 4 files changed, 181 insertions(+), 7 deletions(-) diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index a4ea199..2193b7f 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -479,6 +479,14 @@ fill: var(--primary-contrast); } +.graph-node-overlay-btn.success:hover rect { + fill: var(--success-color); +} + +.graph-node-overlay-btn.success:hover text { + fill: var(--primary-contrast); +} + /* ── Selection rectangle ── */ .graph-selection-rect { diff --git a/server/src/wled_controller/static/js/core/graph-layout.js b/server/src/wled_controller/static/js/core/graph-layout.js index 67b2766..ffb0512 100644 --- a/server/src/wled_controller/static/js/core/graph-layout.js +++ b/server/src/wled_controller/static/js/core/graph-layout.js @@ -198,7 +198,7 @@ function buildGraph(e) { // 6. Sync clocks for (const c of e.syncClocks || []) { - addNode(c.id, 'sync_clock', c.name, ''); + addNode(c.id, 'sync_clock', c.name, '', { running: c.is_running !== false }); } // 7. Picture sources 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 9373862..5ac5730 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.js +++ b/server/src/wled_controller/static/js/core/graph-nodes.js @@ -172,14 +172,47 @@ function renderNode(node, callbacks) { return g; } +// Entity kinds that support start/stop +const START_STOP_KINDS = new Set(['output_target', 'sync_clock']); + +// Entity kinds that support test/preview +const TEST_KINDS = new Set([ + 'capture_template', 'pp_template', 'audio_template', + 'picture_source', 'audio_source', 'value_source', + 'color_strip_source', +]); + function _createOverlay(node, nodeWidth, callbacks) { const overlay = svgEl('g', { class: 'graph-node-overlay' }); const btnSize = 24; const btnGap = 2; - const btns = [ - { icon: '\u270E', action: 'edit', cls: '' }, // ✎ - { icon: '\u2716', action: 'delete', cls: 'danger' }, // ✖ - ]; + + // Build button list dynamically based on node kind/subtype + const btns = []; + + // Start/stop button for applicable kinds + if (START_STOP_KINDS.has(node.kind)) { + btns.push({ + icon: node.running ? '\u25A0' : '\u25B6', // ■ stop / ▶ play + action: 'startstop', + cls: node.running ? 'danger' : 'success', + }); + } + + // Test button for applicable kinds + if (TEST_KINDS.has(node.kind) || (node.kind === 'output_target' && node.subtype === 'key_colors')) { + btns.push({ icon: '\u25B7', action: 'test', cls: '' }); // ▷ test + } + + // Notification test for notification color strip sources + if (node.kind === 'color_strip_source' && node.subtype === 'notification') { + btns.push({ icon: '\uD83D\uDD14', action: 'notify', cls: '' }); // 🔔 + } + + // Always: edit and delete + btns.push({ icon: '\u270E', action: 'edit', cls: '' }); // ✎ + btns.push({ icon: '\u2716', action: 'delete', cls: 'danger' }); // ✖ + const totalW = btns.length * (btnSize + btnGap) - btnGap + 8; const ox = nodeWidth - totalW - 4; const oy = -btnSize - 6; @@ -211,6 +244,9 @@ function _createOverlay(node, nodeWidth, callbacks) { e.stopPropagation(); if (btn.action === 'edit' && callbacks.onEditNode) callbacks.onEditNode(node); if (btn.action === 'delete' && callbacks.onDeleteNode) callbacks.onDeleteNode(node); + if (btn.action === 'startstop' && callbacks.onStartStopNode) callbacks.onStartStopNode(node); + if (btn.action === 'test' && callbacks.onTestNode) callbacks.onTestNode(node); + if (btn.action === 'notify' && callbacks.onNotificationTest) callbacks.onNotificationTest(node); }); overlay.appendChild(bg); }); @@ -218,6 +254,48 @@ function _createOverlay(node, nodeWidth, callbacks) { return overlay; } +/** + * Patch a node's running state in-place without replacing the element. + * Updates the start/stop button icon/class, running dot, and node CSS class. + */ +export function patchNodeRunning(group, node) { + const el = group.querySelector(`.graph-node[data-id="${node.id}"]`); + if (!el) return; + + // Toggle running class + el.classList.toggle('running', !!node.running); + + // Update or add/remove running dot + let dot = el.querySelector('.graph-node-running-dot'); + if (node.running && !dot) { + dot = svgEl('circle', { + class: 'graph-node-running-dot', + cx: node.width - 14, cy: 14, r: 4, + }); + // Insert before the overlay group + const overlay = el.querySelector('.graph-node-overlay'); + if (overlay) el.insertBefore(dot, overlay); + else el.appendChild(dot); + } else if (!node.running && dot) { + dot.remove(); + } + + // Update start/stop button in overlay + const overlayBtns = el.querySelectorAll('.graph-node-overlay-btn'); + for (const btn of overlayBtns) { + const txt = btn.querySelector('text'); + if (!txt) continue; + const content = txt.textContent; + // Match play ▶ or stop ■ icons + if (content === '\u25B6' || content === '\u25A0') { + txt.textContent = node.running ? '\u25A0' : '\u25B6'; + btn.classList.toggle('success', !node.running); + btn.classList.toggle('danger', !!node.running); + break; + } + } +} + /** * Highlight a single node (add class, scroll to). */ 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 5fb1a24..9b6ac00 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, ENTITY_COLORS, ENTITY_LABELS } from '../core/graph-layout.js'; -import { renderNodes, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js'; +import { renderNodes, patchNodeRunning, highlightNode, markOrphans, updateSelection } from '../core/graph-nodes.js'; import { renderEdges, highlightChain, clearEdgeHighlights, updateEdgesForNode, renderFlowDots, updateFlowDotsForNode } from '../core/graph-edges.js'; import { devicesCache, captureTemplatesCache, ppTemplatesCache, @@ -13,6 +13,8 @@ import { outputTargetsCache, patternTemplatesCache, scenePresetsCache, automationsCacheObj, } from '../core/state.js'; +import { fetchWithAuth } from '../core/api.js'; +import { showToast } from '../core/ui.js'; import { t } from '../core/i18n.js'; let _canvas = null; @@ -147,17 +149,31 @@ async function _fetchAllEntities() { devices, captureTemplates, ppTemplates, pictureSources, audioSources, audioTemplates, valueSources, colorStripSources, syncClocks, outputTargets, patternTemplates, scenePresets, automations, + batchStatesResp, ] = await Promise.all([ devicesCache.fetch(), captureTemplatesCache.fetch(), ppTemplatesCache.fetch(), streamsCache.fetch(), audioSourcesCache.fetch(), audioTemplatesCache.fetch(), valueSourcesCache.fetch(), colorStripSourcesCache.fetch(), syncClocksCache.fetch(), outputTargetsCache.fetch(), patternTemplatesCache.fetch(), scenePresetsCache.fetch(), automationsCacheObj.fetch(), + fetchWithAuth('/output-targets/batch/states').catch(() => null), ]); + + // Enrich output targets with running state from batch states + let batchStates = {}; + if (batchStatesResp && batchStatesResp.ok) { + const data = await batchStatesResp.json().catch(() => ({})); + batchStates = data.states || {}; + } + const enrichedTargets = (outputTargets || []).map(t => ({ + ...t, + running: batchStates[t.id]?.processing || false, + })); + return { devices, captureTemplates, ppTemplates, pictureSources, audioSources, audioTemplates, valueSources, colorStripSources, - syncClocks, outputTargets, patternTemplates, scenePresets, automations, + syncClocks, outputTargets: enrichedTargets, patternTemplates, scenePresets, automations, }; } @@ -181,6 +197,9 @@ function _renderGraph(container) { onNodeDblClick: _onNodeDblClick, onEditNode: _onEditNode, onDeleteNode: _onDeleteNode, + onStartStopNode: _onStartStopNode, + onTestNode: _onTestNode, + onNotificationTest: _onNotificationTest, }); markOrphans(nodeGroup, _nodeMap, _edges); @@ -666,6 +685,75 @@ function _onDeleteNode(node) { fnMap[node.kind]?.(); } +function _onStartStopNode(node) { + const newRunning = !node.running; + // Optimistic update — toggle UI immediately + _updateNodeRunning(node.id, newRunning); + + if (node.kind === 'output_target') { + const action = newRunning ? 'start' : 'stop'; + fetchWithAuth(`/output-targets/${node.id}/${action}`, { method: 'POST' }).then(resp => { + if (resp.ok) { + showToast(t(action === 'start' ? 'device.started' : 'device.stopped'), 'success'); + } else { + resp.json().catch(() => ({})).then(err => { + showToast(err.detail || t(`target.error.${action}_failed`), 'error'); + }); + _updateNodeRunning(node.id, !newRunning); // revert + } + }).catch(() => { _updateNodeRunning(node.id, !newRunning); }); + } else if (node.kind === 'sync_clock') { + const action = newRunning ? 'resume' : 'pause'; + fetchWithAuth(`/sync-clocks/${node.id}/${action}`, { method: 'POST' }).then(resp => { + if (resp.ok) { + showToast(t(action === 'pause' ? 'sync_clock.paused' : 'sync_clock.resumed'), 'success'); + } else { + _updateNodeRunning(node.id, !newRunning); // revert + } + }).catch(() => { _updateNodeRunning(node.id, !newRunning); }); + } +} + +/** Update a node's running state in the model and patch it in-place (no re-render). */ +function _updateNodeRunning(nodeId, running) { + const node = _nodeMap?.get(nodeId); + if (!node) return; + node.running = running; + const nodeGroup = document.querySelector('.graph-nodes'); + const edgeGroup = document.querySelector('.graph-edges'); + if (nodeGroup) { + patchNodeRunning(nodeGroup, node); + } + // Update flow dots since running set changed + if (edgeGroup) { + const runningIds = new Set(); + for (const n of _nodeMap.values()) { + if (n.running) runningIds.add(n.id); + } + renderFlowDots(edgeGroup, _edges, runningIds); + } +} + +function _onTestNode(node) { + const fnMap = { + capture_template: () => window.showTestTemplateModal?.(node.id), + pp_template: () => window.showTestPPTemplateModal?.(node.id), + audio_template: () => window.showTestAudioTemplateModal?.(node.id), + picture_source: () => window.showTestStreamModal?.(node.id), + audio_source: () => window.testAudioSource?.(node.id), + value_source: () => window.testValueSource?.(node.id), + color_strip_source: () => window.testColorStrip?.(node.id), + output_target: () => window.testKCTarget?.(node.id), + }; + fnMap[node.kind]?.(); +} + +function _onNotificationTest(node) { + if (node.kind === 'color_strip_source' && node.subtype === 'notification') { + window.testNotification?.(node.id); + } +} + /* ── Keyboard ── */ function _onKeydown(e) {