From d85f7ddf051de42c6bdac07e536a72d001394fbf Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Sun, 12 Apr 2026 02:32:21 +0300 Subject: [PATCH] ui: cache derived catalog views --- TASKS.md | 10 ++--- apps/ui/js/catalog.js | 100 +++++++++++++++++++++++++++++++++++++----- 2 files changed, 95 insertions(+), 15 deletions(-) diff --git a/TASKS.md b/TASKS.md index 1e067e0..51ddbec 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,18 +2,18 @@ ## Current -### `feat/frontend-performance-polish` +### `feat/frontend-observability-and-testability` Status: in_progress DoD: -- repeated O(n) filtering in hot render paths is removed -- tab counts and similar derived UI metrics use cached/derived snapshots instead of recomputing the same filters -- existing UI e2e coverage stays green after the performance polish +- UI modules expose stable hooks for diagnostics and targeted testing where the current DOM is ambiguous +- console/runtime failures on critical pages are easier to attribute to a concrete page module +- existing UI e2e coverage stays green after the observability/testability pass ## Next -- `feat/frontend-observability-and-testability` +- `feat/id-display-support` ## Backlog diff --git a/apps/ui/js/catalog.js b/apps/ui/js/catalog.js index dcdb24b..f389dd9 100644 --- a/apps/ui/js/catalog.js +++ b/apps/ui/js/catalog.js @@ -109,6 +109,15 @@ document.addEventListener('alpine:init', function() { categoryOptions: [], workspaceId: null, stats: emptyStats(), + _opsVersion: 0, + _nonStatusViewCache: null, + _nonStatusViewKey: '', + _filteredCache: null, + _filteredKey: '', + _agentsByOpIdCache: null, + _agentsByOpIdCacheVersion: -1, + _activeAgentsCache: null, + _activeAgentsCacheVersion: -1, async init() { var self = this; @@ -150,13 +159,13 @@ document.addEventListener('alpine:init', function() { try { var response = await window.CrankApi.listOperations(this.workspaceId); - this.operations = (response && response.items ? response.items : []).map(mapOperation); + this.replaceOperations((response && response.items ? response.items : []).map(mapOperation)); this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) { return operation.category; }).filter(Boolean))).sort(); this.stats = computeStats(this.operations); } catch (error) { - this.operations = []; + this.replaceOperations([]); this.categoryOptions = []; this.stats = emptyStats(); this.loadError = error.message || 'Failed to load operations'; @@ -166,6 +175,19 @@ document.addEventListener('alpine:init', function() { this.page = 1; }, + replaceOperations(operations) { + this.operations = operations; + this._opsVersion += 1; + this._nonStatusViewCache = null; + this._nonStatusViewKey = ''; + this._filteredCache = null; + this._filteredKey = ''; + this._agentsByOpIdCache = null; + this._agentsByOpIdCacheVersion = -1; + this._activeAgentsCache = null; + this._activeAgentsCacheVersion = -1; + }, + _applyNonStatusFilters(operations) { var filtered = operations; @@ -196,8 +218,55 @@ document.addEventListener('alpine:init', function() { return filtered; }, - get filtered() { + _nonStatusView() { + var key = [ + this._opsVersion, + this.search.trim(), + this.filterProtocol || '', + this.filterCategory || '', + this.filterAgent || '', + ].join('|'); + + if (this._nonStatusViewCache && this._nonStatusViewKey === key) { + return this._nonStatusViewCache; + } + var operations = this._applyNonStatusFilters(this.operations); + var tabCounts = { + all: operations.length, + active: 0, + draft: 0, + error: 0, + inactive: 0, + }; + + operations.forEach(function(operation) { + if (Object.prototype.hasOwnProperty.call(tabCounts, operation.status)) { + tabCounts[operation.status] += 1; + } + }); + + this._nonStatusViewKey = key; + this._nonStatusViewCache = { + operations: operations, + tabCounts: tabCounts, + }; + return this._nonStatusViewCache; + }, + + get filtered() { + var nonStatusView = this._nonStatusView(); + var key = [ + this._nonStatusViewKey, + this.tab, + this.sort, + ].join('|'); + + if (this._filteredCache && this._filteredKey === key) { + return this._filteredCache; + } + + var operations = nonStatusView.operations; if (this.tab !== 'all') { operations = operations.filter(function(operation) { @@ -213,7 +282,9 @@ document.addEventListener('alpine:init', function() { return 0; }.bind(this)); - return operations; + this._filteredKey = key; + this._filteredCache = operations; + return this._filteredCache; }, get totalFiltered() { @@ -238,9 +309,8 @@ document.addEventListener('alpine:init', function() { }, tabCount(tab) { - var operations = this._applyNonStatusFilters(this.operations); - if (tab === 'all') return operations.length; - return operations.filter(function(operation) { return operation.status === tab; }).length; + var tabCounts = this._nonStatusView().tabCounts; + return Object.prototype.hasOwnProperty.call(tabCounts, tab) ? tabCounts[tab] : 0; }, get activeChips() { @@ -275,14 +345,22 @@ document.addEventListener('alpine:init', function() { }, get agentsByOpId() { + if (this._agentsByOpIdCache && this._agentsByOpIdCacheVersion === this._opsVersion) { + return this._agentsByOpIdCache; + } var map = {}; this.operations.forEach(function(operation) { map[operation.id] = operation.agent_refs || []; }); - return map; + this._agentsByOpIdCacheVersion = this._opsVersion; + this._agentsByOpIdCache = map; + return this._agentsByOpIdCache; }, get activeAgents() { + if (this._activeAgentsCache && this._activeAgentsCacheVersion === this._opsVersion) { + return this._activeAgentsCache; + } var seen = {}; var agents = []; this.operations.forEach(function(operation) { @@ -293,9 +371,11 @@ document.addEventListener('alpine:init', function() { } }); }); - return agents.sort(function(left, right) { + this._activeAgentsCacheVersion = this._opsVersion; + this._activeAgentsCache = agents.sort(function(left, right) { return left.display_name.localeCompare(right.display_name); }); + return this._activeAgentsCache; }, get sortLabel() { @@ -371,7 +451,7 @@ document.addEventListener('alpine:init', function() { try { var operation = this.operations.find(function(item) { return item.id === id; }); await window.CrankApi.deleteOperation(this.workspaceId, id); - this.operations = this.operations.filter(function(operation) { return operation.id !== id; }); + this.replaceOperations(this.operations.filter(function(operation) { return operation.id !== id; })); this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) { return operation.category; }).filter(Boolean))).sort();