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 {
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}`);
}

View File

@@ -207,7 +207,7 @@ class ClusterMembersComponent extends Component {
if (isLoading) {
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
this.checkLoadingCompletion();
@@ -402,7 +402,7 @@ class ClusterMembersComponent extends Component {
// Show loading state
showLoadingState() {
console.log('ClusterMembersComponent: showLoadingState() called');
this.setHTML('', `
this.renderLoading(`
<div class="loading">
<div>Loading cluster members...</div>
</div>
@@ -412,18 +412,13 @@ class ClusterMembersComponent extends Component {
// Show error state
showErrorState(error) {
console.log('ClusterMembersComponent: showErrorState() called with error:', error);
this.setHTML('', `
<div class="error">
<strong>Error loading cluster members:</strong><br>
${error}
</div>
`);
this.renderError(`Error loading cluster members: ${error}`);
}
// Show empty state
showEmptyState() {
console.log('ClusterMembersComponent: showEmptyState() called');
this.setHTML('', `
this.renderEmpty(`
<div class="empty-state">
<div class="empty-state-icon">🌐</div>
<div>No cluster members found</div>
@@ -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('', '<div class="loading-details">Loading detailed information...</div>');
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
}
}
// Handle error state update
handleErrorUpdate(error) {
if (error) {
this.setHTML('', `
<div class="error">
<strong>Error loading node details:</strong><br>
${error}
</div>
`);
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('', '<div class="loading-details">Loading detailed information...</div>');
this.renderLoading('<div class="loading-details">Loading detailed information...</div>');
return;
}
if (error) {
this.setHTML('', `
<div class="error">
<strong>Error loading node details:</strong><br>
${error}
</div>
`);
this.renderError(`Error loading node details: ${error}`);
return;
}
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;
}
@@ -1036,7 +1013,14 @@ class NodeDetailsComponent extends Component {
}
renderTasksTab(tasks) {
const summary = this.viewModel.get('tasksSummary');
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 => `
<div class="task-item">
<div class="task-header">
@@ -1053,14 +1037,17 @@ class NodeDetailsComponent extends Component {
`).join('');
return `
${summaryHTML}
${tasksHTML}
`;
} else {
const total = summary?.totalTasks ?? 0;
const active = summary?.activeTasks ?? 0;
return `
<div class="no-tasks">
<div>📋 No active tasks found</div>
<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>
`;
@@ -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}'`);
}

View File

@@ -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);
// Optionally preserve UI state snapshot
const currentUIState = preserveUIState ? new Map(this._uiState) : null;
// Apply updates
Object.keys(updates).forEach(key => {
this._data[key] = updates[key];
});
// 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
// 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 || `
<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

View File

@@ -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);
}
}