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

This commit is contained in:
2025-08-31 11:24:39 +02:00
parent b757cb68da
commit 1bdaed9a2c
3 changed files with 51 additions and 87 deletions

View File

@@ -51,92 +51,56 @@ class ApiClient {
} }
async getClusterMembers() { async getClusterMembers() {
try { return this.request('/api/cluster/members', { method: 'GET' });
return await this.request('/api/cluster/members', { method: 'GET' });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async getClusterMembersFromNode(ip) { async getClusterMembersFromNode(ip) {
try { return this.request(`/api/cluster/members`, {
return await this.request(`/api/cluster/members`, {
method: 'GET', method: 'GET',
query: { ip: ip } query: { ip: ip }
}); });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async getDiscoveryInfo() { async getDiscoveryInfo() {
try { return this.request('/api/discovery/nodes', { method: 'GET' });
return await this.request('/api/discovery/nodes', { method: 'GET' });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async selectRandomPrimaryNode() { async selectRandomPrimaryNode() {
try { return this.request('/api/discovery/random-primary', {
return await this.request('/api/discovery/random-primary', {
method: 'POST', method: 'POST',
body: { timestamp: new Date().toISOString() } body: { timestamp: new Date().toISOString() }
}); });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async getNodeStatus(ip) { async getNodeStatus(ip) {
try { return this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
return await this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async getTasksStatus(ip) { async getTasksStatus(ip) {
try { return this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
return await this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async getCapabilities(ip) { async getCapabilities(ip) {
try { return this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined });
return await this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async callCapability({ ip, method, uri, params }) { async callCapability({ ip, method, uri, params }) {
try { return this.request('/api/proxy-call', {
return await this.request('/api/proxy-call', {
method: 'POST', method: 'POST',
body: { ip, method, uri, params } body: { ip, method, uri, params }
}); });
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
} }
async uploadFirmware(file, nodeIp) { async uploadFirmware(file, nodeIp) {
try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
return await this.request(`/api/node/update`, { return this.request(`/api/node/update`, {
method: 'POST', method: 'POST',
query: { ip: nodeIp }, query: { ip: nodeIp },
body: formData, body: formData,
isForm: true, isForm: true,
headers: {}, headers: {},
}); });
} catch (error) {
throw new Error(`Upload failed: ${error.message}`);
}
} }
} }

View File

@@ -285,22 +285,11 @@ class ClusterMembersComponent extends Component {
// Check if we should skip rendering during view switches // Check if we should skip rendering during view switches
shouldSkipRender() { shouldSkipRender() {
// Skip rendering if we're in the middle of a view switch // Rely on lifecycle flags controlled by App
const isViewSwitching = document.querySelectorAll('.view-content.active').length === 0; if (!this.isMounted || this.isPaused) {
if (isViewSwitching) { logger.debug('ClusterMembersComponent: Not mounted or paused, skipping render');
console.log('ClusterMembersComponent: View switching in progress, skipping render');
return true; 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; return false;
} }
@@ -308,11 +297,12 @@ class ClusterMembersComponent extends Component {
updateMembersPartially(newMembers, previousMembers) { updateMembersPartially(newMembers, previousMembers) {
console.log('ClusterMembersComponent: Performing partial update to preserve UI state'); console.log('ClusterMembersComponent: Performing partial update to preserve UI state');
// Update only the data that changed, preserving expanded states and active tabs // Build previous map by IP for stable diffs
newMembers.forEach((newMember, index) => { const prevByIp = new Map((previousMembers || []).map(m => [m.ip, m]));
const prevMember = previousMembers[index]; newMembers.forEach((newMember) => {
const prevMember = prevByIp.get(newMember.ip);
if (prevMember && this.hasMemberChanged(newMember, prevMember)) { 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 // 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}"]`); const card = this.findElement(`[data-member-ip="${member.ip}"]`);
if (!card) return; if (!card) return;
@@ -451,9 +441,9 @@ class ClusterMembersComponent extends Component {
<div class="member-status ${statusClass}"> <div class="member-status ${statusClass}">
${statusIcon} ${statusIcon}
</div> </div>
<div class="member-hostname">${member.hostname || 'Unknown Device'}</div> <div class="member-hostname">${this.escapeHtml(member.hostname || 'Unknown Device')}</div>
</div> </div>
<div class="member-ip">${member.ip || 'No IP'}</div> <div class="member-ip">${this.escapeHtml(member.ip || 'No IP')}</div>
<div class="member-latency"> <div class="member-latency">
<span class="latency-label">Latency:</span> <span class="latency-label">Latency:</span>
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span> <span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
@@ -462,7 +452,7 @@ class ClusterMembersComponent extends Component {
${member.labels && Object.keys(member.labels).length ? ` ${member.labels && Object.keys(member.labels).length ? `
<div class="member-row-2"> <div class="member-row-2">
<div class="member-labels"> <div class="member-labels">
${Object.entries(member.labels).map(([key, value]) => `<span class=\"label-chip\">${key}: ${value}</span>`).join('')} ${Object.entries(member.labels).map(([key, value]) => `<span class=\"label-chip\">${this.escapeHtml(key)}: ${this.escapeHtml(value)}</span>`).join('')}
</div> </div>
</div> </div>
` : ''} ` : ''}
@@ -807,8 +797,8 @@ class NodeDetailsComponent extends Component {
} }
renderNodeDetails(nodeStatus, tasks, capabilities) { renderNodeDetails(nodeStatus, tasks, capabilities) {
// Always start with 'status' tab, don't restore previous state // Use persisted active tab from the view model, default to 'status'
const activeTab = 'status'; const activeTab = (this.viewModel && typeof this.viewModel.get === 'function' && this.viewModel.get('activeTab')) || 'status';
console.log('NodeDetailsComponent: Rendering with activeTab:', activeTab); console.log('NodeDetailsComponent: Rendering with activeTab:', activeTab);
const html = ` const html = `

View File

@@ -550,7 +550,7 @@ class Component {
} }
renderError(message) { renderError(message) {
const safe = String(message || 'An error occurred'); const safe = this.escapeHtml(String(message || 'An error occurred'));
const html = ` const html = `
<div class="error"> <div class="error">
<strong>Error:</strong><br> <strong>Error:</strong><br>
@@ -569,6 +569,16 @@ class Component {
this.setHTML('', html); this.setHTML('', html);
} }
// Basic HTML escaping for dynamic values
escapeHtml(str) {
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Tab helpers // Tab helpers
setupTabs(container = this.container, options = {}) { setupTabs(container = this.container, options = {}) {
const { onChange } = options; const { onChange } = options;