536 lines
20 KiB
JavaScript
536 lines
20 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 id="upload-status" style="display: none;"></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();
|
|
}
|
|
});
|
|
|
|
// Set up file input change handler
|
|
const fileInput = container.querySelector('#firmware-file');
|
|
if (fileInput) {
|
|
fileInput.addEventListener('change', async (e) => {
|
|
e.stopPropagation();
|
|
const file = e.target.files[0];
|
|
if (file) {
|
|
await uploadFirmware(file, container);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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 upload firmware
|
|
async function uploadFirmware(file, container) {
|
|
const uploadStatus = container.querySelector('#upload-status');
|
|
const uploadBtn = container.querySelector('.upload-btn');
|
|
const originalText = uploadBtn.textContent;
|
|
|
|
try {
|
|
// Show upload status
|
|
uploadStatus.style.display = 'block';
|
|
uploadStatus.innerHTML = `
|
|
<div class="upload-progress">
|
|
<div>📤 Uploading ${file.name}...</div>
|
|
<div style="font-size: 0.8rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
|
|
</div>
|
|
`;
|
|
|
|
// Disable upload button
|
|
uploadBtn.disabled = true;
|
|
uploadBtn.textContent = '⏳ Uploading...';
|
|
|
|
// Get the member IP from the card
|
|
const memberCard = container.closest('.member-card');
|
|
const memberIp = memberCard.dataset.memberIp;
|
|
|
|
if (!memberIp) {
|
|
throw new Error('Could not determine target node IP address');
|
|
}
|
|
|
|
// Create FormData for multipart upload
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
// Upload to backend
|
|
const response = await fetch('/api/node/update?ip=' + encodeURIComponent(memberIp), {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json();
|
|
throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`);
|
|
}
|
|
|
|
const result = await response.json();
|
|
|
|
// Show success
|
|
uploadStatus.innerHTML = `
|
|
<div class="upload-success">
|
|
<div>✅ Firmware uploaded successfully!</div>
|
|
<div style="font-size: 0.8rem; opacity: 0.7;">Node: ${memberIp}</div>
|
|
<div style="font-size: 0.8rem; opacity: 0.7;">Size: ${(file.size / 1024).toFixed(1)}KB</div>
|
|
</div>
|
|
`;
|
|
|
|
console.log('Firmware upload successful:', result);
|
|
|
|
} catch (error) {
|
|
console.error('Firmware upload failed:', error);
|
|
|
|
// Show error
|
|
uploadStatus.innerHTML = `
|
|
<div class="upload-error">
|
|
<div>❌ Upload failed: ${error.message}</div>
|
|
</div>
|
|
`;
|
|
} finally {
|
|
// Re-enable upload button
|
|
uploadBtn.disabled = false;
|
|
uploadBtn.textContent = originalText;
|
|
|
|
// Clear file input
|
|
const fileInput = container.querySelector('#firmware-file');
|
|
if (fileInput) {
|
|
fileInput.value = '';
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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);
|