chore: restructure public files
This commit is contained in:
144
public/scripts/api-client.js
Normal file
144
public/scripts/api-client.js
Normal file
@@ -0,0 +1,144 @@
|
||||
// API Client for communicating with the backend
|
||||
|
||||
class ApiClient {
|
||||
constructor() {
|
||||
// Auto-detect server URL based on current location
|
||||
const currentHost = window.location.hostname;
|
||||
const currentPort = window.location.port;
|
||||
|
||||
// If accessing from localhost, use localhost:3001
|
||||
// If accessing from another device, use the same hostname but port 3001
|
||||
if (currentHost === 'localhost' || currentHost === '127.0.0.1') {
|
||||
this.baseUrl = 'http://localhost:3001';
|
||||
} else {
|
||||
// Use the same hostname but port 3001
|
||||
this.baseUrl = `http://${currentHost}:3001`;
|
||||
}
|
||||
|
||||
console.log('API Client initialized with base URL:', this.baseUrl);
|
||||
}
|
||||
|
||||
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 {
|
||||
return await this.request('/api/cluster/members', { method: 'GET' });
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getClusterMembersFromNode(ip) {
|
||||
try {
|
||||
return await this.request(`/api/cluster/members`, {
|
||||
method: 'GET',
|
||||
query: { ip: ip }
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getDiscoveryInfo() {
|
||||
try {
|
||||
return await this.request('/api/discovery/nodes', { method: 'GET' });
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async selectRandomPrimaryNode() {
|
||||
try {
|
||||
return await this.request('/api/discovery/random-primary', {
|
||||
method: 'POST',
|
||||
body: { timestamp: new Date().toISOString() }
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getNodeStatus(ip) {
|
||||
try {
|
||||
return await this.request(`/api/node/status/${encodeURIComponent(ip)}`, { method: 'GET' });
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async getTasksStatus(ip) {
|
||||
try {
|
||||
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) {
|
||||
try {
|
||||
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 }) {
|
||||
try {
|
||||
return await this.request('/api/proxy-call', {
|
||||
method: 'POST',
|
||||
body: { ip, method, uri, params }
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Request failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async uploadFirmware(file, nodeIp) {
|
||||
try {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
return await this.request(`/api/node/update`, {
|
||||
method: 'POST',
|
||||
query: { ip: nodeIp },
|
||||
body: formData,
|
||||
isForm: true,
|
||||
headers: {},
|
||||
});
|
||||
} catch (error) {
|
||||
throw new Error(`Upload failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Global API client instance
|
||||
window.apiClient = new ApiClient();
|
||||
229
public/scripts/app.js
Normal file
229
public/scripts/app.js
Normal file
@@ -0,0 +1,229 @@
|
||||
// Main SPORE UI Application
|
||||
|
||||
// Initialize the application when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('=== SPORE UI Application Initialization ===');
|
||||
|
||||
// Initialize the framework (but don't navigate yet)
|
||||
console.log('App: Creating framework instance...');
|
||||
const app = window.app;
|
||||
|
||||
// Create view models
|
||||
console.log('App: Creating view models...');
|
||||
const clusterViewModel = new ClusterViewModel();
|
||||
const firmwareViewModel = new FirmwareViewModel();
|
||||
const topologyViewModel = new TopologyViewModel();
|
||||
console.log('App: View models created:', { clusterViewModel, firmwareViewModel, topologyViewModel });
|
||||
|
||||
// Connect firmware view model to cluster data
|
||||
clusterViewModel.subscribe('members', (members) => {
|
||||
console.log('App: Members subscription triggered:', members);
|
||||
if (members && members.length > 0) {
|
||||
// Extract node information for firmware view
|
||||
const nodes = members.map(member => ({
|
||||
ip: member.ip,
|
||||
hostname: member.hostname || member.ip,
|
||||
labels: member.labels || {}
|
||||
}));
|
||||
firmwareViewModel.updateAvailableNodes(nodes);
|
||||
console.log('App: Updated firmware view model with nodes:', nodes);
|
||||
} else {
|
||||
firmwareViewModel.updateAvailableNodes([]);
|
||||
console.log('App: Cleared firmware view model nodes');
|
||||
}
|
||||
});
|
||||
|
||||
// Register routes with their view models
|
||||
console.log('App: Registering routes...');
|
||||
app.registerRoute('cluster', ClusterViewComponent, 'cluster-view', clusterViewModel);
|
||||
app.registerRoute('topology', TopologyGraphComponent, 'topology-view', topologyViewModel);
|
||||
app.registerRoute('firmware', FirmwareViewComponent, 'firmware-view', firmwareViewModel);
|
||||
console.log('App: Routes registered and components pre-initialized');
|
||||
|
||||
// Initialize cluster status component for header badge AFTER main components
|
||||
// DISABLED - causes interference with main cluster functionality
|
||||
/*
|
||||
console.log('App: Initializing cluster status component...');
|
||||
const clusterStatusComponent = new ClusterStatusComponent(
|
||||
document.querySelector('.cluster-status'),
|
||||
clusterViewModel,
|
||||
app.eventBus
|
||||
);
|
||||
clusterStatusComponent.initialize();
|
||||
console.log('App: Cluster status component initialized');
|
||||
*/
|
||||
|
||||
// Set up navigation event listeners
|
||||
console.log('App: Setting up navigation...');
|
||||
app.setupNavigation();
|
||||
|
||||
// Set up cluster status updates (simple approach without component interference)
|
||||
setupClusterStatusUpdates(clusterViewModel);
|
||||
|
||||
// Set up periodic updates for cluster view with state preservation
|
||||
// setupPeriodicUpdates(); // Disabled automatic refresh
|
||||
|
||||
// Now navigate to the default route
|
||||
console.log('App: Navigating to default route...');
|
||||
app.navigateTo('cluster');
|
||||
|
||||
console.log('=== SPORE UI Application initialization completed ===');
|
||||
});
|
||||
|
||||
// Burger menu toggle for mobile
|
||||
(function setupBurgerMenu(){
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
const nav = document.querySelector('.main-navigation');
|
||||
const burger = document.getElementById('burger-btn');
|
||||
const navLeft = nav ? nav.querySelector('.nav-left') : null;
|
||||
if (!nav || !burger || !navLeft) return;
|
||||
burger.addEventListener('click', function(e){
|
||||
e.preventDefault();
|
||||
nav.classList.toggle('mobile-open');
|
||||
});
|
||||
// Close menu when a nav tab is clicked
|
||||
navLeft.addEventListener('click', function(e){
|
||||
const btn = e.target.closest('.nav-tab');
|
||||
if (btn && nav.classList.contains('mobile-open')) {
|
||||
nav.classList.remove('mobile-open');
|
||||
}
|
||||
});
|
||||
// Close menu on outside click
|
||||
document.addEventListener('click', function(e){
|
||||
if (!nav.contains(e.target) && nav.classList.contains('mobile-open')) {
|
||||
nav.classList.remove('mobile-open');
|
||||
}
|
||||
});
|
||||
});
|
||||
})();
|
||||
|
||||
// Set up periodic updates with state preservation
|
||||
function setupPeriodicUpdates() {
|
||||
// Auto-refresh cluster members every 30 seconds using smart update
|
||||
setInterval(() => {
|
||||
if (window.app.currentView && window.app.currentView.viewModel) {
|
||||
const viewModel = window.app.currentView.viewModel;
|
||||
|
||||
// Use smart update if available, otherwise fall back to regular update
|
||||
if (viewModel.smartUpdate && typeof viewModel.smartUpdate === 'function') {
|
||||
console.log('App: Performing smart update to preserve UI state...');
|
||||
viewModel.smartUpdate();
|
||||
} else if (viewModel.updateClusterMembers && typeof viewModel.updateClusterMembers === 'function') {
|
||||
console.log('App: Performing regular update...');
|
||||
viewModel.updateClusterMembers();
|
||||
}
|
||||
}
|
||||
}, 30000);
|
||||
|
||||
// Update primary node display every 10 seconds (this is lightweight and doesn't affect UI state)
|
||||
setInterval(() => {
|
||||
if (window.app.currentView && window.app.currentView.viewModel) {
|
||||
const viewModel = window.app.currentView.viewModel;
|
||||
if (viewModel.updatePrimaryNodeDisplay && typeof viewModel.updatePrimaryNodeDisplay === 'function') {
|
||||
viewModel.updatePrimaryNodeDisplay();
|
||||
}
|
||||
}
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
// Set up cluster status updates (simple approach without component interference)
|
||||
function setupClusterStatusUpdates(clusterViewModel) {
|
||||
// Set initial "discovering" state immediately
|
||||
updateClusterStatusBadge(undefined, undefined, undefined);
|
||||
|
||||
// Force a fresh fetch and keep showing "discovering" until we get real data
|
||||
let hasReceivedRealData = false;
|
||||
|
||||
// Subscribe to view model changes to update cluster status
|
||||
clusterViewModel.subscribe('totalNodes', (totalNodes) => {
|
||||
if (hasReceivedRealData) {
|
||||
updateClusterStatusBadge(totalNodes, clusterViewModel.get('clientInitialized'), clusterViewModel.get('error'));
|
||||
}
|
||||
});
|
||||
|
||||
clusterViewModel.subscribe('clientInitialized', (clientInitialized) => {
|
||||
if (hasReceivedRealData) {
|
||||
updateClusterStatusBadge(clusterViewModel.get('totalNodes'), clientInitialized, clusterViewModel.get('error'));
|
||||
}
|
||||
});
|
||||
|
||||
clusterViewModel.subscribe('error', (error) => {
|
||||
if (hasReceivedRealData) {
|
||||
updateClusterStatusBadge(clusterViewModel.get('totalNodes'), clusterViewModel.get('clientInitialized'), error);
|
||||
}
|
||||
});
|
||||
|
||||
// Force a fresh fetch and only update status after we get real data
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
console.log('Cluster Status: Forcing fresh fetch from backend...');
|
||||
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
||||
console.log('Cluster Status: Got fresh data:', discoveryInfo);
|
||||
|
||||
// Now we have real data, mark it and update the status
|
||||
hasReceivedRealData = true;
|
||||
updateClusterStatusBadge(discoveryInfo.totalNodes, discoveryInfo.clientInitialized, null);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Cluster Status: Failed to fetch fresh data:', error);
|
||||
hasReceivedRealData = true;
|
||||
updateClusterStatusBadge(0, false, error.message);
|
||||
}
|
||||
}, 100); // Small delay to ensure view model is ready
|
||||
}
|
||||
|
||||
function updateClusterStatusBadge(totalNodes, clientInitialized, error) {
|
||||
const clusterStatusBadge = document.querySelector('.cluster-status');
|
||||
if (!clusterStatusBadge) return;
|
||||
|
||||
let statusText, statusIcon, statusClass;
|
||||
|
||||
// Check if we're still in initial state (no real data yet)
|
||||
const hasRealData = totalNodes !== undefined && clientInitialized !== undefined;
|
||||
|
||||
if (!hasRealData) {
|
||||
statusText = 'Cluster Discovering...';
|
||||
statusIcon = '🔍';
|
||||
statusClass = 'cluster-status-discovering';
|
||||
} else if (error || totalNodes === 0) {
|
||||
// Show "Cluster Offline" for both errors and when no nodes are discovered
|
||||
statusText = 'Cluster Offline';
|
||||
statusIcon = '🔴';
|
||||
statusClass = 'cluster-status-offline';
|
||||
} else if (clientInitialized) {
|
||||
statusText = 'Cluster Online';
|
||||
statusIcon = '🟢';
|
||||
statusClass = 'cluster-status-online';
|
||||
} else {
|
||||
statusText = 'Cluster Connecting';
|
||||
statusIcon = '🟡';
|
||||
statusClass = 'cluster-status-connecting';
|
||||
}
|
||||
|
||||
// Update the badge
|
||||
clusterStatusBadge.innerHTML = `${statusIcon} ${statusText}`;
|
||||
|
||||
// Remove all existing status classes
|
||||
clusterStatusBadge.classList.remove('cluster-status-online', 'cluster-status-offline', 'cluster-status-connecting', 'cluster-status-error', 'cluster-status-discovering');
|
||||
|
||||
// Add the appropriate status class
|
||||
clusterStatusBadge.classList.add(statusClass);
|
||||
}
|
||||
|
||||
// Global error handler
|
||||
window.addEventListener('error', function(event) {
|
||||
console.error('Global error:', event.error);
|
||||
});
|
||||
|
||||
// Global unhandled promise rejection handler
|
||||
window.addEventListener('unhandledrejection', function(event) {
|
||||
console.error('Unhandled promise rejection:', event.reason);
|
||||
});
|
||||
|
||||
// Clean up on page unload
|
||||
window.addEventListener('beforeunload', function() {
|
||||
if (window.app) {
|
||||
console.log('App: Cleaning up cached components...');
|
||||
window.app.cleanup();
|
||||
}
|
||||
});
|
||||
3161
public/scripts/components.js
Normal file
3161
public/scripts/components.js
Normal file
File diff suppressed because it is too large
Load Diff
862
public/scripts/framework.js
Normal file
862
public/scripts/framework.js
Normal file
@@ -0,0 +1,862 @@
|
||||
// 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() {
|
||||
this.events = new Map();
|
||||
}
|
||||
|
||||
// Subscribe to an event
|
||||
subscribe(event, callback) {
|
||||
if (!this.events.has(event)) {
|
||||
this.events.set(event, []);
|
||||
}
|
||||
this.events.get(event).push(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const callbacks = this.events.get(event);
|
||||
if (callbacks) {
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Publish an event
|
||||
publish(event, data) {
|
||||
if (this.events.has(event)) {
|
||||
this.events.get(event).forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
console.error(`Error in event callback for ${event}:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Unsubscribe from an event
|
||||
unsubscribe(event, callback) {
|
||||
if (this.events.has(event)) {
|
||||
const callbacks = this.events.get(event);
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all events
|
||||
clear() {
|
||||
this.events.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Base ViewModel class with enhanced state management
|
||||
class ViewModel {
|
||||
constructor() {
|
||||
this._data = {};
|
||||
this._listeners = new Map();
|
||||
this._eventBus = null;
|
||||
this._uiState = new Map(); // Store UI state like active tabs, expanded cards, etc.
|
||||
this._previousData = {}; // Store previous data for change detection
|
||||
}
|
||||
|
||||
// Set the event bus for this view model
|
||||
setEventBus(eventBus) {
|
||||
this._eventBus = eventBus;
|
||||
}
|
||||
|
||||
// Get data property
|
||||
get(property) {
|
||||
return this._data[property];
|
||||
}
|
||||
|
||||
// Set data property and notify listeners
|
||||
set(property, value) {
|
||||
console.log(`ViewModel: Setting property '${property}' to:`, value);
|
||||
|
||||
// Check if the value has actually changed
|
||||
const hasChanged = this._data[property] !== value;
|
||||
|
||||
if (hasChanged) {
|
||||
// Store previous value for change detection
|
||||
this._previousData[property] = this._data[property];
|
||||
|
||||
// Update the data
|
||||
this._data[property] = value;
|
||||
|
||||
console.log(`ViewModel: Property '${property}' changed, notifying listeners...`);
|
||||
this._notifyListeners(property, value, this._previousData[property]);
|
||||
} else {
|
||||
console.log(`ViewModel: Property '${property}' unchanged, skipping notification`);
|
||||
}
|
||||
}
|
||||
|
||||
// Set multiple properties at once with change detection
|
||||
setMultiple(properties) {
|
||||
const changedProperties = {};
|
||||
|
||||
// Determine changes and update previousData snapshot per key
|
||||
Object.keys(properties).forEach(key => {
|
||||
const newValue = properties[key];
|
||||
const oldValue = this._data[key];
|
||||
if (oldValue !== newValue) {
|
||||
this._previousData[key] = oldValue;
|
||||
changedProperties[key] = newValue;
|
||||
}
|
||||
});
|
||||
|
||||
// Apply all properties
|
||||
Object.keys(properties).forEach(key => {
|
||||
this._data[key] = properties[key];
|
||||
});
|
||||
|
||||
// Notify listeners only for changed properties with accurate previous values
|
||||
Object.keys(changedProperties).forEach(key => {
|
||||
this._notifyListeners(key, this._data[key], this._previousData[key]);
|
||||
});
|
||||
|
||||
if (Object.keys(changedProperties).length > 0) {
|
||||
console.log(`ViewModel: Updated ${Object.keys(changedProperties).length} changed properties:`, Object.keys(changedProperties));
|
||||
}
|
||||
}
|
||||
|
||||
// Subscribe to property changes
|
||||
subscribe(property, callback) {
|
||||
if (!this._listeners.has(property)) {
|
||||
this._listeners.set(property, []);
|
||||
}
|
||||
this._listeners.get(property).push(callback);
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const callbacks = this._listeners.get(property);
|
||||
if (callbacks) {
|
||||
const index = callbacks.indexOf(callback);
|
||||
if (index > -1) {
|
||||
callbacks.splice(index, 1);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// Notify listeners of property changes
|
||||
_notifyListeners(property, value, previousValue) {
|
||||
console.log(`ViewModel: _notifyListeners called for property '${property}'`);
|
||||
if (this._listeners.has(property)) {
|
||||
const callbacks = this._listeners.get(property);
|
||||
console.log(`ViewModel: Found ${callbacks.length} listeners for property '${property}'`);
|
||||
callbacks.forEach((callback, index) => {
|
||||
try {
|
||||
console.log(`ViewModel: Calling listener ${index} for property '${property}'`);
|
||||
callback(value, previousValue);
|
||||
} catch (error) {
|
||||
console.error(`Error in property listener for ${property}:`, error);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(`ViewModel: No listeners found for property '${property}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// Publish event to event bus
|
||||
publish(event, data) {
|
||||
if (this._eventBus) {
|
||||
this._eventBus.publish(event, data);
|
||||
}
|
||||
}
|
||||
|
||||
// Get all data
|
||||
getAll() {
|
||||
return { ...this._data };
|
||||
}
|
||||
|
||||
// Clear all data
|
||||
clear() {
|
||||
this._data = {};
|
||||
this._listeners.clear();
|
||||
}
|
||||
|
||||
// UI State Management Methods
|
||||
setUIState(key, value) {
|
||||
this._uiState.set(key, value);
|
||||
}
|
||||
|
||||
getUIState(key) {
|
||||
return this._uiState.get(key);
|
||||
}
|
||||
|
||||
getAllUIState() {
|
||||
return new Map(this._uiState);
|
||||
}
|
||||
|
||||
clearUIState(key) {
|
||||
if (key) {
|
||||
this._uiState.delete(key);
|
||||
} else {
|
||||
this._uiState.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Check if a property has changed
|
||||
hasChanged(property) {
|
||||
return this._data[property] !== this._previousData[property];
|
||||
}
|
||||
|
||||
// Get previous value of a property
|
||||
getPrevious(property) {
|
||||
return this._previousData[property];
|
||||
}
|
||||
|
||||
// Batch update with change detection
|
||||
batchUpdate(updates, options = {}) {
|
||||
const { preserveUIState = true, notifyChanges = true } = options;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Notify listeners for changed keys
|
||||
if (notifyChanges) {
|
||||
changedKeys.forEach(key => {
|
||||
this._notifyListeners(key, this._data[key], this._previousData[key]);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Base Component class with enhanced state preservation
|
||||
class Component {
|
||||
constructor(container, viewModel, eventBus) {
|
||||
this.container = container;
|
||||
this.viewModel = viewModel;
|
||||
this.eventBus = eventBus;
|
||||
this.isMounted = false;
|
||||
this.unsubscribers = [];
|
||||
this.uiState = new Map(); // Local UI state for this component
|
||||
|
||||
// Set event bus on view model
|
||||
if (this.viewModel) {
|
||||
this.viewModel.setEventBus(eventBus);
|
||||
}
|
||||
|
||||
// Bind methods
|
||||
this.render = this.render.bind(this);
|
||||
this.mount = this.mount.bind(this);
|
||||
this.unmount = this.unmount.bind(this);
|
||||
this.updatePartial = this.updatePartial.bind(this);
|
||||
}
|
||||
|
||||
// Mount the component
|
||||
mount() {
|
||||
if (this.isMounted) return;
|
||||
|
||||
console.log(`${this.constructor.name}: Starting mount...`);
|
||||
this.isMounted = true;
|
||||
this.setupEventListeners();
|
||||
this.setupViewModelListeners();
|
||||
this.render();
|
||||
|
||||
console.log(`${this.constructor.name}: Mounted successfully`);
|
||||
}
|
||||
|
||||
// Unmount the component
|
||||
unmount() {
|
||||
if (!this.isMounted) return;
|
||||
|
||||
this.isMounted = false;
|
||||
this.cleanupEventListeners();
|
||||
this.cleanupViewModelListeners();
|
||||
|
||||
console.log(`${this.constructor.name} unmounted`);
|
||||
}
|
||||
|
||||
// Pause the component (keep alive but pause activity)
|
||||
pause() {
|
||||
if (!this.isMounted) return;
|
||||
|
||||
console.log(`${this.constructor.name}: Pausing component`);
|
||||
|
||||
// Pause any active timers or animations
|
||||
if (this.updateInterval) {
|
||||
clearInterval(this.updateInterval);
|
||||
this.updateInterval = null;
|
||||
}
|
||||
|
||||
// Pause any ongoing operations
|
||||
this.isPaused = true;
|
||||
|
||||
// Override in subclasses to pause specific functionality
|
||||
this.onPause();
|
||||
}
|
||||
|
||||
// Resume the component
|
||||
resume() {
|
||||
if (!this.isMounted || !this.isPaused) return;
|
||||
|
||||
console.log(`${this.constructor.name}: Resuming component`);
|
||||
|
||||
this.isPaused = false;
|
||||
|
||||
// Restart any necessary timers or operations
|
||||
this.onResume();
|
||||
|
||||
// Re-render if needed
|
||||
if (this.shouldRenderOnResume()) {
|
||||
this.render();
|
||||
}
|
||||
}
|
||||
|
||||
// Override in subclasses to handle pause-specific logic
|
||||
onPause() {
|
||||
// Default implementation does nothing
|
||||
}
|
||||
|
||||
// Override in subclasses to handle resume-specific logic
|
||||
onResume() {
|
||||
// Default implementation does nothing
|
||||
}
|
||||
|
||||
// Override in subclasses to determine if re-render is needed on resume
|
||||
shouldRenderOnResume() {
|
||||
// Default: don't re-render on resume
|
||||
return false;
|
||||
}
|
||||
|
||||
// Setup event listeners (override in subclasses)
|
||||
setupEventListeners() {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
// Setup view model listeners (override in subclasses)
|
||||
setupViewModelListeners() {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
// Cleanup event listeners (override in subclasses)
|
||||
cleanupEventListeners() {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
// Cleanup view model listeners (override in subclasses)
|
||||
cleanupViewModelListeners() {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
// Render the component (override in subclasses)
|
||||
render() {
|
||||
// Override in subclasses
|
||||
}
|
||||
|
||||
// Partial update method for efficient data updates
|
||||
updatePartial(property, newValue, previousValue) {
|
||||
// Override in subclasses to implement partial updates
|
||||
console.log(`${this.constructor.name}: Partial update for '${property}':`, { newValue, previousValue });
|
||||
}
|
||||
|
||||
// UI State Management Methods
|
||||
setUIState(key, value) {
|
||||
this.uiState.set(key, value);
|
||||
// Also store in view model for persistence across refreshes
|
||||
if (this.viewModel) {
|
||||
this.viewModel.setUIState(key, value);
|
||||
}
|
||||
}
|
||||
|
||||
getUIState(key) {
|
||||
// First try local state, then view model state
|
||||
return this.uiState.get(key) || (this.viewModel ? this.viewModel.getUIState(key) : null);
|
||||
}
|
||||
|
||||
getAllUIState() {
|
||||
const localState = new Map(this.uiState);
|
||||
const viewModelState = this.viewModel ? this.viewModel.getAllUIState() : new Map();
|
||||
|
||||
// Merge states, with local state taking precedence
|
||||
const mergedState = new Map(viewModelState);
|
||||
localState.forEach((value, key) => mergedState.set(key, value));
|
||||
|
||||
return mergedState;
|
||||
}
|
||||
|
||||
clearUIState(key) {
|
||||
if (key) {
|
||||
this.uiState.delete(key);
|
||||
if (this.viewModel) {
|
||||
this.viewModel.clearUIState(key);
|
||||
}
|
||||
} else {
|
||||
this.uiState.clear();
|
||||
if (this.viewModel) {
|
||||
this.viewModel.clearUIState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Restore UI state from view model
|
||||
restoreUIState() {
|
||||
if (this.viewModel) {
|
||||
const viewModelState = this.viewModel.getAllUIState();
|
||||
viewModelState.forEach((value, key) => {
|
||||
this.uiState.set(key, value);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to add event listener and track for cleanup
|
||||
addEventListener(element, event, handler) {
|
||||
element.addEventListener(event, handler);
|
||||
this.unsubscribers.push(() => {
|
||||
element.removeEventListener(event, handler);
|
||||
});
|
||||
}
|
||||
|
||||
// Helper method to subscribe to event bus and track for cleanup
|
||||
subscribeToEvent(event, handler) {
|
||||
const unsubscribe = this.eventBus.subscribe(event, handler);
|
||||
this.unsubscribers.push(unsubscribe);
|
||||
}
|
||||
|
||||
// Helper method to subscribe to view model property and track for cleanup
|
||||
subscribeToProperty(property, handler) {
|
||||
if (this.viewModel) {
|
||||
const unsubscribe = this.viewModel.subscribe(property, (newValue, previousValue) => {
|
||||
// Call handler with both new and previous values for change detection
|
||||
handler(newValue, previousValue);
|
||||
});
|
||||
this.unsubscribers.push(unsubscribe);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to find element within component container
|
||||
findElement(selector) {
|
||||
return this.container.querySelector(selector);
|
||||
}
|
||||
|
||||
// Helper method to find all elements within component container
|
||||
findAllElements(selector) {
|
||||
return this.container.querySelectorAll(selector);
|
||||
}
|
||||
|
||||
// Helper method to set innerHTML safely
|
||||
setHTML(selector, html) {
|
||||
console.log(`${this.constructor.name}: setHTML called with selector '${selector}', html length: ${html.length}`);
|
||||
|
||||
let element;
|
||||
if (selector === '') {
|
||||
// Empty selector means set HTML on the component's container itself
|
||||
element = this.container;
|
||||
console.log(`${this.constructor.name}: Using component container for empty selector`);
|
||||
} else {
|
||||
// Find element within the component's container
|
||||
element = this.findElement(selector);
|
||||
}
|
||||
|
||||
if (element) {
|
||||
console.log(`${this.constructor.name}: Element found, setting innerHTML`);
|
||||
element.innerHTML = html;
|
||||
console.log(`${this.constructor.name}: innerHTML set successfully`);
|
||||
} else {
|
||||
console.error(`${this.constructor.name}: Element not found for selector '${selector}'`);
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to set text content safely
|
||||
setText(selector, text) {
|
||||
const element = this.findElement(selector);
|
||||
if (element) {
|
||||
element.textContent = text;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to add/remove CSS classes
|
||||
setClass(selector, className, add = true) {
|
||||
const element = this.findElement(selector);
|
||||
if (element) {
|
||||
if (add) {
|
||||
element.classList.add(className);
|
||||
} else {
|
||||
element.classList.remove(className);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to set CSS styles
|
||||
setStyle(selector, property, value) {
|
||||
const element = this.findElement(selector);
|
||||
if (element) {
|
||||
element.style[property] = value;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to show/hide elements
|
||||
setVisible(selector, visible) {
|
||||
const element = this.findElement(selector);
|
||||
if (element) {
|
||||
element.style.display = visible ? '' : 'none';
|
||||
}
|
||||
}
|
||||
|
||||
// Helper method to enable/disable elements
|
||||
setEnabled(selector, enabled) {
|
||||
const element = this.findElement(selector);
|
||||
if (element) {
|
||||
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
|
||||
class App {
|
||||
constructor() {
|
||||
this.eventBus = new EventBus();
|
||||
this.components = new Map();
|
||||
this.currentView = null;
|
||||
this.routes = new Map();
|
||||
this.navigationInProgress = false;
|
||||
this.navigationQueue = [];
|
||||
this.lastNavigationTime = 0;
|
||||
this.navigationCooldown = 300; // 300ms cooldown between navigations
|
||||
|
||||
// Component cache to keep components alive
|
||||
this.componentCache = new Map();
|
||||
this.cachedViews = new Set();
|
||||
}
|
||||
|
||||
// Register a route
|
||||
registerRoute(name, componentClass, containerId, viewModel = null) {
|
||||
this.routes.set(name, { componentClass, containerId, viewModel });
|
||||
|
||||
// Pre-initialize component in cache for better performance
|
||||
this.preInitializeComponent(name, componentClass, containerId, viewModel);
|
||||
}
|
||||
|
||||
// Pre-initialize component in cache
|
||||
preInitializeComponent(name, componentClass, containerId, viewModel) {
|
||||
const container = document.getElementById(containerId);
|
||||
if (!container) return;
|
||||
|
||||
// Create component instance but don't mount it yet
|
||||
const component = new componentClass(container, viewModel, this.eventBus);
|
||||
component.routeName = name;
|
||||
component.isCached = true;
|
||||
|
||||
// Store in cache
|
||||
this.componentCache.set(name, component);
|
||||
console.log(`App: Pre-initialized component for route '${name}'`);
|
||||
}
|
||||
|
||||
// Navigate to a route
|
||||
navigateTo(routeName) {
|
||||
// Check cooldown period
|
||||
const now = Date.now();
|
||||
if (now - this.lastNavigationTime < this.navigationCooldown) {
|
||||
console.log(`App: Navigation cooldown active, skipping route '${routeName}'`);
|
||||
return;
|
||||
}
|
||||
|
||||
// If navigation is already in progress, queue this request
|
||||
if (this.navigationInProgress) {
|
||||
console.log(`App: Navigation in progress, queuing route '${routeName}'`);
|
||||
if (!this.navigationQueue.includes(routeName)) {
|
||||
this.navigationQueue.push(routeName);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// If trying to navigate to the same route, do nothing
|
||||
if (this.currentView && this.currentView.routeName === routeName) {
|
||||
console.log(`App: Already on route '${routeName}', skipping navigation`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.lastNavigationTime = now;
|
||||
this.performNavigation(routeName);
|
||||
}
|
||||
|
||||
// Perform the actual navigation
|
||||
async performNavigation(routeName) {
|
||||
this.navigationInProgress = true;
|
||||
|
||||
try {
|
||||
console.log(`App: Navigating to route '${routeName}'`);
|
||||
const route = this.routes.get(routeName);
|
||||
if (!route) {
|
||||
console.error(`Route '${routeName}' not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`App: Route found, component: ${route.componentClass.name}, container: ${route.containerId}`);
|
||||
|
||||
// Get or create component from cache
|
||||
let component = this.componentCache.get(routeName);
|
||||
if (!component) {
|
||||
console.log(`App: Component not in cache, creating new instance for '${routeName}'`);
|
||||
const container = document.getElementById(route.containerId);
|
||||
if (!container) {
|
||||
console.error(`Container '${route.containerId}' not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
component = new route.componentClass(container, route.viewModel, this.eventBus);
|
||||
component.routeName = routeName;
|
||||
component.isCached = true;
|
||||
this.componentCache.set(routeName, component);
|
||||
}
|
||||
|
||||
// Hide current view smoothly
|
||||
if (this.currentView) {
|
||||
console.log('App: Hiding current view');
|
||||
await this.hideCurrentView();
|
||||
}
|
||||
|
||||
// Show new view
|
||||
console.log(`App: Showing new view '${routeName}'`);
|
||||
await this.showView(routeName, component);
|
||||
|
||||
// Update navigation state
|
||||
this.updateNavigation(routeName);
|
||||
|
||||
// Set as current view
|
||||
this.currentView = component;
|
||||
|
||||
// Mark view as cached for future use
|
||||
this.cachedViews.add(routeName);
|
||||
|
||||
console.log(`App: Navigation to '${routeName}' completed`);
|
||||
|
||||
} catch (error) {
|
||||
console.error('App: Navigation failed:', error);
|
||||
} finally {
|
||||
this.navigationInProgress = false;
|
||||
|
||||
// Process any queued navigation requests
|
||||
if (this.navigationQueue.length > 0) {
|
||||
const nextRoute = this.navigationQueue.shift();
|
||||
console.log(`App: Processing queued navigation to '${nextRoute}'`);
|
||||
setTimeout(() => this.navigateTo(nextRoute), 100);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Hide current view smoothly
|
||||
async hideCurrentView() {
|
||||
if (!this.currentView) return;
|
||||
|
||||
// If component is mounted, pause it instead of unmounting
|
||||
if (this.currentView.isMounted) {
|
||||
console.log('App: Pausing current view instead of unmounting');
|
||||
this.currentView.pause();
|
||||
}
|
||||
|
||||
// Fade out the container
|
||||
if (this.currentView.container) {
|
||||
this.currentView.container.style.opacity = '0';
|
||||
this.currentView.container.style.transition = 'opacity 0.15s ease-out';
|
||||
}
|
||||
|
||||
// Wait for fade out to complete
|
||||
await new Promise(resolve => setTimeout(resolve, 150));
|
||||
}
|
||||
|
||||
// Show view smoothly
|
||||
async showView(routeName, component) {
|
||||
const container = component.container;
|
||||
|
||||
// Ensure component is mounted (but not necessarily active)
|
||||
if (!component.isMounted) {
|
||||
console.log(`App: Mounting component for '${routeName}'`);
|
||||
component.mount();
|
||||
} else {
|
||||
console.log(`App: Resuming component for '${routeName}'`);
|
||||
component.resume();
|
||||
}
|
||||
|
||||
// Fade in the container
|
||||
container.style.opacity = '0';
|
||||
container.style.transition = 'opacity 0.2s ease-in';
|
||||
|
||||
// Small delay to ensure smooth transition
|
||||
await new Promise(resolve => setTimeout(resolve, 50));
|
||||
|
||||
// Fade in
|
||||
container.style.opacity = '1';
|
||||
}
|
||||
|
||||
// Update navigation state
|
||||
updateNavigation(activeRoute) {
|
||||
// Remove active class from all nav tabs
|
||||
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||
tab.classList.remove('active');
|
||||
});
|
||||
|
||||
// Add active class to current route tab
|
||||
const activeTab = document.querySelector(`[data-view="${activeRoute}"]`);
|
||||
if (activeTab) {
|
||||
activeTab.classList.add('active');
|
||||
}
|
||||
|
||||
// Hide all view contents with smooth transition
|
||||
document.querySelectorAll('.view-content').forEach(view => {
|
||||
view.classList.remove('active');
|
||||
view.style.opacity = '0';
|
||||
view.style.transition = 'opacity 0.15s ease-out';
|
||||
});
|
||||
|
||||
// Show current view content with smooth transition
|
||||
const activeView = document.getElementById(`${activeRoute}-view`);
|
||||
if (activeView) {
|
||||
activeView.classList.add('active');
|
||||
// Small delay to ensure smooth transition
|
||||
setTimeout(() => {
|
||||
activeView.style.opacity = '1';
|
||||
activeView.style.transition = 'opacity 0.2s ease-in';
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
|
||||
// Register a component
|
||||
registerComponent(name, component) {
|
||||
this.components.set(name, component);
|
||||
}
|
||||
|
||||
// Get a component by name
|
||||
getComponent(name) {
|
||||
return this.components.get(name);
|
||||
}
|
||||
|
||||
// Get the event bus
|
||||
getEventBus() {
|
||||
return this.eventBus;
|
||||
}
|
||||
|
||||
// Initialize the application
|
||||
init() {
|
||||
console.log('SPORE UI Framework initialized');
|
||||
|
||||
// Note: Navigation is now handled by the app initialization
|
||||
// to ensure routes are registered before navigation
|
||||
}
|
||||
|
||||
// Setup navigation
|
||||
setupNavigation() {
|
||||
document.querySelectorAll('.nav-tab').forEach(tab => {
|
||||
tab.addEventListener('click', () => {
|
||||
const routeName = tab.dataset.view;
|
||||
this.navigateTo(routeName);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Clean up cached components (call when app is shutting down)
|
||||
cleanup() {
|
||||
console.log('App: Cleaning up cached components...');
|
||||
|
||||
this.componentCache.forEach((component, routeName) => {
|
||||
if (component.isMounted) {
|
||||
console.log(`App: Unmounting cached component '${routeName}'`);
|
||||
component.unmount();
|
||||
}
|
||||
});
|
||||
|
||||
this.componentCache.clear();
|
||||
this.cachedViews.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Global app instance
|
||||
window.app = new App();
|
||||
632
public/scripts/view-models.js
Normal file
632
public/scripts/view-models.js
Normal file
@@ -0,0 +1,632 @@
|
||||
// View Models for SPORE UI Components
|
||||
|
||||
// Cluster View Model with enhanced state preservation
|
||||
class ClusterViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
members: [],
|
||||
primaryNode: null,
|
||||
totalNodes: 0,
|
||||
clientInitialized: false,
|
||||
isLoading: false,
|
||||
error: null,
|
||||
expandedCards: new Map(),
|
||||
activeTabs: new Map(), // Store active tab for each node
|
||||
lastUpdateTime: null,
|
||||
onlineNodes: 0
|
||||
});
|
||||
|
||||
// Initialize cluster status after a short delay to allow components to subscribe
|
||||
setTimeout(() => {
|
||||
this.updatePrimaryNodeDisplay();
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Update cluster members with state preservation
|
||||
async updateClusterMembers() {
|
||||
try {
|
||||
console.log('ClusterViewModel: updateClusterMembers called');
|
||||
|
||||
// Store current UI state before update
|
||||
const currentUIState = this.getAllUIState();
|
||||
const currentExpandedCards = this.get('expandedCards');
|
||||
const currentActiveTabs = this.get('activeTabs');
|
||||
|
||||
this.set('isLoading', true);
|
||||
this.set('error', null);
|
||||
|
||||
console.log('ClusterViewModel: Fetching cluster members...');
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
console.log('ClusterViewModel: Got response:', response);
|
||||
|
||||
const members = response.members || [];
|
||||
const onlineNodes = Array.isArray(members)
|
||||
? members.filter(m => m && m.status === 'active').length
|
||||
: 0;
|
||||
|
||||
// Use batch update to preserve UI state
|
||||
this.batchUpdate({
|
||||
members: members,
|
||||
lastUpdateTime: new Date().toISOString(),
|
||||
onlineNodes: onlineNodes
|
||||
}, { preserveUIState: true });
|
||||
|
||||
// Restore expanded cards and active tabs
|
||||
this.set('expandedCards', currentExpandedCards);
|
||||
this.set('activeTabs', currentActiveTabs);
|
||||
|
||||
// Update primary node display
|
||||
console.log('ClusterViewModel: Updating primary node display...');
|
||||
await this.updatePrimaryNodeDisplay();
|
||||
|
||||
} catch (error) {
|
||||
console.error('ClusterViewModel: Failed to fetch cluster members:', error);
|
||||
this.set('error', error.message);
|
||||
} finally {
|
||||
this.set('isLoading', false);
|
||||
console.log('ClusterViewModel: updateClusterMembers completed');
|
||||
}
|
||||
}
|
||||
|
||||
// Update primary node display with state preservation
|
||||
async updatePrimaryNodeDisplay() {
|
||||
try {
|
||||
const discoveryInfo = await window.apiClient.getDiscoveryInfo();
|
||||
|
||||
// Use batch update to preserve UI state
|
||||
const updates = {};
|
||||
|
||||
if (discoveryInfo.primaryNode) {
|
||||
updates.primaryNode = discoveryInfo.primaryNode;
|
||||
updates.clientInitialized = discoveryInfo.clientInitialized;
|
||||
updates.totalNodes = discoveryInfo.totalNodes;
|
||||
} else if (discoveryInfo.totalNodes > 0) {
|
||||
updates.primaryNode = discoveryInfo.nodes[0]?.ip;
|
||||
updates.clientInitialized = false;
|
||||
updates.totalNodes = discoveryInfo.totalNodes;
|
||||
} else {
|
||||
updates.primaryNode = null;
|
||||
updates.clientInitialized = false;
|
||||
updates.totalNodes = 0;
|
||||
}
|
||||
|
||||
this.batchUpdate(updates, { preserveUIState: true });
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch discovery info:', error);
|
||||
this.set('error', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Select random primary node
|
||||
async selectRandomPrimaryNode() {
|
||||
try {
|
||||
const result = await window.apiClient.selectRandomPrimaryNode();
|
||||
|
||||
if (result.success) {
|
||||
// Update the display after a short delay
|
||||
setTimeout(() => {
|
||||
this.updatePrimaryNodeDisplay();
|
||||
}, 1500);
|
||||
|
||||
return result;
|
||||
} else {
|
||||
throw new Error(result.message || 'Random selection failed');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to select random primary node:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// Store expanded card state
|
||||
storeExpandedCard(memberIp, content) {
|
||||
const expandedCards = this.get('expandedCards');
|
||||
expandedCards.set(memberIp, content);
|
||||
this.set('expandedCards', expandedCards);
|
||||
|
||||
// Also store in UI state for persistence
|
||||
this.setUIState(`expanded_${memberIp}`, content);
|
||||
}
|
||||
|
||||
// Get expanded card state
|
||||
getExpandedCard(memberIp) {
|
||||
const expandedCards = this.get('expandedCards');
|
||||
return expandedCards.get(memberIp);
|
||||
}
|
||||
|
||||
// Clear expanded card state
|
||||
clearExpandedCard(memberIp) {
|
||||
const expandedCards = this.get('expandedCards');
|
||||
expandedCards.delete(memberIp);
|
||||
this.set('expandedCards', expandedCards);
|
||||
|
||||
// Also clear from UI state
|
||||
this.clearUIState(`expanded_${memberIp}`);
|
||||
}
|
||||
|
||||
// Store active tab for a specific node
|
||||
storeActiveTab(memberIp, tabName) {
|
||||
const activeTabs = this.get('activeTabs');
|
||||
activeTabs.set(memberIp, tabName);
|
||||
this.set('activeTabs', activeTabs);
|
||||
|
||||
// Also store in UI state for persistence
|
||||
this.setUIState(`activeTab_${memberIp}`, tabName);
|
||||
}
|
||||
|
||||
// Get active tab for a specific node
|
||||
getActiveTab(memberIp) {
|
||||
const activeTabs = this.get('activeTabs');
|
||||
return activeTabs.get(memberIp) || 'status'; // Default to 'status' tab
|
||||
}
|
||||
|
||||
// Check if data has actually changed to avoid unnecessary updates
|
||||
hasDataChanged(newData, dataType) {
|
||||
const currentData = this.get(dataType);
|
||||
|
||||
if (Array.isArray(newData) && Array.isArray(currentData)) {
|
||||
if (newData.length !== currentData.length) return true;
|
||||
|
||||
// Compare each member's key properties
|
||||
return newData.some((newMember, index) => {
|
||||
const currentMember = currentData[index];
|
||||
return !currentMember ||
|
||||
newMember.ip !== currentMember.ip ||
|
||||
newMember.status !== currentMember.status ||
|
||||
newMember.latency !== currentMember.latency;
|
||||
});
|
||||
}
|
||||
|
||||
return newData !== currentData;
|
||||
}
|
||||
|
||||
// Smart update that only updates changed data
|
||||
async smartUpdate() {
|
||||
try {
|
||||
console.log('ClusterViewModel: Performing smart update...');
|
||||
|
||||
// Fetch new data
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
const newMembers = response.members || [];
|
||||
|
||||
// Check if members data has actually changed
|
||||
if (this.hasDataChanged(newMembers, 'members')) {
|
||||
console.log('ClusterViewModel: Members data changed, updating...');
|
||||
await this.updateClusterMembers();
|
||||
} else {
|
||||
console.log('ClusterViewModel: Members data unchanged, skipping update');
|
||||
// Still update primary node display as it might have changed
|
||||
await this.updatePrimaryNodeDisplay();
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('ClusterViewModel: Smart update failed:', error);
|
||||
this.set('error', error.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Node Details View Model with enhanced state preservation
|
||||
class NodeDetailsViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
nodeStatus: null,
|
||||
tasks: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
activeTab: 'status',
|
||||
nodeIp: null,
|
||||
capabilities: null,
|
||||
tasksSummary: null
|
||||
});
|
||||
}
|
||||
|
||||
// Load node details with state preservation
|
||||
async loadNodeDetails(ip) {
|
||||
try {
|
||||
// Store current UI state
|
||||
const currentActiveTab = this.get('activeTab');
|
||||
|
||||
this.set('isLoading', true);
|
||||
this.set('error', null);
|
||||
this.set('nodeIp', ip);
|
||||
|
||||
const nodeStatus = await window.apiClient.getNodeStatus(ip);
|
||||
|
||||
// Use batch update to preserve UI state
|
||||
this.batchUpdate({
|
||||
nodeStatus: nodeStatus
|
||||
}, { preserveUIState: true });
|
||||
|
||||
// Restore active tab
|
||||
this.set('activeTab', currentActiveTab);
|
||||
|
||||
// Load tasks data
|
||||
await this.loadTasksData();
|
||||
|
||||
// Load capabilities data
|
||||
await this.loadCapabilitiesData();
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to load node details:', error);
|
||||
this.set('error', error.message);
|
||||
} finally {
|
||||
this.set('isLoading', false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load tasks data with state preservation
|
||||
async loadTasksData() {
|
||||
try {
|
||||
const ip = this.get('nodeIp');
|
||||
const response = await window.apiClient.getTasksStatus(ip);
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
// Load capabilities data with state preservation
|
||||
async loadCapabilitiesData() {
|
||||
try {
|
||||
const ip = this.get('nodeIp');
|
||||
const response = await window.apiClient.getCapabilities(ip);
|
||||
this.set('capabilities', response || null);
|
||||
} catch (error) {
|
||||
console.error('Failed to load capabilities:', error);
|
||||
this.set('capabilities', null);
|
||||
}
|
||||
}
|
||||
|
||||
// Invoke a capability against this node
|
||||
async callCapability(method, uri, params) {
|
||||
const ip = this.get('nodeIp');
|
||||
return window.apiClient.callCapability({ ip, method, uri, params });
|
||||
}
|
||||
|
||||
// Set active tab with state persistence
|
||||
setActiveTab(tabName) {
|
||||
console.log('NodeDetailsViewModel: Setting activeTab to:', tabName);
|
||||
this.set('activeTab', tabName);
|
||||
|
||||
// Store in UI state for persistence
|
||||
this.setUIState('activeTab', tabName);
|
||||
}
|
||||
|
||||
// Upload firmware
|
||||
async uploadFirmware(file, nodeIp) {
|
||||
try {
|
||||
const result = await window.apiClient.uploadFirmware(file, nodeIp);
|
||||
return result;
|
||||
} catch (error) {
|
||||
console.error('Firmware upload failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Firmware View Model
|
||||
class FirmwareViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
selectedFile: null,
|
||||
targetType: 'all',
|
||||
specificNode: null,
|
||||
availableNodes: [],
|
||||
uploadProgress: null,
|
||||
uploadResults: [],
|
||||
isUploading: false,
|
||||
selectedLabels: [],
|
||||
availableLabels: []
|
||||
});
|
||||
}
|
||||
|
||||
// Set selected file
|
||||
setSelectedFile(file) {
|
||||
this.set('selectedFile', file);
|
||||
}
|
||||
|
||||
// Set target type
|
||||
setTargetType(type) {
|
||||
this.set('targetType', type);
|
||||
// Clear any previously selected labels when switching options
|
||||
const currentLabels = this.get('selectedLabels') || [];
|
||||
if (currentLabels.length > 0) {
|
||||
this.set('selectedLabels', []);
|
||||
}
|
||||
}
|
||||
|
||||
// Set specific node
|
||||
setSpecificNode(nodeIp) {
|
||||
this.set('specificNode', nodeIp);
|
||||
}
|
||||
|
||||
// Update available nodes
|
||||
updateAvailableNodes(nodes) {
|
||||
this.set('availableNodes', nodes);
|
||||
// Compute availableLabels as unique key=value pairs from nodes' labels
|
||||
try {
|
||||
const labelSet = new Set();
|
||||
(nodes || []).forEach(n => {
|
||||
const labels = n && n.labels ? n.labels : {};
|
||||
Object.entries(labels).forEach(([k, v]) => {
|
||||
labelSet.add(`${k}=${v}`);
|
||||
});
|
||||
});
|
||||
const availableLabels = Array.from(labelSet).sort((a, b) => a.localeCompare(b));
|
||||
this.set('availableLabels', availableLabels);
|
||||
// Prune selected labels that are no longer available
|
||||
const selected = this.get('selectedLabels') || [];
|
||||
const pruned = selected.filter(x => availableLabels.includes(x));
|
||||
if (pruned.length !== selected.length) {
|
||||
this.set('selectedLabels', pruned);
|
||||
}
|
||||
} catch (_) {
|
||||
this.set('availableLabels', []);
|
||||
this.set('selectedLabels', []);
|
||||
}
|
||||
}
|
||||
|
||||
// Start upload
|
||||
startUpload() {
|
||||
this.set('isUploading', true);
|
||||
this.set('uploadProgress', {
|
||||
current: 0,
|
||||
total: 0,
|
||||
status: 'Preparing...'
|
||||
});
|
||||
this.set('uploadResults', []);
|
||||
}
|
||||
|
||||
// Update upload progress
|
||||
updateUploadProgress(current, total, status) {
|
||||
this.set('uploadProgress', {
|
||||
current,
|
||||
total,
|
||||
status
|
||||
});
|
||||
}
|
||||
|
||||
// Add upload result
|
||||
addUploadResult(result) {
|
||||
const results = this.get('uploadResults');
|
||||
results.push(result);
|
||||
this.set('uploadResults', results);
|
||||
}
|
||||
|
||||
// Complete upload
|
||||
completeUpload() {
|
||||
this.set('isUploading', false);
|
||||
}
|
||||
|
||||
// Reset upload state
|
||||
resetUploadState() {
|
||||
this.set('selectedFile', null);
|
||||
this.set('uploadProgress', null);
|
||||
this.set('uploadResults', []);
|
||||
this.set('isUploading', false);
|
||||
}
|
||||
|
||||
// Set selected labels
|
||||
setSelectedLabels(labels) {
|
||||
this.set('selectedLabels', Array.isArray(labels) ? labels : []);
|
||||
}
|
||||
|
||||
// Return nodes matching ALL selected label pairs
|
||||
getAffectedNodesByLabels() {
|
||||
const selected = this.get('selectedLabels') || [];
|
||||
if (!selected.length) return [];
|
||||
const selectedPairs = selected.map(s => {
|
||||
const idx = String(s).indexOf('=');
|
||||
return idx > -1 ? { key: s.slice(0, idx), value: s.slice(idx + 1) } : null;
|
||||
}).filter(Boolean);
|
||||
const nodes = this.get('availableNodes') || [];
|
||||
return nodes.filter(n => {
|
||||
const labels = n && n.labels ? n.labels : {};
|
||||
return selectedPairs.every(p => String(labels[p.key]) === String(p.value));
|
||||
});
|
||||
}
|
||||
|
||||
// Check if deploy button should be enabled
|
||||
isDeployEnabled() {
|
||||
const hasFile = this.get('selectedFile') !== null;
|
||||
const availableNodes = this.get('availableNodes');
|
||||
const hasAvailableNodes = availableNodes && availableNodes.length > 0;
|
||||
|
||||
let isValidTarget = false;
|
||||
if (this.get('targetType') === 'all') {
|
||||
isValidTarget = hasAvailableNodes;
|
||||
} else if (this.get('targetType') === 'specific') {
|
||||
isValidTarget = hasAvailableNodes && this.get('specificNode');
|
||||
} else if (this.get('targetType') === 'labels') {
|
||||
const affected = this.getAffectedNodesByLabels();
|
||||
isValidTarget = hasAvailableNodes && (this.get('selectedLabels') || []).length > 0 && affected.length > 0;
|
||||
}
|
||||
|
||||
return hasFile && isValidTarget && !this.get('isUploading');
|
||||
}
|
||||
}
|
||||
|
||||
// Navigation View Model
|
||||
class NavigationViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
activeView: 'cluster',
|
||||
views: ['cluster', 'firmware']
|
||||
});
|
||||
}
|
||||
|
||||
// Set active view
|
||||
setActiveView(viewName) {
|
||||
this.set('activeView', viewName);
|
||||
}
|
||||
|
||||
// Get active view
|
||||
getActiveView() {
|
||||
return this.get('activeView');
|
||||
}
|
||||
}
|
||||
|
||||
// Topology View Model for network topology visualization
|
||||
class TopologyViewModel extends ViewModel {
|
||||
constructor() {
|
||||
super();
|
||||
this.setMultiple({
|
||||
nodes: [],
|
||||
links: [],
|
||||
isLoading: false,
|
||||
error: null,
|
||||
lastUpdateTime: null,
|
||||
selectedNode: null
|
||||
});
|
||||
}
|
||||
|
||||
// Update network topology data
|
||||
async updateNetworkTopology() {
|
||||
try {
|
||||
console.log('TopologyViewModel: updateNetworkTopology called');
|
||||
|
||||
this.set('isLoading', true);
|
||||
this.set('error', null);
|
||||
|
||||
// Get cluster members from the primary node
|
||||
const response = await window.apiClient.getClusterMembers();
|
||||
console.log('TopologyViewModel: Got cluster members response:', response);
|
||||
|
||||
const members = response.members || [];
|
||||
|
||||
// Build enhanced graph data with actual node connections
|
||||
const { nodes, links } = await this.buildEnhancedGraphData(members);
|
||||
|
||||
this.batchUpdate({
|
||||
nodes: nodes,
|
||||
links: links,
|
||||
lastUpdateTime: new Date().toISOString()
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
console.error('TopologyViewModel: Failed to fetch network topology:', error);
|
||||
this.set('error', error.message);
|
||||
} finally {
|
||||
this.set('isLoading', false);
|
||||
console.log('TopologyViewModel: updateNetworkTopology completed');
|
||||
}
|
||||
}
|
||||
|
||||
// Build enhanced graph data with actual node connections
|
||||
async buildEnhancedGraphData(members) {
|
||||
const nodes = [];
|
||||
const links = [];
|
||||
const nodeConnections = new Map();
|
||||
|
||||
// Create nodes from members
|
||||
members.forEach((member, index) => {
|
||||
if (member && member.ip) {
|
||||
nodes.push({
|
||||
id: member.ip,
|
||||
hostname: member.hostname || member.ip,
|
||||
ip: member.ip,
|
||||
status: member.status || 'UNKNOWN',
|
||||
latency: member.latency || 0,
|
||||
resources: member.resources || {},
|
||||
x: Math.random() * 1200 + 100, // Better spacing for 1400px width
|
||||
y: Math.random() * 800 + 100 // Better spacing for 1000px height
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Try to get cluster members from each node to build actual connections
|
||||
for (const node of nodes) {
|
||||
try {
|
||||
const nodeResponse = await window.apiClient.getClusterMembersFromNode(node.ip);
|
||||
if (nodeResponse && nodeResponse.members) {
|
||||
nodeConnections.set(node.ip, nodeResponse.members);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn(`Failed to get cluster members from node ${node.ip}:`, error);
|
||||
// Continue with other nodes
|
||||
}
|
||||
}
|
||||
|
||||
// Build links based on actual connections
|
||||
for (const [sourceIp, sourceMembers] of nodeConnections) {
|
||||
for (const targetMember of sourceMembers) {
|
||||
if (targetMember.ip && targetMember.ip !== sourceIp) {
|
||||
// Check if we already have this link
|
||||
const existingLink = links.find(link =>
|
||||
(link.source === sourceIp && link.target === targetMember.ip) ||
|
||||
(link.source === targetMember.ip && link.target === sourceIp)
|
||||
);
|
||||
|
||||
if (!existingLink) {
|
||||
const sourceNode = nodes.find(n => n.id === sourceIp);
|
||||
const targetNode = nodes.find(n => n.id === targetMember.ip);
|
||||
|
||||
if (sourceNode && targetNode) {
|
||||
const latency = targetMember.latency || this.estimateLatency(sourceNode, targetNode);
|
||||
|
||||
links.push({
|
||||
source: sourceIp,
|
||||
target: targetMember.ip,
|
||||
latency: latency,
|
||||
sourceNode: sourceNode,
|
||||
targetNode: targetNode,
|
||||
bidirectional: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If no actual connections found, create a basic mesh
|
||||
if (links.length === 0) {
|
||||
console.log('TopologyViewModel: No actual connections found, creating basic mesh');
|
||||
for (let i = 0; i < nodes.length; i++) {
|
||||
for (let j = i + 1; j < nodes.length; j++) {
|
||||
const sourceNode = nodes[i];
|
||||
const targetNode = nodes[j];
|
||||
|
||||
const estimatedLatency = this.estimateLatency(sourceNode, targetNode);
|
||||
|
||||
links.push({
|
||||
source: sourceNode.id,
|
||||
target: targetNode.id,
|
||||
latency: estimatedLatency,
|
||||
sourceNode: sourceNode,
|
||||
targetNode: targetNode,
|
||||
bidirectional: true
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { nodes, links };
|
||||
}
|
||||
|
||||
// Estimate latency between two nodes
|
||||
estimateLatency(sourceNode, targetNode) {
|
||||
// Simple estimation - in a real implementation, you'd get actual measurements
|
||||
const baseLatency = 5; // Base latency in ms
|
||||
const randomVariation = Math.random() * 10; // Random variation
|
||||
return Math.round(baseLatency + randomVariation);
|
||||
}
|
||||
|
||||
// Select a node in the graph
|
||||
selectNode(nodeId) {
|
||||
this.set('selectedNode', nodeId);
|
||||
}
|
||||
|
||||
// Clear node selection
|
||||
clearSelection() {
|
||||
this.set('selectedNode', null);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user