From dd92af9913d7c4b0900c827adb288a6736d3e0d9 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 14 Mar 2026 21:56:46 +0300 Subject: [PATCH] Add graph filter by entity type/running state and fix duplicate API calls - Add entity type toggle pills and running/stopped filter to graph filter bar - DataCache: return cached data if fresh, skip redundant fetches on page load - Entity events: use force-fetch instead of invalidate+fetch to avoid stale gap - Add no-cache middleware for static JS/CSS/JSON to prevent stale browser cache - Reduces API calls on page load from ~70 to ~30 Co-Authored-By: Claude Opus 4.6 --- server/src/wled_controller/main.py | 8 ++ .../static/css/graph-editor.css | 49 +++++++++- .../wled_controller/static/js/core/cache.js | 18 +++- .../static/js/core/entity-events.js | 3 +- .../static/js/features/graph-editor.js | 93 ++++++++++++++++--- .../wled_controller/static/locales/en.json | 4 +- .../wled_controller/static/locales/ru.json | 4 +- .../wled_controller/static/locales/zh.json | 4 +- 8 files changed, 157 insertions(+), 26 deletions(-) diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index 12c788e..c6a1179 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -276,6 +276,14 @@ async def pwa_service_worker(): ) +# Middleware: no-cache for static JS/CSS (development convenience) +@app.middleware("http") +async def _no_cache_static(request: Request, call_next): + response = await call_next(request) + if request.url.path.startswith("/static/") and request.url.path.endswith((".js", ".css", ".json")): + response.headers["Cache-Control"] = "no-cache, must-revalidate" + return response + # Mount static files static_path = Path(__file__).parent / "static" if static_path.exists(): diff --git a/server/src/wled_controller/static/css/graph-editor.css b/server/src/wled_controller/static/css/graph-editor.css index f563e54..96e6e6c 100644 --- a/server/src/wled_controller/static/css/graph-editor.css +++ b/server/src/wled_controller/static/css/graph-editor.css @@ -704,21 +704,66 @@ left: 50%; transform: translateX(-50%); display: none; - align-items: center; + flex-direction: column; gap: 6px; background: var(--bg-color); border: 1px solid var(--border-color); border-radius: 8px; - padding: 6px 10px; + padding: 8px 10px; z-index: 30; box-shadow: 0 2px 8px rgba(0,0,0,0.15); min-width: 260px; + max-width: 480px; } .graph-filter.visible { display: flex; } +.graph-filter-row { + display: flex; + align-items: center; + gap: 6px; + width: 100%; +} + +.graph-filter-pills { + display: flex; + flex-wrap: wrap; + gap: 4px; + align-items: center; +} + +.graph-filter-pill { + background: transparent; + border: 1px solid var(--pill-color, var(--border-color)); + color: var(--pill-color, var(--text-muted)); + border-radius: 12px; + padding: 2px 8px; + font-size: 0.7rem; + cursor: pointer; + white-space: nowrap; + transition: background 0.15s, color 0.15s; + line-height: 1.4; +} + +.graph-filter-pill:hover { + background: color-mix(in srgb, var(--pill-color, var(--border-color)) 15%, transparent); +} + +.graph-filter-pill.active { + background: var(--pill-color, var(--primary-color)); + color: #fff; + border-color: var(--pill-color, var(--primary-color)); +} + +.graph-filter-sep { + width: 1px; + height: 16px; + background: var(--border-color); + margin: 0 2px; +} + .graph-filter-icon { flex-shrink: 0; stroke: var(--text-muted); diff --git a/server/src/wled_controller/static/js/core/cache.js b/server/src/wled_controller/static/js/core/cache.js index feaddf6..2bc9893 100644 --- a/server/src/wled_controller/static/js/core/cache.js +++ b/server/src/wled_controller/static/js/core/cache.js @@ -19,13 +19,20 @@ export class DataCache { this._loading = false; this._promise = null; this._subscribers = []; + this._fresh = false; // true after first successful fetch, cleared on invalidate } get data() { return this._data; } get loading() { return this._loading; } - /** Fetch from API. Deduplicates concurrent calls. */ - async fetch() { + /** + * Fetch from API. Deduplicates concurrent calls. + * Returns cached data immediately if already fetched and not invalidated. + * @param {Object} [opts] + * @param {boolean} [opts.force=false] - Force re-fetch even if cache is fresh + */ + async fetch({ force = false } = {}) { + if (!force && this._fresh) return this._data; if (this._promise) return this._promise; this._loading = true; this._promise = this._doFetch(); @@ -43,6 +50,7 @@ export class DataCache { if (!resp.ok) return this._data; const json = await resp.json(); this._data = this._extractData(json); + this._fresh = true; this._notify(); return this._data; } catch (err) { @@ -52,15 +60,15 @@ export class DataCache { } } - /** Clear cached data; next fetch() will re-request. */ + /** Mark cache as stale; next fetch() will re-request. */ invalidate() { - this._data = this._defaultValue; - this._notify(); + this._fresh = false; } /** Manually set cache value (e.g. after a create/update call). */ update(value) { this._data = value; + this._fresh = true; this._notify(); } diff --git a/server/src/wled_controller/static/js/core/entity-events.js b/server/src/wled_controller/static/js/core/entity-events.js index 9b6b99a..15d4fe7 100644 --- a/server/src/wled_controller/static/js/core/entity-events.js +++ b/server/src/wled_controller/static/js/core/entity-events.js @@ -33,8 +33,7 @@ const ENTITY_CACHE_MAP = { function _invalidateAndReload(entityType) { const cache = ENTITY_CACHE_MAP[entityType]; if (cache) { - cache.invalidate(); - cache.fetch(); + cache.fetch({ force: true }); } document.dispatchEvent(new CustomEvent('entity:reload', { detail: { entity_type: entityType }, 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 6ec7af2..516a1e7 100644 --- a/server/src/wled_controller/static/js/features/graph-editor.js +++ b/server/src/wled_controller/static/js/features/graph-editor.js @@ -32,6 +32,8 @@ let _searchItems = []; let _loading = false; let _filterVisible = false; let _filterQuery = ''; // current active filter text +let _filterKinds = new Set(); // empty = all kinds shown +let _filterRunning = null; // null = all, true = running only, false = stopped only // Node drag state let _dragState = null; // { nodeId, el, startClient, startNode, dragging } @@ -268,32 +270,47 @@ export function toggleGraphFilter() { if (_filterVisible) { const input = bar.querySelector('.graph-filter-input'); if (input) { input.value = _filterQuery; input.focus(); } + // Restore pill active states + bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => { + p.classList.toggle('active', _filterKinds.has(p.dataset.kind)); + }); + bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => { + p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running); + }); } else { + _filterKinds.clear(); + _filterRunning = null; _applyFilter(''); } } function _applyFilter(query) { - _filterQuery = query; - const q = query.toLowerCase().trim(); + if (query !== undefined) _filterQuery = query; + const q = _filterQuery.toLowerCase().trim(); const nodeGroup = document.querySelector('.graph-nodes'); const edgeGroup = document.querySelector('.graph-edges'); const mm = document.querySelector('.graph-minimap'); if (!_nodeMap) return; + const hasTextFilter = !!q; + const hasKindFilter = _filterKinds.size > 0; + const hasRunningFilter = _filterRunning !== null; + const hasAny = hasTextFilter || hasKindFilter || hasRunningFilter; + // Build set of matching node IDs const matchIds = new Set(); for (const node of _nodeMap.values()) { - if (!q || node.name.toLowerCase().includes(q) || node.kind.includes(q) || (node.subtype || '').toLowerCase().includes(q)) { - matchIds.add(node.id); - } + const textMatch = !hasTextFilter || node.name.toLowerCase().includes(q) || node.kind.includes(q) || (node.subtype || '').toLowerCase().includes(q); + const kindMatch = !hasKindFilter || _filterKinds.has(node.kind); + const runMatch = !hasRunningFilter || (node.running === _filterRunning); + if (textMatch && kindMatch && runMatch) matchIds.add(node.id); } // Apply filtered-out class to nodes if (nodeGroup) { nodeGroup.querySelectorAll('.graph-node').forEach(el => { - el.classList.toggle('graph-filtered-out', !!q && !matchIds.has(el.getAttribute('data-id'))); + el.classList.toggle('graph-filtered-out', hasAny && !matchIds.has(el.getAttribute('data-id'))); }); } @@ -302,7 +319,7 @@ function _applyFilter(query) { edgeGroup.querySelectorAll('.graph-edge').forEach(el => { const from = el.getAttribute('data-from'); const to = el.getAttribute('data-to'); - el.classList.toggle('graph-filtered-out', !!q && (!matchIds.has(from) || !matchIds.has(to))); + el.classList.toggle('graph-filtered-out', hasAny && (!matchIds.has(from) || !matchIds.has(to))); }); } @@ -310,13 +327,13 @@ function _applyFilter(query) { if (mm) { mm.querySelectorAll('.graph-minimap-node').forEach(el => { const id = el.getAttribute('data-id'); - el.setAttribute('opacity', (!q || matchIds.has(id)) ? '0.7' : '0.07'); + el.setAttribute('opacity', (!hasAny || matchIds.has(id)) ? '0.7' : '0.07'); }); } // Update filter button active state const btn = document.querySelector('.graph-filter-btn'); - if (btn) btn.classList.toggle('active', !!q); + if (btn) btn.classList.toggle('active', hasAny); } export function graphFitAll() { @@ -618,14 +635,52 @@ function _renderGraph(container) { if (filterClear) { filterClear.addEventListener('click', () => { if (filterInput) filterInput.value = ''; + _filterKinds.clear(); + _filterRunning = null; + container.querySelectorAll('.graph-filter-pill').forEach(p => p.classList.remove('active')); _applyFilter(''); }); } + // Entity type pills + container.querySelectorAll('.graph-filter-pill[data-kind]').forEach(pill => { + pill.addEventListener('click', () => { + const kind = pill.dataset.kind; + if (_filterKinds.has(kind)) { _filterKinds.delete(kind); pill.classList.remove('active'); } + else { _filterKinds.add(kind); pill.classList.add('active'); } + _applyFilter(); + }); + }); + + // Running/stopped pills + container.querySelectorAll('.graph-filter-pill[data-running]').forEach(pill => { + pill.addEventListener('click', () => { + const val = pill.dataset.running === 'true'; + if (_filterRunning === val) { + _filterRunning = null; + pill.classList.remove('active'); + } else { + _filterRunning = val; + // Deactivate sibling running pills, activate this one + container.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => p.classList.remove('active')); + pill.classList.add('active'); + } + _applyFilter(); + }); + }); + // Restore active filter if re-rendering - if (_filterQuery && _filterVisible) { + if ((_filterQuery || _filterKinds.size || _filterRunning !== null) && _filterVisible) { const bar = container.querySelector('.graph-filter'); - if (bar) bar.classList.add('visible'); + if (bar) { + bar.classList.add('visible'); + bar.querySelectorAll('.graph-filter-pill[data-kind]').forEach(p => { + p.classList.toggle('active', _filterKinds.has(p.dataset.kind)); + }); + bar.querySelectorAll('.graph-filter-pill[data-running]').forEach(p => { + p.classList.toggle('active', _filterRunning !== null && String(_filterRunning) === p.dataset.running); + }); + } _applyFilter(_filterQuery); } @@ -735,9 +790,19 @@ function _graphHTML() {
- - - +
+ + + +
+
+ ${Object.entries(ENTITY_LABELS).map(([kind, label]) => + `` + ).join('')} + + + +
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 61b5ddd..ec19d3b 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1465,5 +1465,7 @@ "graph.color_picker": "Node color", "graph.filter": "Filter nodes", "graph.filter_placeholder": "Filter by name...", - "graph.filter_clear": "Clear filter" + "graph.filter_clear": "Clear filter", + "graph.filter_running": "Running", + "graph.filter_stopped": "Stopped" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 8b8bf5d..bf736a8 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1414,5 +1414,7 @@ "graph.color_picker": "Цвет узла", "graph.filter": "Фильтр узлов", "graph.filter_placeholder": "Фильтр по имени...", - "graph.filter_clear": "Очистить фильтр" + "graph.filter_clear": "Очистить фильтр", + "graph.filter_running": "Запущен", + "graph.filter_stopped": "Остановлен" } diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 77a73ac..d7cc613 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -1414,5 +1414,7 @@ "graph.color_picker": "节点颜色", "graph.filter": "筛选节点", "graph.filter_placeholder": "按名称筛选...", - "graph.filter_clear": "清除筛选" + "graph.filter_clear": "清除筛选", + "graph.filter_running": "运行中", + "graph.filter_stopped": "已停止" }