From 1bdaed9a2c75378c02ba3a018bf219f37afd155a Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Sun, 31 Aug 2025 11:24:39 +0200 Subject: [PATCH] refactor(rendering): restore NodeDetails active tab; keyed partial updates by IP; add escapeHtml in base Component and use in members; simplify ApiClient methods by removing redundant try/catch --- public/scripts/api-client.js | 88 +++++++++++------------------------- public/scripts/components.js | 38 ++++++---------- public/scripts/framework.js | 12 ++++- 3 files changed, 51 insertions(+), 87 deletions(-) diff --git a/public/scripts/api-client.js b/public/scripts/api-client.js index dd90201..bcd0f91 100644 --- a/public/scripts/api-client.js +++ b/public/scripts/api-client.js @@ -51,92 +51,56 @@ class ApiClient { } async getClusterMembers() { - try { - return await this.request('/api/cluster/members', { method: 'GET' }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/cluster/members', { method: 'GET' }); } async getClusterMembersFromNode(ip) { - try { - return await this.request(`/api/cluster/members`, { - method: 'GET', - query: { ip: ip } - }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request(`/api/cluster/members`, { + method: 'GET', + query: { ip: ip } + }); } async getDiscoveryInfo() { - try { - return await this.request('/api/discovery/nodes', { method: 'GET' }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/discovery/nodes', { method: 'GET' }); } async selectRandomPrimaryNode() { - try { - return await this.request('/api/discovery/random-primary', { - method: 'POST', - body: { timestamp: new Date().toISOString() } - }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/discovery/random-primary', { + method: 'POST', + body: { timestamp: new Date().toISOString() } + }); } async getNodeStatus(ip) { - try { - return await this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' }); } async getTasksStatus(ip) { - try { - return await this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined }); } async getCapabilities(ip) { - try { - return await this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined }); } async callCapability({ ip, method, uri, params }) { - try { - return await this.request('/api/proxy-call', { - method: 'POST', - body: { ip, method, uri, params } - }); - } catch (error) { - throw new Error(`Request failed: ${error.message}`); - } + return this.request('/api/proxy-call', { + method: 'POST', + body: { ip, method, uri, params } + }); } async uploadFirmware(file, nodeIp) { - try { - const formData = new FormData(); - formData.append('file', file); - return await this.request(`/api/node/update`, { - method: 'POST', - query: { ip: nodeIp }, - body: formData, - isForm: true, - headers: {}, - }); - } catch (error) { - throw new Error(`Upload failed: ${error.message}`); - } + const formData = new FormData(); + formData.append('file', file); + return this.request(`/api/node/update`, { + method: 'POST', + query: { ip: nodeIp }, + body: formData, + isForm: true, + headers: {}, + }); } } diff --git a/public/scripts/components.js b/public/scripts/components.js index b81e452..11cb776 100644 --- a/public/scripts/components.js +++ b/public/scripts/components.js @@ -285,22 +285,11 @@ class ClusterMembersComponent extends Component { // Check if we should skip rendering during view switches shouldSkipRender() { - // Skip rendering if we're in the middle of a view switch - const isViewSwitching = document.querySelectorAll('.view-content.active').length === 0; - if (isViewSwitching) { - console.log('ClusterMembersComponent: View switching in progress, skipping render'); + // Rely on lifecycle flags controlled by App + if (!this.isMounted || this.isPaused) { + logger.debug('ClusterMembersComponent: Not mounted or paused, skipping render'); return true; } - - // Skip rendering if the component is not visible - const isVisible = this.container.style.display !== 'none' && - this.container.style.opacity !== '0' && - this.container.classList.contains('active'); - if (!isVisible) { - console.log('ClusterMembersComponent: Component not visible, skipping render'); - return true; - } - return false; } @@ -308,11 +297,12 @@ class ClusterMembersComponent extends Component { updateMembersPartially(newMembers, previousMembers) { console.log('ClusterMembersComponent: Performing partial update to preserve UI state'); - // Update only the data that changed, preserving expanded states and active tabs - newMembers.forEach((newMember, index) => { - const prevMember = previousMembers[index]; + // Build previous map by IP for stable diffs + const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m])); + newMembers.forEach((newMember) => { + const prevMember = prevByIp.get(newMember.ip); if (prevMember && this.hasMemberChanged(newMember, prevMember)) { - this.updateMemberCard(newMember, index); + this.updateMemberCard(newMember); } }); } @@ -325,7 +315,7 @@ class ClusterMembersComponent extends Component { } // Update a specific member card without re-rendering the entire component - updateMemberCard(member, index) { + updateMemberCard(member) { const card = this.findElement(`[data-member-ip="${member.ip}"]`); if (!card) return; @@ -451,9 +441,9 @@ class ClusterMembersComponent extends Component {
${statusIcon}
-
${member.hostname || 'Unknown Device'}
+
${this.escapeHtml(member.hostname || 'Unknown Device')}
-
${member.ip || 'No IP'}
+
${this.escapeHtml(member.ip || 'No IP')}
Latency: ${member.latency ? member.latency + 'ms' : 'N/A'} @@ -462,7 +452,7 @@ class ClusterMembersComponent extends Component { ${member.labels && Object.keys(member.labels).length ? `
- ${Object.entries(member.labels).map(([key, value]) => `${key}: ${value}`).join('')} + ${Object.entries(member.labels).map(([key, value]) => `${this.escapeHtml(key)}: ${this.escapeHtml(value)}`).join('')}
` : ''} @@ -807,8 +797,8 @@ class NodeDetailsComponent extends Component { } renderNodeDetails(nodeStatus, tasks, capabilities) { - // Always start with 'status' tab, don't restore previous state - const activeTab = 'status'; + // Use persisted active tab from the view model, default to 'status' + const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status'; console.log('NodeDetailsComponent: Rendering with activeTab:', activeTab); const html = ` diff --git a/public/scripts/framework.js b/public/scripts/framework.js index 43ba9e8..b76df22 100644 --- a/public/scripts/framework.js +++ b/public/scripts/framework.js @@ -550,7 +550,7 @@ class Component { } renderError(message) { - const safe = String(message || 'An error occurred'); + const safe = this.escapeHtml(String(message || 'An error occurred')); const html = `
Error:
@@ -569,6 +569,16 @@ class Component { this.setHTML('', html); } + // Basic HTML escaping for dynamic values + escapeHtml(str) { + return String(str) + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + } + // Tab helpers setupTabs(container = this.container, options = {}) { const { onChange } = options;