From c15654ef5acee808af30f4d2400d1d7af9977f07 Mon Sep 17 00:00:00 2001 From: Patrick Balsiger Date: Thu, 28 Aug 2025 20:46:53 +0200 Subject: [PATCH] feat: frontend optimization, refactoring --- public/api-client.js | 149 +++++++++++++----------------------------- public/components.js | 74 +++++++-------------- public/framework.js | 129 ++++++++++++++++++++++++++++-------- public/view-models.js | 7 +- 4 files changed, 179 insertions(+), 180 deletions(-) diff --git a/public/api-client.js b/public/api-client.js index 480127d..ad9bbc2 100644 --- a/public/api-client.js +++ b/public/api-client.js @@ -2,23 +2,44 @@ class ApiClient { constructor() { - this.baseUrl = 'http://localhost:3001'; // Backend server URL + this.baseUrl = (typeof window !== 'undefined' && window.API_BASE_URL) || 'http://localhost:3001'; // Backend server URL + } + + async request(path, { method = 'GET', headers = {}, body = undefined, query = undefined, isForm = false } = {}) { + const url = new URL(`${this.baseUrl}${path}`); + if (query && typeof query === 'object') { + Object.entries(query).forEach(([k, v]) => { + if (v !== undefined && v !== null) url.searchParams.set(k, String(v)); + }); + } + const finalHeaders = { 'Accept': 'application/json', ...headers }; + const options = { method, headers: finalHeaders }; + if (body !== undefined) { + if (isForm) { + options.body = body; + } else { + options.headers['Content-Type'] = options.headers['Content-Type'] || 'application/json'; + options.body = typeof body === 'string' ? body : JSON.stringify(body); + } + } + const response = await fetch(url.toString(), options); + let data; + const text = await response.text(); + try { + data = text ? JSON.parse(text) : null; + } catch (_) { + data = text; // Non-JSON payload + } + if (!response.ok) { + const message = (data && data.message) || `HTTP ${response.status}: ${response.statusText}`; + throw new Error(message); + } + return data; } async getClusterMembers() { try { - const response = await fetch(`${this.baseUrl}/api/cluster/members`, { - method: 'GET', - headers: { - 'Accept': 'application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); + return await this.request('/api/cluster/members', { method: 'GET' }); } catch (error) { throw new Error(`Request failed: ${error.message}`); } @@ -26,18 +47,7 @@ class ApiClient { async getDiscoveryInfo() { try { - const response = await fetch(`${this.baseUrl}/api/discovery/nodes`, { - method: 'GET', - headers: { - 'Accept': 'application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); + return await this.request('/api/discovery/nodes', { method: 'GET' }); } catch (error) { throw new Error(`Request failed: ${error.message}`); } @@ -45,22 +55,10 @@ class ApiClient { async selectRandomPrimaryNode() { try { - const response = await fetch(`${this.baseUrl}/api/discovery/random-primary`, { + return await this.request('/api/discovery/random-primary', { method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ - timestamp: new Date().toISOString() - }) + body: { timestamp: new Date().toISOString() } }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); } catch (error) { throw new Error(`Request failed: ${error.message}`); } @@ -68,18 +66,7 @@ class ApiClient { async getNodeStatus(ip) { try { - const response = await fetch(`${this.baseUrl}/api/node/status/${encodeURIComponent(ip)}`, { - method: 'GET', - headers: { - 'Accept': 'application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); + return await this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' }); } catch (error) { throw new Error(`Request failed: ${error.message}`); } @@ -87,21 +74,7 @@ class ApiClient { async getTasksStatus(ip) { try { - const url = ip - ? `${this.baseUrl}/api/tasks/status?ip=${encodeURIComponent(ip)}` - : `${this.baseUrl}/api/tasks/status`; - const response = await fetch(url, { - method: 'GET', - headers: { - 'Accept': 'application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); + return await this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined }); } catch (error) { throw new Error(`Request failed: ${error.message}`); } @@ -109,21 +82,7 @@ class ApiClient { async getCapabilities(ip) { try { - const url = ip - ? `${this.baseUrl}/api/capabilities?ip=${encodeURIComponent(ip)}` - : `${this.baseUrl}/api/capabilities`; - const response = await fetch(url, { - method: 'GET', - headers: { - 'Accept': 'application/json' - } - }); - - if (!response.ok) { - throw new Error(`HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); + return await this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined }); } catch (error) { throw new Error(`Request failed: ${error.message}`); } @@ -131,19 +90,10 @@ class ApiClient { async callCapability({ ip, method, uri, params }) { try { - const response = await fetch(`${this.baseUrl}/api/proxy-call`, { + return await this.request('/api/proxy-call', { method: 'POST', - headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' - }, - body: JSON.stringify({ ip, method, uri, params }) + body: { ip, method, uri, params } }); - if (!response.ok) { - const errorData = await response.json().catch(() => ({})); - throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); - } - return await response.json(); } catch (error) { throw new Error(`Request failed: ${error.message}`); } @@ -153,18 +103,13 @@ class ApiClient { try { const formData = new FormData(); formData.append('file', file); - - const response = await fetch(`${this.baseUrl}/api/node/update?ip=${encodeURIComponent(nodeIp)}`, { + return await this.request(`/api/node/update`, { method: 'POST', - body: formData + query: { ip: nodeIp }, + body: formData, + isForm: true, + headers: {}, }); - - if (!response.ok) { - const errorData = await response.json(); - throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); - } - - return await response.json(); } catch (error) { throw new Error(`Upload failed: ${error.message}`); } diff --git a/public/components.js b/public/components.js index 821bc9a..796fdbc 100644 --- a/public/components.js +++ b/public/components.js @@ -207,7 +207,7 @@ class ClusterMembersComponent extends Component { if (isLoading) { console.log('ClusterMembersComponent: Showing loading state'); - this.showLoadingState(); + this.renderLoading(`\n
\n
Loading cluster members...
\n
\n `); // Set up a loading completion check this.checkLoadingCompletion(); @@ -402,7 +402,7 @@ class ClusterMembersComponent extends Component { // Show loading state showLoadingState() { console.log('ClusterMembersComponent: showLoadingState() called'); - this.setHTML('', ` + this.renderLoading(`
Loading cluster members...
@@ -412,18 +412,13 @@ class ClusterMembersComponent extends Component { // Show error state showErrorState(error) { console.log('ClusterMembersComponent: showErrorState() called with error:', error); - this.setHTML('', ` -
- Error loading cluster members:
- ${error} -
- `); + this.renderError(`Error loading cluster members: ${error}`); } // Show empty state showEmptyState() { console.log('ClusterMembersComponent: showEmptyState() called'); - this.setHTML('', ` + this.renderEmpty(`
🌐
No cluster members found
@@ -571,16 +566,8 @@ class ClusterMembersComponent extends Component { const targetTab = button.dataset.tab; - // Remove active class from all buttons and contents - tabButtons.forEach(btn => btn.classList.remove('active')); - tabContents.forEach(content => content.classList.remove('active')); - - // Add active class to clicked button and corresponding content - button.classList.add('active'); - const targetContent = container.querySelector(`#${targetTab}-tab`); - if (targetContent) { - targetContent.classList.add('active'); - } + // Use base helper to set active tab + this.setActiveTab(targetTab, container); // Store active tab state const memberCard = container.closest('.member-card'); @@ -772,19 +759,14 @@ class NodeDetailsComponent extends Component { // Handle loading state update handleLoadingUpdate(isLoading) { if (isLoading) { - this.setHTML('', '
Loading detailed information...
'); + this.renderLoading('
Loading detailed information...
'); } } // Handle error state update handleErrorUpdate(error) { if (error) { - this.setHTML('', ` -
- Error loading node details:
- ${error} -
- `); + this.renderError(`Error loading node details: ${error}`); } } @@ -811,22 +793,17 @@ class NodeDetailsComponent extends Component { const capabilities = this.viewModel.get('capabilities'); if (isLoading) { - this.setHTML('', '
Loading detailed information...
'); + this.renderLoading('
Loading detailed information...
'); return; } if (error) { - this.setHTML('', ` -
- Error loading node details:
- ${error} -
- `); + this.renderError(`Error loading node details: ${error}`); return; } if (!nodeStatus) { - this.setHTML('', '
No node status available
'); + this.renderEmpty('
No node status available
'); return; } @@ -1036,7 +1013,14 @@ class NodeDetailsComponent extends Component { } renderTasksTab(tasks) { + const summary = this.viewModel.get('tasksSummary'); if (tasks && tasks.length > 0) { + const summaryHTML = summary ? ` +
+ Total: ${summary.totalTasks ?? tasks.length} + Active: ${summary.activeTasks ?? tasks.filter(t => t.running).length} +
+ ` : ''; const tasksHTML = tasks.map(task => `
@@ -1053,14 +1037,17 @@ class NodeDetailsComponent extends Component { `).join(''); return ` + ${summaryHTML} ${tasksHTML} `; } else { + const total = summary?.totalTasks ?? 0; + const active = summary?.activeTasks ?? 0; return `
📋 No active tasks found
- This node has no running tasks + ${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
`; @@ -1096,7 +1083,7 @@ class NodeDetailsComponent extends Component { console.log('NodeDetailsComponent: Tab clicked:', targetTab); // Update tab UI locally, don't store in view model - this.updateActiveTab(targetTab); + this.setActiveTab(targetTab); }); }); @@ -1110,20 +1097,7 @@ class NodeDetailsComponent extends Component { // Update active tab without full re-render updateActiveTab(newTab, previousTab = null) { - const tabButtons = this.findAllElements('.tab-button'); - const tabContents = this.findAllElements('.tab-content'); - - // Remove active class from all buttons and contents - tabButtons.forEach(btn => btn.classList.remove('active')); - tabContents.forEach(content => content.classList.remove('active')); - - // Add active class to new active tab - const activeButton = this.findElement(`[data-tab="${newTab}"]`); - const activeContent = this.findElement(`#${newTab}-tab`); - - if (activeButton) activeButton.classList.add('active'); - if (activeContent) activeContent.classList.add('active'); - + this.setActiveTab(newTab); console.log(`NodeDetailsComponent: Active tab updated to '${newTab}'`); } diff --git a/public/framework.js b/public/framework.js index 25d9715..1196235 100644 --- a/public/framework.js +++ b/public/framework.js @@ -1,5 +1,16 @@ // SPORE UI Framework - Component-based architecture with pub/sub system +// Lightweight logger with level gating +const logger = { + debug: (...args) => { try { if (window && window.DEBUG) { console.debug(...args); } } catch (_) { /* no-op */ } }, + info: (...args) => console.info(...args), + warn: (...args) => console.warn(...args), + error: (...args) => console.error(...args), +}; +if (typeof window !== 'undefined') { + window.logger = window.logger || logger; +} + // Event Bus for pub/sub communication class EventBus { constructor() { @@ -99,24 +110,25 @@ class ViewModel { // Set multiple properties at once with change detection setMultiple(properties) { const changedProperties = {}; - const unchangedProperties = {}; + // Determine changes and update previousData snapshot per key Object.keys(properties).forEach(key => { - if (this._data[key] !== properties[key]) { - changedProperties[key] = properties[key]; - } else { - unchangedProperties[key] = properties[key]; + const newValue = properties[key]; + const oldValue = this._data[key]; + if (oldValue !== newValue) { + this._previousData[key] = oldValue; + changedProperties[key] = newValue; } }); - // Set all properties + // Apply all properties Object.keys(properties).forEach(key => { this._data[key] = properties[key]; }); - // Notify listeners only for changed properties + // Notify listeners only for changed properties with accurate previous values Object.keys(changedProperties).forEach(key => { - this._notifyListeners(key, changedProperties[key], this._previousData[key]); + this._notifyListeners(key, this._data[key], this._previousData[key]); }); if (Object.keys(changedProperties).length > 0) { @@ -215,28 +227,33 @@ class ViewModel { batchUpdate(updates, options = {}) { const { preserveUIState = true, notifyChanges = true } = options; - if (preserveUIState) { - // Store current UI state - const currentUIState = new Map(this._uiState); - - // Apply updates - Object.keys(updates).forEach(key => { - this._data[key] = updates[key]; - }); - - // Restore UI state + // Optionally preserve UI state snapshot + const currentUIState = preserveUIState ? new Map(this._uiState) : null; + + // Track which keys actually change and what the previous values were + const changedKeys = []; + Object.keys(updates).forEach(key => { + const newValue = updates[key]; + const oldValue = this._data[key]; + if (oldValue !== newValue) { + this._previousData[key] = oldValue; + this._data[key] = newValue; + changedKeys.push(key); + } else { + // Still apply to ensure consistency if needed + this._data[key] = newValue; + } + }); + + // Restore UI state if requested + if (preserveUIState && currentUIState) { this._uiState = currentUIState; - } else { - // Apply updates normally - Object.keys(updates).forEach(key => { - this._data[key] = updates[key]; - }); } - // Notify listeners if requested + // Notify listeners for changed keys if (notifyChanges) { - Object.keys(updates).forEach(key => { - this._notifyListeners(key, updates[key], this._previousData[key]); + changedKeys.forEach(key => { + this._notifyListeners(key, this._data[key], this._previousData[key]); }); } } @@ -521,6 +538,66 @@ class Component { element.disabled = !enabled; } } + + // Reusable render helpers + renderLoading(customHtml) { + const html = customHtml || ` +
+
Loading...
+
+ `; + this.setHTML('', html); + } + + renderError(message) { + const safe = String(message || 'An error occurred'); + const html = ` +
+ Error:
+ ${safe} +
+ `; + this.setHTML('', html); + } + + renderEmpty(customHtml) { + const html = customHtml || ` +
+
No data
+
+ `; + this.setHTML('', html); + } + + // Tab helpers + setupTabs(container = this.container) { + const tabButtons = container.querySelectorAll('.tab-button'); + const tabContents = container.querySelectorAll('.tab-content'); + tabButtons.forEach(button => { + this.addEventListener(button, 'click', (e) => { + e.stopPropagation(); + const targetTab = button.dataset.tab; + this.setActiveTab(targetTab, container); + }); + }); + tabContents.forEach(content => { + this.addEventListener(content, 'click', (e) => { + e.stopPropagation(); + }); + }); + } + + setActiveTab(tabName, container = this.container) { + const tabButtons = container.querySelectorAll('.tab-button'); + const tabContents = container.querySelectorAll('.tab-content'); + tabButtons.forEach(btn => btn.classList.remove('active')); + tabContents.forEach(content => content.classList.remove('active')); + const activeButton = container.querySelector(`[data-tab="${tabName}"]`); + const activeContent = container.querySelector(`#${tabName}-tab`); + if (activeButton) activeButton.classList.add('active'); + if (activeContent) activeContent.classList.add('active'); + logger.debug(`${this.constructor.name}: Active tab set to '${tabName}'`); + } } // Application class to manage components and routing diff --git a/public/view-models.js b/public/view-models.js index f30e66c..72716b7 100644 --- a/public/view-models.js +++ b/public/view-models.js @@ -212,7 +212,8 @@ class NodeDetailsViewModel extends ViewModel { error: null, activeTab: 'status', nodeIp: null, - capabilities: null + capabilities: null, + tasksSummary: null }); } @@ -255,10 +256,12 @@ class NodeDetailsViewModel extends ViewModel { try { const ip = this.get('nodeIp'); const response = await window.apiClient.getTasksStatus(ip); - this.set('tasks', response || []); + this.set('tasks', (response && Array.isArray(response.tasks)) ? response.tasks : []); + this.set('tasksSummary', response && response.summary ? response.summary : null); } catch (error) { console.error('Failed to load tasks:', error); this.set('tasks', []); + this.set('tasksSummary', null); } }