feat: frontend optimization, refactoring
This commit is contained in:
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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}'`);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 || `
|
||||
<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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user