Files
spore-ui/public/script.js

445 lines
17 KiB
JavaScript

// Frontend API client - calls our Express backend
class FrontendApiClient {
constructor() {
this.baseUrl = ''; // Same origin as the current page
}
async getClusterMembers() {
try {
const response = await fetch('/api/cluster/members', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
async getNodeStatus(ip) {
try {
// Create a proxy endpoint that forwards the request to the specific node
const response = await fetch(`/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();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
async getTasksStatus() {
try {
const response = await fetch('/api/tasks/status', {
method: 'GET',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
throw new Error(`Request failed: ${error.message}`);
}
}
}
// Global client instance
const client = new FrontendApiClient();
// Function to refresh cluster members
async function refreshClusterMembers() {
const container = document.getElementById('cluster-members-container');
// Store the currently expanded cards BEFORE showing loading state
const expandedCards = new Map();
const existingCards = container.querySelectorAll('.member-card');
existingCards.forEach(card => {
if (card.classList.contains('expanded')) {
const memberIp = card.dataset.memberIp;
const memberDetails = card.querySelector('.member-details');
if (memberDetails) {
expandedCards.set(memberIp, memberDetails.innerHTML);
console.log(`Storing expanded state for ${memberIp}`);
}
}
});
console.log(`Stored ${expandedCards.size} expanded cards for restoration`);
// Show loading state
container.innerHTML = `
<div class="loading">
<div>Loading cluster members...</div>
</div>
`;
try {
const response = await client.getClusterMembers();
console.log(response);
displayClusterMembers(response.members, expandedCards);
} catch (error) {
console.error('Failed to fetch cluster members:', error);
container.innerHTML = `
<div class="error">
<strong>Error loading cluster members:</strong><br>
${error.message}
</div>
`;
}
}
// Function to load detailed node information
async function loadNodeDetails(card, memberIp) {
console.log('Loading node details for IP:', memberIp);
const memberDetails = card.querySelector('.member-details');
console.log('Member details element:', memberDetails);
try {
console.log('Fetching node status...');
const nodeStatus = await client.getNodeStatus(memberIp);
console.log('Node status received:', nodeStatus);
displayNodeDetails(memberDetails, nodeStatus);
} catch (error) {
console.error('Failed to load node details:', error);
memberDetails.innerHTML = `
<div class="error">
<strong>Error loading node details:</strong><br>
${error.message}
</div>
`;
}
}
// Function to display node details
function displayNodeDetails(container, nodeStatus) {
console.log('Displaying node details in container:', container);
console.log('Node status data:', nodeStatus);
container.innerHTML = `
<div class="tabs-container">
<div class="tabs-header">
<button class="tab-button active" data-tab="status">Status</button>
<button class="tab-button" data-tab="endpoints">Endpoints</button>
<button class="tab-button" data-tab="tasks">Tasks</button>
<button class="tab-button" data-tab="firmware">Firmware</button>
</div>
<div class="tab-content active" id="status-tab">
<div class="detail-row">
<span class="detail-label">Free Heap:</span>
<span class="detail-value">${Math.round(nodeStatus.freeHeap / 1024)}KB</span>
</div>
<div class="detail-row">
<span class="detail-label">Chip ID:</span>
<span class="detail-value">${nodeStatus.chipId}</span>
</div>
<div class="detail-row">
<span class="detail-label">SDK Version:</span>
<span class="detail-value">${nodeStatus.sdkVersion}</span>
</div>
<div class="detail-row">
<span class="detail-label">CPU Frequency:</span>
<span class="detail-value">${nodeStatus.cpuFreqMHz}MHz</span>
</div>
<div class="detail-row">
<span class="detail-label">Flash Size:</span>
<span class="detail-value">${Math.round(nodeStatus.flashChipSize / 1024)}KB</span>
</div>
</div>
<div class="tab-content" id="endpoints-tab">
<h4>Available API Endpoints:</h4>
${nodeStatus.api ? nodeStatus.api.map(endpoint =>
`<div class="endpoint-item">${endpoint.method === 1 ? 'GET' : 'POST'} ${endpoint.uri}</div>`
).join('') : '<div class="endpoint-item">No API endpoints available</div>'}
</div>
<div class="tab-content" id="tasks-tab">
<div class="loading-tasks">Loading tasks...</div>
</div>
<div class="tab-content" id="firmware-tab">
<div class="firmware-upload">
<h4>Firmware Update</h4>
<div class="upload-area">
<input type="file" id="firmware-file" accept=".bin,.hex" style="display: none;">
<button class="upload-btn" data-action="select-file">
📁 Choose Firmware File
</button>
<div class="upload-info">Select a .bin or .hex file to upload</div>
</div>
</div>
</div>
</div>
`;
// Set up tab switching
setupTabs(container);
// Load tasks data for the tasks tab
loadTasksData(container, nodeStatus);
console.log('Node details HTML set successfully');
}
// Function to set up tab switching
function setupTabs(container) {
const tabButtons = container.querySelectorAll('.tab-button');
const tabContents = container.querySelectorAll('.tab-content');
tabButtons.forEach(button => {
button.addEventListener('click', (e) => {
// Prevent the click event from bubbling up to the card
e.stopPropagation();
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');
}
});
});
// Also prevent event propagation on tab content areas
tabContents.forEach(content => {
content.addEventListener('click', (e) => {
e.stopPropagation();
});
});
// Set up firmware upload button
const uploadBtn = container.querySelector('.upload-btn[data-action="select-file"]');
if (uploadBtn) {
uploadBtn.addEventListener('click', (e) => {
e.stopPropagation();
const fileInput = container.querySelector('#firmware-file');
if (fileInput) {
fileInput.click();
}
});
}
}
// Function to load tasks data
async function loadTasksData(container, nodeStatus) {
const tasksTab = container.querySelector('#tasks-tab');
if (!tasksTab) return;
try {
const response = await client.getTasksStatus();
console.log('Tasks data received:', response);
if (response && response.length > 0) {
const tasksHTML = response.map(task => `
<div class="task-item">
<div class="task-header">
<span class="task-name">${task.name || 'Unknown Task'}</span>
<span class="task-status ${task.running ? 'running' : 'stopped'}">
${task.running ? '🟢 Running' : '🔴 Stopped'}
</span>
</div>
<div class="task-details">
<span class="task-interval">Interval: ${task.interval}ms</span>
<span class="task-enabled">${task.enabled ? '🟢 Enabled' : '🔴 Disabled'}</span>
</div>
</div>
`).join('');
tasksTab.innerHTML = `
<h4>Active Tasks</h4>
${tasksHTML}
`;
} else {
tasksTab.innerHTML = `
<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
</div>
</div>
`;
}
} catch (error) {
console.error('Failed to load tasks:', error);
tasksTab.innerHTML = `
<div class="error">
<strong>Error loading tasks:</strong><br>
${error.message}
</div>
`;
}
}
// Function to display cluster members
function displayClusterMembers(members, expandedCards = new Map()) {
const container = document.getElementById('cluster-members-container');
if (!members || members.length === 0) {
container.innerHTML = `
<div class="empty-state">
<div class="empty-state-icon">🌐</div>
<div>No cluster members found</div>
<div style="font-size: 0.9rem; margin-top: 0.5rem; opacity: 0.7;">
The cluster might be empty or not yet discovered
</div>
</div>
`;
return;
}
const membersHTML = members.map(member => {
const statusClass = member.status === 'active' ? 'status-online' : 'status-offline';
const statusText = member.status === 'active' ? 'Online' : 'Offline';
const statusIcon = member.status === 'active' ? '🟢' : '🔴';
return `
<div class="member-card" data-member-ip="${member.ip}">
<div class="member-header">
<div class="member-info">
<div class="member-name">${member.hostname || 'Unknown Device'}</div>
<div class="member-ip">${member.ip || 'No IP'}</div>
<div class="member-status ${statusClass}">
${statusIcon} ${statusText}
</div>
<div class="member-latency">
<span class="latency-label">Latency:</span>
<span class="latency-value">${member.latency ? member.latency + 'ms' : 'N/A'}</span>
</div>
</div>
<div class="expand-icon">▶️</div>
</div>
<div class="member-details">
<div class="loading-details">Loading detailed information...</div>
</div>
</div>
`;
}).join('');
container.innerHTML = membersHTML;
// Add event listeners for expand/collapse
console.log('Setting up event listeners for', members.length, 'member cards');
// Small delay to ensure DOM is ready
setTimeout(() => {
document.querySelectorAll('.member-card').forEach((card, index) => {
const expandIcon = card.querySelector('.expand-icon');
const memberDetails = card.querySelector('.member-details');
const memberIp = card.dataset.memberIp;
console.log(`Setting up card ${index} with IP: ${memberIp}`);
// Restore expanded state if this card was expanded before refresh
if (expandedCards.has(memberIp)) {
console.log(`Restoring expanded state for ${memberIp}`);
const restoredContent = expandedCards.get(memberIp);
console.log(`Restored content length: ${restoredContent.length} characters`);
memberDetails.innerHTML = restoredContent;
card.classList.add('expanded');
expandIcon.classList.add('expanded');
// Re-setup tabs for restored content
setupTabs(memberDetails);
console.log(`Successfully restored expanded state for ${memberIp}`);
} else {
console.log(`No expanded state to restore for ${memberIp}`);
}
// Make the entire card clickable
card.addEventListener('click', async (e) => {
// Don't trigger if clicking on the expand icon (to avoid double-triggering)
if (e.target === expandIcon) {
return;
}
console.log('Card clicked for IP:', memberIp);
const isExpanding = !card.classList.contains('expanded');
console.log('Is expanding:', isExpanding);
if (isExpanding) {
// Expanding - fetch detailed status
console.log('Starting to expand...');
await loadNodeDetails(card, memberIp);
card.classList.add('expanded');
expandIcon.classList.add('expanded');
console.log('Card expanded successfully');
} else {
// Collapsing
console.log('Collapsing...');
card.classList.remove('expanded');
expandIcon.classList.remove('expanded');
console.log('Card collapsed successfully');
}
});
// Keep the expand icon click handler for visual feedback
if (expandIcon) {
expandIcon.addEventListener('click', async (e) => {
e.stopPropagation();
console.log('Expand icon clicked for IP:', memberIp);
const isExpanding = !card.classList.contains('expanded');
console.log('Is expanding:', isExpanding);
if (isExpanding) {
// Expanding - fetch detailed status
console.log('Starting to expand...');
await loadNodeDetails(card, memberIp);
card.classList.add('expanded');
expandIcon.classList.add('expanded');
console.log('Card expanded successfully');
} else {
// Collapsing
console.log('Collapsing...');
card.classList.remove('expanded');
expandIcon.classList.remove('expanded');
console.log('Card collapsed successfully');
}
});
console.log(`Event listener added for expand icon on card ${index}`);
} else {
console.error(`No expand icon found for card ${index}`);
}
console.log(`Event listener added for card ${index}`);
});
}, 100);
}
// Load cluster members when page loads
document.addEventListener('DOMContentLoaded', function() {
refreshClusterMembers();
});
// Auto-refresh every 30 seconds
// FIXME not working properly: scroll position is not preserved, if there is an upload happening, this mus also be handled
//setInterval(refreshClusterMembers, 30000);