feat: frontend optimization, refactoring

This commit is contained in:
2025-08-28 20:46:53 +02:00
parent 9486594199
commit c15654ef5a
4 changed files with 179 additions and 180 deletions

View File

@@ -2,23 +2,44 @@
class ApiClient { class ApiClient {
constructor() { 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() { async getClusterMembers() {
try { try {
const response = await fetch(`${this.baseUrl}/api/cluster/members`, { return await this.request('/api/cluster/members', { method: 'GET' });
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) { } catch (error) {
throw new Error(`Request failed: ${error.message}`); throw new Error(`Request failed: ${error.message}`);
} }
@@ -26,18 +47,7 @@ class ApiClient {
async getDiscoveryInfo() { async getDiscoveryInfo() {
try { try {
const response = await fetch(`${this.baseUrl}/api/discovery/nodes`, { return await this.request('/api/discovery/nodes', { method: 'GET' });
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) { } catch (error) {
throw new Error(`Request failed: ${error.message}`); throw new Error(`Request failed: ${error.message}`);
} }
@@ -45,22 +55,10 @@ class ApiClient {
async selectRandomPrimaryNode() { async selectRandomPrimaryNode() {
try { try {
const response = await fetch(`${this.baseUrl}/api/discovery/random-primary`, { return await this.request('/api/discovery/random-primary', {
method: 'POST', method: 'POST',
headers: { body: { timestamp: new Date().toISOString() }
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
timestamp: new Date().toISOString()
})
}); });
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) { } catch (error) {
throw new Error(`Request failed: ${error.message}`); throw new Error(`Request failed: ${error.message}`);
} }
@@ -68,18 +66,7 @@ class ApiClient {
async getNodeStatus(ip) { async getNodeStatus(ip) {
try { try {
const response = await fetch(`${this.baseUrl}/api/node/status/${encodeURIComponent(ip)}`, { return await this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) { } catch (error) {
throw new Error(`Request failed: ${error.message}`); throw new Error(`Request failed: ${error.message}`);
} }
@@ -87,21 +74,7 @@ class ApiClient {
async getTasksStatus(ip) { async getTasksStatus(ip) {
try { try {
const url = ip return await this.request('/api/tasks/status', { method: 'GET', query: ip ? { ip } : undefined });
? `${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();
} catch (error) { } catch (error) {
throw new Error(`Request failed: ${error.message}`); throw new Error(`Request failed: ${error.message}`);
} }
@@ -109,21 +82,7 @@ class ApiClient {
async getCapabilities(ip) { async getCapabilities(ip) {
try { try {
const url = ip return await this.request('/api/capabilities', { method: 'GET', query: ip ? { ip } : undefined });
? `${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();
} catch (error) { } catch (error) {
throw new Error(`Request failed: ${error.message}`); throw new Error(`Request failed: ${error.message}`);
} }
@@ -131,19 +90,10 @@ class ApiClient {
async callCapability({ ip, method, uri, params }) { async callCapability({ ip, method, uri, params }) {
try { try {
const response = await fetch(`${this.baseUrl}/api/proxy-call`, { return await this.request('/api/proxy-call', {
method: 'POST', method: 'POST',
headers: { body: { ip, method, uri, params }
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({ 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) { } catch (error) {
throw new Error(`Request failed: ${error.message}`); throw new Error(`Request failed: ${error.message}`);
} }
@@ -153,18 +103,13 @@ class ApiClient {
try { try {
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', file);
return await this.request(`/api/node/update`, {
const response = await fetch(`${this.baseUrl}/api/node/update?ip=${encodeURIComponent(nodeIp)}`, {
method: 'POST', 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) { } catch (error) {
throw new Error(`Upload failed: ${error.message}`); throw new Error(`Upload failed: ${error.message}`);
} }

View File

@@ -207,7 +207,7 @@ class ClusterMembersComponent extends Component {
if (isLoading) { if (isLoading) {
console.log('ClusterMembersComponent: Showing loading state'); console.log('ClusterMembersComponent: Showing loading state');
this.showLoadingState(); this.renderLoading(`\n <div class="loading">\n <div>Loading cluster members...</div>\n </div>\n `);
// Set up a loading completion check // Set up a loading completion check
this.checkLoadingCompletion(); this.checkLoadingCompletion();
@@ -402,7 +402,7 @@ class ClusterMembersComponent extends Component {
// Show loading state // Show loading state
showLoadingState() { showLoadingState() {
console.log('ClusterMembersComponent: showLoadingState() called'); console.log('ClusterMembersComponent: showLoadingState() called');
this.setHTML('', ` this.renderLoading(`
<div class="loading"> <div class="loading">
<div>Loading cluster members...</div> <div>Loading cluster members...</div>
</div> </div>
@@ -412,18 +412,13 @@ class ClusterMembersComponent extends Component {
// Show error state // Show error state
showErrorState(error) { showErrorState(error) {
console.log('ClusterMembersComponent: showErrorState() called with error:', error); console.log('ClusterMembersComponent: showErrorState() called with error:', error);
this.setHTML('', ` this.renderError(`Error loading cluster members: ${error}`);
<div class="error">
<strong>Error loading cluster members:</strong><br>
${error}
</div>
`);
} }
// Show empty state // Show empty state
showEmptyState() { showEmptyState() {
console.log('ClusterMembersComponent: showEmptyState() called'); console.log('ClusterMembersComponent: showEmptyState() called');
this.setHTML('', ` this.renderEmpty(`
<div class="empty-state"> <div class="empty-state">
<div class="empty-state-icon">🌐</div> <div class="empty-state-icon">🌐</div>
<div>No cluster members found</div> <div>No cluster members found</div>
@@ -571,16 +566,8 @@ class ClusterMembersComponent extends Component {
const targetTab = button.dataset.tab; const targetTab = button.dataset.tab;
// Remove active class from all buttons and contents // Use base helper to set active tab
tabButtons.forEach(btn => btn.classList.remove('active')); this.setActiveTab(targetTab, container);
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');
}
// Store active tab state // Store active tab state
const memberCard = container.closest('.member-card'); const memberCard = container.closest('.member-card');
@@ -772,19 +759,14 @@ class NodeDetailsComponent extends Component {
// Handle loading state update // Handle loading state update
handleLoadingUpdate(isLoading) { handleLoadingUpdate(isLoading) {
if (isLoading) { if (isLoading) {
this.setHTML('', '<div class="loading-details">Loading detailed information...</div>'); this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
} }
} }
// Handle error state update // Handle error state update
handleErrorUpdate(error) { handleErrorUpdate(error) {
if (error) { if (error) {
this.setHTML('', ` this.renderError(`Error loading node details: ${error}`);
<div class="error">
<strong>Error loading node details:</strong><br>
${error}
</div>
`);
} }
} }
@@ -811,22 +793,17 @@ class NodeDetailsComponent extends Component {
const capabilities = this.viewModel.get('capabilities'); const capabilities = this.viewModel.get('capabilities');
if (isLoading) { if (isLoading) {
this.setHTML('', '<div class="loading-details">Loading detailed information...</div>'); this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
return; return;
} }
if (error) { if (error) {
this.setHTML('', ` this.renderError(`Error loading node details: ${error}`);
<div class="error">
<strong>Error loading node details:</strong><br>
${error}
</div>
`);
return; return;
} }
if (!nodeStatus) { if (!nodeStatus) {
this.setHTML('', '<div class="loading-details">No node status available</div>'); this.renderEmpty('<div class="loading-details">No node status available</div>');
return; return;
} }
@@ -1036,7 +1013,14 @@ class NodeDetailsComponent extends Component {
} }
renderTasksTab(tasks) { renderTasksTab(tasks) {
const summary = this.viewModel.get('tasksSummary');
if (tasks && tasks.length > 0) { if (tasks && tasks.length > 0) {
const summaryHTML = summary ? `
<div class="tasks-summary">
<span>Total: ${summary.totalTasks ?? tasks.length}</span>
<span style="margin-left: 0.75rem;">Active: ${summary.activeTasks ?? tasks.filter(t => t.running).length}</span>
</div>
` : '';
const tasksHTML = tasks.map(task => ` const tasksHTML = tasks.map(task => `
<div class="task-item"> <div class="task-item">
<div class="task-header"> <div class="task-header">
@@ -1053,14 +1037,17 @@ class NodeDetailsComponent extends Component {
`).join(''); `).join('');
return ` return `
${summaryHTML}
${tasksHTML} ${tasksHTML}
`; `;
} else { } else {
const total = summary?.totalTasks ?? 0;
const active = summary?.activeTasks ?? 0;
return ` return `
<div class="no-tasks"> <div class="no-tasks">
<div>📋 No active tasks found</div> <div>📋 No active tasks found</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;"> <div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
This node has no running tasks ${total > 0 ? `Total tasks: ${total}, active: ${active}` : 'This node has no running tasks'}
</div> </div>
</div> </div>
`; `;
@@ -1096,7 +1083,7 @@ class NodeDetailsComponent extends Component {
console.log('NodeDetailsComponent: Tab clicked:', targetTab); console.log('NodeDetailsComponent: Tab clicked:', targetTab);
// Update tab UI locally, don't store in view model // 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 // Update active tab without full re-render
updateActiveTab(newTab, previousTab = null) { updateActiveTab(newTab, previousTab = null) {
const tabButtons = this.findAllElements('.tab-button'); this.setActiveTab(newTab);
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');
console.log(`NodeDetailsComponent: Active tab updated to '${newTab}'`); console.log(`NodeDetailsComponent: Active tab updated to '${newTab}'`);
} }

View File

@@ -1,5 +1,16 @@
// SPORE UI Framework - Component-based architecture with pub/sub system // 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 // Event Bus for pub/sub communication
class EventBus { class EventBus {
constructor() { constructor() {
@@ -99,24 +110,25 @@ class ViewModel {
// Set multiple properties at once with change detection // Set multiple properties at once with change detection
setMultiple(properties) { setMultiple(properties) {
const changedProperties = {}; const changedProperties = {};
const unchangedProperties = {};
// Determine changes and update previousData snapshot per key
Object.keys(properties).forEach(key => { Object.keys(properties).forEach(key => {
if (this._data[key] !== properties[key]) { const newValue = properties[key];
changedProperties[key] = properties[key]; const oldValue = this._data[key];
} else { if (oldValue !== newValue) {
unchangedProperties[key] = properties[key]; this._previousData[key] = oldValue;
changedProperties[key] = newValue;
} }
}); });
// Set all properties // Apply all properties
Object.keys(properties).forEach(key => { Object.keys(properties).forEach(key => {
this._data[key] = properties[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 => { 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) { if (Object.keys(changedProperties).length > 0) {
@@ -215,28 +227,33 @@ class ViewModel {
batchUpdate(updates, options = {}) { batchUpdate(updates, options = {}) {
const { preserveUIState = true, notifyChanges = true } = options; const { preserveUIState = true, notifyChanges = true } = options;
if (preserveUIState) { // Optionally preserve UI state snapshot
// Store current UI state const currentUIState = preserveUIState ? new Map(this._uiState) : null;
const currentUIState = new Map(this._uiState);
// Track which keys actually change and what the previous values were
// Apply updates const changedKeys = [];
Object.keys(updates).forEach(key => { Object.keys(updates).forEach(key => {
this._data[key] = updates[key]; const newValue = updates[key];
}); const oldValue = this._data[key];
if (oldValue !== newValue) {
// Restore UI state 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; 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) { if (notifyChanges) {
Object.keys(updates).forEach(key => { changedKeys.forEach(key => {
this._notifyListeners(key, updates[key], this._previousData[key]); this._notifyListeners(key, this._data[key], this._previousData[key]);
}); });
} }
} }
@@ -521,6 +538,66 @@ class Component {
element.disabled = !enabled; element.disabled = !enabled;
} }
} }
// Reusable render helpers
renderLoading(customHtml) {
const html = customHtml || `
<div class="loading">
<div>Loading...</div>
</div>
`;
this.setHTML('', html);
}
renderError(message) {
const safe = String(message || 'An error occurred');
const html = `
<div class="error">
<strong>Error:</strong><br>
${safe}
</div>
`;
this.setHTML('', html);
}
renderEmpty(customHtml) {
const html = customHtml || `
<div class="empty-state">
<div>No data</div>
</div>
`;
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 // Application class to manage components and routing

View File

@@ -212,7 +212,8 @@ class NodeDetailsViewModel extends ViewModel {
error: null, error: null,
activeTab: 'status', activeTab: 'status',
nodeIp: null, nodeIp: null,
capabilities: null capabilities: null,
tasksSummary: null
}); });
} }
@@ -255,10 +256,12 @@ class NodeDetailsViewModel extends ViewModel {
try { try {
const ip = this.get('nodeIp'); const ip = this.get('nodeIp');
const response = await window.apiClient.getTasksStatus(ip); 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) { } catch (error) {
console.error('Failed to load tasks:', error); console.error('Failed to load tasks:', error);
this.set('tasks', []); this.set('tasks', []);
this.set('tasksSummary', null);
} }
} }